From c3f21c5b305a78141e87d5f987f8217f7437b4b4 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 8 Jun 2026 16:08:54 +0200 Subject: [PATCH 1/3] feat(minerva): add real runtime verification profile Refs #687 --- docs/export/minerva.md | 33 +- .../ROOT/pages/how-to/minerva-export.adoc | 37 +- scripts/run-minerva-real-runtime-profile.sh | 110 ++++++ .../api/skainet-compile-minerva.api | 12 +- .../skainet-compile-minerva/build.gradle.kts | 5 + .../compile/minerva/MinervaNpzModelWriter.kt | 55 ++- .../minerva/MinervaExportFacadeTest.kt | 2 +- .../minerva/MinervaNpzModelWriterTest.kt | 8 +- .../minerva/MinervaJvmCompilerAndPackager.kt | 144 ++++++- .../examples/MinervaTinyMlpExportSample.kt | 7 + .../MinervaJvmCompilerAndPackagerTest.kt | 363 +++++++++++++++++- .../MinervaTinyMlpExportSampleTest.kt | 6 +- 12 files changed, 706 insertions(+), 76 deletions(-) create mode 100755 scripts/run-minerva-real-runtime-profile.sh diff --git a/docs/export/minerva.md b/docs/export/minerva.md index dff35cc5..fea922e5 100644 --- a/docs/export/minerva.md +++ b/docs/export/minerva.md @@ -9,19 +9,18 @@ Inside this repository, use `project(":skainet-compile:skainet-compile-minerva") Configure libminerva through `MinervaExportOptions` or the JVM sample environment: ```bash -export MINERVA_COMPILER_SCRIPT=/opt/libminerva/tools/compile_model.py +export MINERVA_COMPILER_SCRIPT=/opt/libminerva/compiler/minerva_compile.py export MINERVA_RUNTIME_ROOT=/opt/libminerva export MINERVA_CALIBRATION_NPZ=/secure/project/calibration.npz export MINERVA_KEY_FILE=/secure/project/device.key export MINERVA_RUN_CMAKE=true export MINERVA_RUN_CTEST=true +export MINERVA_HOST_TOLERANCE=1.0 export MINERVA_HOST_ADAPTER_SOURCE=/secure/project/minerva_runtime_adapter.c -export MINERVA_HOST_INCLUDE_DIRS=/opt/libminerva/include -export MINERVA_HOST_LIBRARY_DIRS=/opt/libminerva/lib -export MINERVA_HOST_LIBRARIES=minerva +export MINERVA_HOST_INCLUDE_DIRS=/secure/project/minerva-host-secrets ``` -Do not commit real device keys. `include/secrets.example.h` contains placeholders only. +Do not commit real device keys. `include/secrets.example.h` contains placeholders only. For host verification against libminerva, `MINERVA_HOST_INCLUDE_DIRS` can point at an untracked directory that contains a host-only `secrets.h` defining `MNV_DEVICE_KEY`. ## Compatibility @@ -36,13 +35,15 @@ Do not commit real device keys. `include/secrets.example.h` contains placeholder | Activations | `Relu`, `Sigmoid`, `Tanh` after a dense layer | | Out of scope | CNNs, attention, recurrent models, dynamic shapes, branching graphs, transformers, and arbitrary imported operator sets | +`model.npz` stores activation names as scalar NumPy Unicode arrays (`relu`, `sigmoid`, `tanh`, or `linear`) because the current libminerva compiler reads `layer_i_act` as a string. + ## Export API ```kotlin val options = MinervaExportOptions( outputDir = "build/minerva", projectName = "TinySecureMlp", - compilerScript = "/opt/libminerva/tools/compile_model.py", + compilerScript = "/opt/libminerva/compiler/minerva_compile.py", runtimeRoot = "/opt/libminerva", calibrationNpz = "/secure/project/calibration.npz", keyFile = "/secure/project/device.key" @@ -93,7 +94,7 @@ The generated host harness has a stable adapter ABI: int minerva_run_inference(const float *input, int input_count, float *output, int output_count); ``` -`host/runtime_adapter.example.c` implements that ABI against the libminerva public C API (`mnv_init`, `mnv_seed_prng`, `mnv_run`, and `mnv_verify_output`). The adapter converts SKaiNET's normalized float fixtures to libminerva Q8 activation buffers and converts Q8 outputs back to floats for parity comparison. +`host/runtime_adapter.example.c` implements that ABI against the current libminerva runtime symbols (`mnv_init`, `mnv_seed_prng`, `mnv_run_with_model`, and `mnv_verify_output_with_key`). The adapter keeps compile-time switches for runtimes that expose the older `mnv_run` / `mnv_verify_output` names. It converts SKaiNET's normalized float fixtures to libminerva Q8 activation buffers and converts Q8 outputs back to floats for parity comparison. Copy the adapter outside the generated bundle when product-specific scaling or entropy seeding needs local edits, then point CMake at the copied source. This keeps the generated host harness stable while leaving runtime policy in one reviewable adapter file. @@ -105,14 +106,12 @@ metadata = mapOf( MinervaHostVerificationMetadata.RUN_CTEST to "true", MinervaHostVerificationMetadata.HOST_OUTPUT_PATH to "host-output.txt", MinervaHostVerificationMetadata.HOST_ADAPTER_SOURCE to "/secure/project/minerva_runtime_adapter.c", - MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS to "/opt/libminerva/include", - MinervaHostVerificationMetadata.HOST_LIBRARY_DIRS to "/opt/libminerva/lib", - MinervaHostVerificationMetadata.HOST_LIBRARIES to "minerva" + MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS to "/secure/project/minerva-host-secrets" ) ``` `HOST_OUTPUT_PATH` is optional when the host run writes `host/observed-output.txt`. -The include, library directory, and library values are passed to CMake as semicolon-separated lists, matching CMake list syntax. +The generated CMake can build a checkout-style libminerva runtime from `runtimeRoot` when that directory contains `CMakeLists.txt`. Include, library directory, and library values are passed to CMake as semicolon-separated lists, matching CMake list syntax; use `HOST_LIBRARY_DIRS` and `HOST_LIBRARIES` only when linking an already-built runtime. CI recipe: @@ -124,13 +123,19 @@ CI recipe: -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" \ -Pminerva.calibrationNpz="$MINERVA_CALIBRATION_NPZ" \ -Pminerva.keyFile="$MINERVA_KEY_FILE" \ + -Pminerva.hostVerification.tolerance="${MINERVA_HOST_TOLERANCE:-1.0}" \ -Pminerva.hostVerification.hostAdapterSource="$MINERVA_HOST_ADAPTER_SOURCE" \ - -Pminerva.hostVerification.hostIncludeDirs="$MINERVA_HOST_INCLUDE_DIRS" \ - -Pminerva.hostVerification.hostLibraryDirs="$MINERVA_HOST_LIBRARY_DIRS" \ - -Pminerva.hostVerification.hostLibraries="$MINERVA_HOST_LIBRARIES" + -Pminerva.hostVerification.hostIncludeDirs="$MINERVA_HOST_INCLUDE_DIRS" ``` `minervaHostVerification` is skipped by default. When enabled, it runs `jvmTest` and `runMinervaTinyMlpSample` with CMake and CTest host verification enabled unless `-Pminerva.hostVerification.runCmakeBuild=false` or `-Pminerva.hostVerification.runCTest=false` is set. +The default parity tolerance remains `1e-3`; the real checkout profile sets `MINERVA_HOST_TOLERANCE=1.0` by default because current libminerva Q8 host outputs are useful as a runtime smoke proof but are not yet numerically close to the SKaiNET float reference. + +For a local checkout proof, the helper below creates an untracked key, calibration archive, host-only `secrets.h`, and host-only AVR `pgmspace.h` compatibility shim under `build/minerva-real-runtime-profile`, then runs the gated verification task with CMake and CTest enabled: + +```bash +MINERVA_RUNTIME_ROOT=/opt/libminerva ./scripts/run-minerva-real-runtime-profile.sh +``` ## Model Sources diff --git a/docs/modules/ROOT/pages/how-to/minerva-export.adoc b/docs/modules/ROOT/pages/how-to/minerva-export.adoc index c8e05c8b..4843b58f 100644 --- a/docs/modules/ROOT/pages/how-to/minerva-export.adoc +++ b/docs/modules/ROOT/pages/how-to/minerva-export.adoc @@ -45,19 +45,18 @@ Configure libminerva through `MinervaExportOptions` or environment variables use [source,bash] ---- -export MINERVA_COMPILER_SCRIPT=/opt/libminerva/tools/compile_model.py +export MINERVA_COMPILER_SCRIPT=/opt/libminerva/compiler/minerva_compile.py export MINERVA_RUNTIME_ROOT=/opt/libminerva export MINERVA_CALIBRATION_NPZ=/secure/project/calibration.npz export MINERVA_KEY_FILE=/secure/project/device.key export MINERVA_RUN_CMAKE=true export MINERVA_RUN_CTEST=true +export MINERVA_HOST_TOLERANCE=1.0 export MINERVA_HOST_ADAPTER_SOURCE=/secure/project/minerva_runtime_adapter.c -export MINERVA_HOST_INCLUDE_DIRS=/opt/libminerva/include -export MINERVA_HOST_LIBRARY_DIRS=/opt/libminerva/lib -export MINERVA_HOST_LIBRARIES=minerva +export MINERVA_HOST_INCLUDE_DIRS=/secure/project/minerva-host-secrets ---- -`MINERVA_KEY_FILE` and the generated `include/secrets.example.h` are placeholders for integration. Do not commit real device keys or derived secrets. +`MINERVA_KEY_FILE` and the generated `include/secrets.example.h` are placeholders for integration. Do not commit real device keys or derived secrets. For host verification against libminerva, `MINERVA_HOST_INCLUDE_DIRS` can point at an untracked directory that contains a host-only `secrets.h` defining `MNV_DEVICE_KEY`. == Compatibility Matrix @@ -90,6 +89,8 @@ export MINERVA_HOST_LIBRARIES=minerva | CNNs, attention blocks, recurrent models, dynamic shapes, branching graphs, transformers, and arbitrary imported operator sets. |=== +`model.npz` stores activation names as scalar NumPy Unicode arrays (`relu`, `sigmoid`, `tanh`, or `linear`) because the current libminerva compiler reads `layer_i_act` as a string. + == Export API The public entry point is `MinervaExportFacade`. The facade accepts an already-built `ComputeGraph`, or it can record a representative forward pass for compatible SKaiNET models. @@ -102,7 +103,7 @@ import sk.ainet.compile.minerva.MinervaExportOptions val options = MinervaExportOptions( outputDir = "build/minerva", projectName = "TinySecureMlp", - compilerScript = "/opt/libminerva/tools/compile_model.py", + compilerScript = "/opt/libminerva/compiler/minerva_compile.py", runtimeRoot = "/opt/libminerva", calibrationNpz = "/secure/project/calibration.npz", keyFile = "/secure/project/device.key" @@ -161,7 +162,7 @@ The generated host harness has a stable adapter ABI: int minerva_run_inference(const float *input, int input_count, float *output, int output_count); ---- -`host/runtime_adapter.example.c` implements that ABI against the libminerva public C API: `mnv_init`, `mnv_seed_prng`, `mnv_run`, and `mnv_verify_output`. The adapter converts SKaiNET's normalized float fixtures to libminerva Q8 activation buffers and converts Q8 outputs back to floats for parity comparison. +`host/runtime_adapter.example.c` implements that ABI against the current libminerva runtime symbols: `mnv_init`, `mnv_seed_prng`, `mnv_run_with_model`, and `mnv_verify_output_with_key`. The adapter keeps compile-time switches for runtimes that expose the older `mnv_run` / `mnv_verify_output` names. It converts SKaiNET's normalized float fixtures to libminerva Q8 activation buffers and converts Q8 outputs back to floats for parity comparison. Copy the adapter outside the generated bundle when product-specific scaling or entropy seeding needs local edits, then point CMake at the copied source. This keeps the generated host harness stable while leaving runtime policy in one reviewable adapter file. @@ -174,16 +175,14 @@ metadata = mapOf( MinervaHostVerificationMetadata.RUN_CTEST to "true", MinervaHostVerificationMetadata.HOST_OUTPUT_PATH to "host-output.txt", MinervaHostVerificationMetadata.HOST_ADAPTER_SOURCE to "/secure/project/minerva_runtime_adapter.c", - MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS to "/opt/libminerva/include", - MinervaHostVerificationMetadata.HOST_LIBRARY_DIRS to "/opt/libminerva/lib", - MinervaHostVerificationMetadata.HOST_LIBRARIES to "minerva" + MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS to "/secure/project/minerva-host-secrets" ) ---- `RUN_CMAKE_BUILD` configures and builds `host/CMakeLists.txt`. `RUN_CTEST` runs the packaged CTest smoke test. `HOST_OUTPUT_PATH` lets a real host run write comma- or whitespace-separated float outputs that are compared with the SKaiNET reference output using `hostVerificationTolerance`. `HOST_OUTPUT_PATH` is optional when the host run writes the default `host/observed-output.txt` file. -The include, library directory, and library values are passed to CMake as semicolon-separated lists, matching CMake list syntax. +The generated CMake can build a checkout-style libminerva runtime from `runtimeRoot` when that directory contains `CMakeLists.txt`. Include, library directory, and library values are passed to CMake as semicolon-separated lists, matching CMake list syntax; use `HOST_LIBRARY_DIRS` and `HOST_LIBRARIES` only when linking an already-built runtime. Local CI recipe: @@ -196,13 +195,20 @@ Local CI recipe: -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" \ -Pminerva.calibrationNpz="$MINERVA_CALIBRATION_NPZ" \ -Pminerva.keyFile="$MINERVA_KEY_FILE" \ + -Pminerva.hostVerification.tolerance="${MINERVA_HOST_TOLERANCE:-1.0}" \ -Pminerva.hostVerification.hostAdapterSource="$MINERVA_HOST_ADAPTER_SOURCE" \ - -Pminerva.hostVerification.hostIncludeDirs="$MINERVA_HOST_INCLUDE_DIRS" \ - -Pminerva.hostVerification.hostLibraryDirs="$MINERVA_HOST_LIBRARY_DIRS" \ - -Pminerva.hostVerification.hostLibraries="$MINERVA_HOST_LIBRARIES" + -Pminerva.hostVerification.hostIncludeDirs="$MINERVA_HOST_INCLUDE_DIRS" ---- The Gradle `minervaHostVerification` task is gated. It only runs when `minerva.hostVerification.enabled=true`, `minerva.runtimeRoot`, and `minerva.compilerScript` are present. When enabled, it runs `jvmTest` and `runMinervaTinyMlpSample` with `MINERVA_RUN_CMAKE=true` and `MINERVA_RUN_CTEST=true` by default. Override `-Pminerva.hostVerification.runCmakeBuild=false` or `-Pminerva.hostVerification.runCTest=false` only for runtime bring-up. +The default parity tolerance remains `1e-3`; the real checkout profile sets `MINERVA_HOST_TOLERANCE=1.0` by default because current libminerva Q8 host outputs are useful as a runtime smoke proof but are not yet numerically close to the SKaiNET float reference. + +For a local checkout proof, the helper below creates an untracked key, calibration archive, host-only `secrets.h`, and host-only AVR `pgmspace.h` compatibility shim under `build/minerva-real-runtime-profile`, then runs the gated verification task with CMake and CTest enabled: + +[source,bash] +---- +MINERVA_RUNTIME_ROOT=/opt/libminerva ./scripts/run-minerva-real-runtime-profile.sh +---- == Firmware Integration @@ -263,6 +269,7 @@ Without `MINERVA_COMPILER_SCRIPT`, the sample task runs a dry validation through -Pminerva.runtimeRoot="$MINERVA_RUNTIME_ROOT" \ -Pminerva.calibrationNpz="$MINERVA_CALIBRATION_NPZ" \ -Pminerva.keyFile="$MINERVA_KEY_FILE" \ + -Pminerva.hostVerification.tolerance="${MINERVA_HOST_TOLERANCE:-1.0}" \ -Pminerva.hostVerification.runCmakeBuild=true \ -Pminerva.hostVerification.runCTest=true ---- @@ -295,7 +302,7 @@ The sample graph is covered by `MinervaTinyMlpExportSampleTest`, which validates | Reduce the graph to the phase-one MLP pattern, or use StableHLO for a general compiler flow. | CMake or CTest verification fails -| Inspect `minerva-host-verification.log`, confirm CMake is installed, and confirm the generated host harness is linked against the pinned libminerva runtime. +| Inspect `host/build/cmake-configure.log`, `host/build/cmake-build.log`, or `host/build/ctest.log`; confirm CMake is installed, `secrets.h` is available from `MINERVA_HOST_INCLUDE_DIRS`, and the generated host harness is linked against the pinned libminerva runtime. | Secret leak check fails | Remove real secrets from generated files and regenerate the bundle. Only placeholders belong in source control. diff --git a/scripts/run-minerva-real-runtime-profile.sh b/scripts/run-minerva-real-runtime-profile.sh new file mode 100755 index 00000000..8b5cb9d4 --- /dev/null +++ b/scripts/run-minerva-real-runtime-profile.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUNTIME_ROOT="${MINERVA_RUNTIME_ROOT:-${1:-}}" + +if [[ -z "${RUNTIME_ROOT}" ]]; then + echo "Usage: MINERVA_RUNTIME_ROOT=/path/to/libminerva $0" >&2 + echo " or: $0 /path/to/libminerva" >&2 + exit 2 +fi + +RUNTIME_ROOT="$(cd "${RUNTIME_ROOT}" && pwd)" +PYTHON_BIN="${PYTHON:-python3}" +COMPILER_SCRIPT="${MINERVA_COMPILER_SCRIPT:-${RUNTIME_ROOT}/compiler/minerva_compile.py}" +PROFILE_DIR="${MINERVA_PROFILE_DIR:-${ROOT_DIR}/build/minerva-real-runtime-profile}" +PROJECT_DIR="${ROOT_DIR}/build/minerva/TinySecureMlp" +KEY_FILE="${MINERVA_KEY_FILE:-${PROFILE_DIR}/device.key}" +CALIBRATION_NPZ="${MINERVA_CALIBRATION_NPZ:-${PROFILE_DIR}/calibration.npz}" +SECRET_INCLUDE_DIR="${PROFILE_DIR}/secret-include" +SECRET_HEADER="${SECRET_INCLUDE_DIR}/secrets.h" +AVR_PGMSPACE_HEADER="${SECRET_INCLUDE_DIR}/avr/pgmspace.h" +HOST_ADAPTER_SOURCE="${MINERVA_HOST_ADAPTER_SOURCE:-${PROJECT_DIR}/host/runtime_adapter.example.c}" +HOST_TOLERANCE="${MINERVA_HOST_TOLERANCE:-1.0}" + +mkdir -p "${PROFILE_DIR}" "${SECRET_INCLUDE_DIR}" "$(dirname "${AVR_PGMSPACE_HEADER}")" + +if [[ ! -f "${COMPILER_SCRIPT}" ]]; then + echo "Minerva compiler script not found: ${COMPILER_SCRIPT}" >&2 + exit 2 +fi + +if [[ ! -f "${KEY_FILE}" ]]; then + "${PYTHON_BIN}" "${COMPILER_SCRIPT}" --gen-key "${KEY_FILE}" +fi + +"${PYTHON_BIN}" - "${CALIBRATION_NPZ}" <<'PY' +from pathlib import Path +import sys +import numpy as np + +path = Path(sys.argv[1]) +path.parent.mkdir(parents=True, exist_ok=True) +samples = np.array( + [ + [0.25, 0.50, 0.75, 1.00], + [0.10, 0.25, 0.50, 0.75], + [-0.25, 0.00, 0.25, 0.50], + [1.00, 0.75, 0.50, 0.25], + ], + dtype=np.float32, +) +np.savez(path, X=samples) +PY + +"${PYTHON_BIN}" - "${KEY_FILE}" "${SECRET_HEADER}" <<'PY' +from pathlib import Path +import sys + +key_path = Path(sys.argv[1]) +header_path = Path(sys.argv[2]) +key = key_path.read_bytes() +if len(key) < 32: + raise SystemExit(f"Minerva key must contain at least 32 bytes: {key_path}") +values = ", ".join(f"0x{byte:02X}" for byte in key[:32]) +header_path.parent.mkdir(parents=True, exist_ok=True) +header_path.write_text( + "#pragma once\n" + "#include \n" + "static const uint8_t skainet_minerva_host_key[32] = { " + + values + + " };\n" + "#define MNV_DEVICE_KEY skainet_minerva_host_key\n" +) +PY + +cat > "${AVR_PGMSPACE_HEADER}" <<'EOF' +#pragma once +#include + +#ifndef PROGMEM +#define PROGMEM +#endif + +#ifndef memcpy_P +#define memcpy_P(destination, source, size) memcpy((destination), (source), (size)) +#endif + +#ifndef pgm_read_byte +#define pgm_read_byte(address) (*(const unsigned char *)(address)) +#endif +EOF + +cd "${ROOT_DIR}" + +./gradlew :skainet-compile:skainet-compile-minerva:minervaHostVerification \ + -Pminerva.hostVerification.enabled=true \ + -Pminerva.runtimeRoot="${RUNTIME_ROOT}" \ + -Pminerva.compilerScript="${COMPILER_SCRIPT}" \ + -Pminerva.keyFile="${KEY_FILE}" \ + -Pminerva.calibrationNpz="${CALIBRATION_NPZ}" \ + -Pminerva.hostVerification.tolerance="${HOST_TOLERANCE}" \ + -Pminerva.hostVerification.hostAdapterSource="${HOST_ADAPTER_SOURCE}" \ + -Pminerva.hostVerification.hostIncludeDirs="${SECRET_INCLUDE_DIR}" \ + -Pminerva.hostVerification.runCmakeBuild=true \ + -Pminerva.hostVerification.runCTest=true + +echo "Minerva real-runtime profile completed." +echo "Bundle: ${PROJECT_DIR}" +echo "Profile artifacts: ${PROFILE_DIR}" diff --git a/skainet-compile/skainet-compile-minerva/api/skainet-compile-minerva.api b/skainet-compile/skainet-compile-minerva/api/skainet-compile-minerva.api index 71bd6474..b43ebad6 100644 --- a/skainet-compile/skainet-compile-minerva/api/skainet-compile-minerva.api +++ b/skainet-compile/skainet-compile-minerva/api/skainet-compile-minerva.api @@ -520,15 +520,16 @@ public final class sk/ainet/compile/minerva/MinervaLoweringException : java/lang } public final class sk/ainet/compile/minerva/MinervaNpzArray { - public fun (Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V - public synthetic fun (Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Lsk/ainet/compile/minerva/MinervaNpzDType; public final fun component3 ()Ljava/util/List; public final fun component4 ()Ljava/util/List; public final fun component5 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lsk/ainet/compile/minerva/MinervaNpzArray; - public static synthetic fun copy$default (Lsk/ainet/compile/minerva/MinervaNpzArray;Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lsk/ainet/compile/minerva/MinervaNpzArray; + public final fun component6 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lsk/ainet/compile/minerva/MinervaNpzArray; + public static synthetic fun copy$default (Lsk/ainet/compile/minerva/MinervaNpzArray;Ljava/lang/String;Lsk/ainet/compile/minerva/MinervaNpzDType;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lsk/ainet/compile/minerva/MinervaNpzArray; public fun equals (Ljava/lang/Object;)Z public final fun getDtype ()Lsk/ainet/compile/minerva/MinervaNpzDType; public final fun getElementCount ()I @@ -536,6 +537,8 @@ public final class sk/ainet/compile/minerva/MinervaNpzArray { public final fun getIntData ()Ljava/util/List; public final fun getName ()Ljava/lang/String; public final fun getShape ()Ljava/util/List; + public final fun getStringData ()Ljava/util/List; + public final fun getStringElementWidth ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } @@ -543,6 +546,7 @@ public final class sk/ainet/compile/minerva/MinervaNpzArray { public final class sk/ainet/compile/minerva/MinervaNpzDType : java/lang/Enum { public static final field FLOAT32 Lsk/ainet/compile/minerva/MinervaNpzDType; public static final field INT32 Lsk/ainet/compile/minerva/MinervaNpzDType; + public static final field STRING Lsk/ainet/compile/minerva/MinervaNpzDType; public static fun getEntries ()Lkotlin/enums/EnumEntries; public final fun getNumpyDescriptor ()Ljava/lang/String; public static fun valueOf (Ljava/lang/String;)Lsk/ainet/compile/minerva/MinervaNpzDType; diff --git a/skainet-compile/skainet-compile-minerva/build.gradle.kts b/skainet-compile/skainet-compile-minerva/build.gradle.kts index b5d5e4e8..8f4fbab3 100644 --- a/skainet-compile/skainet-compile-minerva/build.gradle.kts +++ b/skainet-compile/skainet-compile-minerva/build.gradle.kts @@ -38,6 +38,7 @@ val minervaKeyFile = providers.gradleProperty("minerva.keyFile") val minervaCalibrationNpz = providers.gradleProperty("minerva.calibrationNpz") val minervaRunCmakeBuild = providers.gradleProperty("minerva.hostVerification.runCmakeBuild") val minervaRunCTest = providers.gradleProperty("minerva.hostVerification.runCTest") +val minervaHostVerificationTolerance = providers.gradleProperty("minerva.hostVerification.tolerance") val minervaHostOutputPath = providers.gradleProperty("minerva.hostVerification.hostOutputPath") val minervaHostAdapterSource = providers.gradleProperty("minerva.hostVerification.hostAdapterSource") val minervaHostIncludeDirs = providers.gradleProperty("minerva.hostVerification.hostIncludeDirs") @@ -67,6 +68,7 @@ tasks.register("minervaHostVerification") { inputs.property("minerva.keyFile", minervaKeyFile.orElse("")) inputs.property("minerva.hostVerification.runCmakeBuild", minervaRunCmakeBuildForSample) inputs.property("minerva.hostVerification.runCTest", minervaRunCTestForSample) + inputs.property("minerva.hostVerification.tolerance", minervaHostVerificationTolerance.orElse("")) inputs.property("minerva.hostVerification.hostOutputPath", minervaHostOutputPath.orElse("")) inputs.property("minerva.hostVerification.hostAdapterSource", minervaHostAdapterSource.orElse("")) inputs.property("minerva.hostVerification.hostIncludeDirs", minervaHostIncludeDirs.orElse("")) @@ -106,6 +108,9 @@ tasks.register("runMinervaTinyMlpSample") { minervaRunCTestForSample.orNull?.let { environment("MINERVA_RUN_CTEST", it) } + minervaHostVerificationTolerance.orElse(providers.environmentVariable("MINERVA_HOST_TOLERANCE")).orNull?.let { + environment("MINERVA_HOST_TOLERANCE", it) + } minervaHostOutputPath.orElse(providers.environmentVariable("MINERVA_HOST_OUTPUT_PATH")).orNull?.let { environment("MINERVA_HOST_OUTPUT_PATH", it) } diff --git a/skainet-compile/skainet-compile-minerva/src/commonMain/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriter.kt b/skainet-compile/skainet-compile-minerva/src/commonMain/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriter.kt index bd359f9b..8f99faec 100644 --- a/skainet-compile/skainet-compile-minerva/src/commonMain/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriter.kt +++ b/skainet-compile/skainet-compile-minerva/src/commonMain/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriter.kt @@ -11,7 +11,8 @@ import sk.ainet.compile.export.GraphExportWriter */ public enum class MinervaNpzDType(public val numpyDescriptor: String) { FLOAT32(", public val floatData: List = emptyList(), - public val intData: List = emptyList() + public val intData: List = emptyList(), + public val stringData: List = emptyList() ) { init { require(name.isNotBlank()) { "array name cannot be blank" } - require(shape.isNotEmpty()) { "array shape cannot be empty" } + require(shape.isNotEmpty() || dtype == MinervaNpzDType.STRING) { "array shape cannot be empty" } require(shape.all { it >= 0 }) { "array shape dimensions must be non-negative" } val elementCount = shape.fold(1) { acc, dim -> acc * dim } when (dtype) { MinervaNpzDType.FLOAT32 -> { require(floatData.size == elementCount) { "floatData size must match array element count" } require(intData.isEmpty()) { "intData must be empty for FLOAT32 arrays" } + require(stringData.isEmpty()) { "stringData must be empty for FLOAT32 arrays" } require(floatData.all { it.isFinite() }) { "floatData values must be finite" } } MinervaNpzDType.INT32 -> { require(intData.size == elementCount) { "intData size must match array element count" } require(floatData.isEmpty()) { "floatData must be empty for INT32 arrays" } + require(stringData.isEmpty()) { "stringData must be empty for INT32 arrays" } + } + MinervaNpzDType.STRING -> { + require(stringData.size == elementCount) { "stringData size must match array element count" } + require(floatData.isEmpty()) { "floatData must be empty for STRING arrays" } + require(intData.isEmpty()) { "intData must be empty for STRING arrays" } + require(stringData.all { value -> value.isNotEmpty() && value.all { it.code in 1..127 } }) { + "stringData values must be non-empty ASCII strings" + } } } } public val elementCount: Int get() = shape.fold(1) { acc, dim -> acc * dim } + + public val stringElementWidth: Int + get() = stringData.maxOfOrNull { it.length } ?: 0 } /** @@ -181,10 +196,10 @@ public class MinervaNpzModelWriter @kotlin.jvm.JvmOverloads constructor( shape = layer.bias?.shape ?: listOf(0), values = layer.bias?.let { requiredValues(it, layer.id, "layer_${index}_b") } ?: emptyList() ) - arrays += intArray( + arrays += stringArray( name = "layer_${index}_act", - shape = listOf(1), - values = listOf(activationCode(layer.activation)) + shape = emptyList(), + values = listOf(activationName(layer.activation)) ) arrays += intArray( name = "layer_${index}_input_shape", @@ -247,12 +262,12 @@ public class MinervaNpzModelWriter @kotlin.jvm.JvmOverloads constructor( } } - private fun activationCode(activation: MinervaActivation?): Int { + private fun activationName(activation: MinervaActivation?): String { return when (activation) { - null -> 0 - MinervaActivation.RELU -> 1 - MinervaActivation.SIGMOID -> 2 - MinervaActivation.TANH -> 3 + null -> "linear" + MinervaActivation.RELU -> "relu" + MinervaActivation.SIGMOID -> "sigmoid" + MinervaActivation.TANH -> "tanh" } } @@ -263,6 +278,10 @@ public class MinervaNpzModelWriter @kotlin.jvm.JvmOverloads constructor( private fun intArray(name: String, shape: List, values: List): MinervaNpzArray { return MinervaNpzArray(name = name, dtype = MinervaNpzDType.INT32, shape = shape, intData = values) } + + private fun stringArray(name: String, shape: List, values: List): MinervaNpzArray { + return MinervaNpzArray(name = name, dtype = MinervaNpzDType.STRING, shape = shape, stringData = values) + } } private object MinervaNpzArchiveWriter { @@ -280,6 +299,12 @@ private object NpyWriter { when (array.dtype) { MinervaNpzDType.FLOAT32 -> array.floatData.forEach { payload.writeIntLE(it.toRawBits()) } MinervaNpzDType.INT32 -> array.intData.forEach { payload.writeIntLE(it) } + MinervaNpzDType.STRING -> array.stringData.forEach { value -> + value.forEach { char -> payload.writeIntLE(char.code) } + repeat(array.stringElementWidth - value.length) { + payload.writeIntLE(0) + } + } } val header = header(array) val output = ByteAccumulator() @@ -295,10 +320,15 @@ private object NpyWriter { private fun header(array: MinervaNpzArray): ByteArray { val shapeText = when (array.shape.size) { + 0 -> "()" 1 -> "(${array.shape.single()},)" else -> array.shape.joinToString(prefix = "(", postfix = ")") } - val raw = "{'descr': '${array.dtype.numpyDescriptor}', 'fortran_order': False, 'shape': $shapeText, }" + val descriptor = when (array.dtype) { + MinervaNpzDType.STRING -> "${array.dtype.numpyDescriptor}${array.stringElementWidth}" + else -> array.dtype.numpyDescriptor + } + val raw = "{'descr': '$descriptor', 'fortran_order': False, 'shape': $shapeText, }" val preambleSize = 10 val padding = (16 - ((preambleSize + raw.length + 1) % 16)) % 16 return (raw + " ".repeat(padding) + "\n").encodeToByteArray() @@ -429,4 +459,3 @@ private class ByteAccumulator { return ByteArray(bytes.size) { index -> bytes[index] } } } - diff --git a/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaExportFacadeTest.kt b/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaExportFacadeTest.kt index f1877177..2dfbe4b2 100644 --- a/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaExportFacadeTest.kt +++ b/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaExportFacadeTest.kt @@ -245,7 +245,7 @@ class MinervaExportFacadeTest { outputDir = generatedDir, weightsCPath = "$generatedDir/weights.c", weightsHPath = "$generatedDir/weights.h", - commandSummary = "fake-minerva-compiler --model model.npz", + commandSummary = "fake-minerva-compiler model.npz", stdout = "ok" ) } diff --git a/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriterTest.kt b/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriterTest.kt index a0e03ac1..e32ea241 100644 --- a/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriterTest.kt +++ b/skainet-compile/skainet-compile-minerva/src/commonTest/kotlin/sk/ainet/compile/minerva/MinervaNpzModelWriterTest.kt @@ -48,8 +48,8 @@ class MinervaNpzModelWriterTest { assertEquals("1x2", first.metadata["outputShape"]) assertEquals(listOf(4, 3), first.array("layer_0_w").shape) assertEquals(12, first.array("layer_0_w").floatData.size) - assertEquals(listOf(1), first.array("layer_0_act").intData) - assertEquals(listOf(2), first.array("layer_1_act").intData) + assertEquals(listOf("relu"), first.array("layer_0_act").stringData) + assertEquals(listOf("sigmoid"), first.array("layer_1_act").stringData) assertTrue(context.artifacts.any { it.path == "model.npz" && it.role == GraphExportArtifactRole.INTERMEDIATE }) assertTrue(context.diagnostics.any { it.code == "minerva.npz.completed" }) } @@ -66,8 +66,8 @@ class MinervaNpzModelWriterTest { assertTrue(entries.all { it.data.startsWithNpyMagic() }) assertTrue(entries.single { it.name == "layer_0_w.npy" }.npyHeader().contains("'descr': ' val previous = command.getOrNull(index - 1) when (previous) { - "--key-file" -> "" + "--key" -> "" else -> value } }.joinToString(" ") @@ -465,12 +461,33 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( ) } Files.createDirectories(target.parent) - if (source.toAbsolutePath().normalize() != target.toAbsolutePath().normalize()) { - Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING) + val samePath = source.toAbsolutePath().normalize() == target.toAbsolutePath().normalize() + if (logicalName == "weights.c") { + val original = Files.readString(source) + val normalized = normalizeWeightsSource(original) + if (!samePath || normalized != original) { + Files.writeString(target, normalized) + } + } else { + if (!samePath) { + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING) + } } return target } + private fun normalizeWeightsSource(source: String): String { + return source + .lineSequence() + .joinToString(separator = "\n") { line -> + if (line.trim() == "}},") { + line.replace("}},", "},") + } else { + line + } + } + if (source.endsWith("\n")) "\n" else "" + } + private fun recordArtifacts( context: GraphExportContext, projectDir: Path, @@ -569,6 +586,51 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( |set(MINERVA_HOST_LIBRARY_DIRS "$libraryDirs" CACHE STRING "Additional semicolon-separated Minerva host library directories") |set(MINERVA_HOST_LIBRARIES "$libraries" CACHE STRING "Additional semicolon-separated Minerva host libraries") | + |set(MINERVA_GENERATED_WEIGHTS_HEADER "${'$'}{CMAKE_CURRENT_SOURCE_DIR}/../include/weights.h") + |if(EXISTS "${'$'}{MINERVA_GENERATED_WEIGHTS_HEADER}") + | file(READ "${'$'}{MINERVA_GENERATED_WEIGHTS_HEADER}" MINERVA_GENERATED_WEIGHTS_H) + | function(minerva_define_from_weights name) + | string(REGEX MATCH "#define[ \t]+${'$'}{name}[ \t]+([0-9]+)U?" _match "${'$'}{MINERVA_GENERATED_WEIGHTS_H}") + | if(CMAKE_MATCH_1) + | add_compile_definitions("${'$'}{name}=${'$'}{CMAKE_MATCH_1}U") + | endif() + | endfunction() + | minerva_define_from_weights(MNV_INPUT_SIZE) + | minerva_define_from_weights(MNV_NUM_LAYERS) + | minerva_define_from_weights(MNV_OUTPUT_SIZE) + | minerva_define_from_weights(MNV_ENCRYPTED_LEN) + | foreach(MINERVA_LAYER_INDEX RANGE 0 15) + | minerva_define_from_weights("MNV_LAYER_${'$'}{MINERVA_LAYER_INDEX}_SIZE") + | endforeach() + |endif() + | + |if(MINERVA_RUNTIME_ROOT AND EXISTS "${'$'}{MINERVA_RUNTIME_ROOT}/CMakeLists.txt") + | add_subdirectory("${'$'}{MINERVA_RUNTIME_ROOT}" "${'$'}{CMAKE_CURRENT_BINARY_DIR}/libminerva") + |endif() + |if(TARGET minerva AND MINERVA_RUNTIME_ROOT) + | target_include_directories(minerva PUBLIC + | "${'$'}{MINERVA_RUNTIME_ROOT}/src/core" + | "${'$'}{MINERVA_RUNTIME_ROOT}/src/security" + | "${'$'}{MINERVA_RUNTIME_ROOT}/src/arch" + | "${'$'}{MINERVA_RUNTIME_ROOT}/src/hal" + | ) + | get_target_property(MINERVA_RUNTIME_TARGET_SOURCES minerva SOURCES) + | if(NOT MINERVA_RUNTIME_TARGET_SOURCES) + | set(MINERVA_RUNTIME_TARGET_SOURCES "") + | endif() + | foreach(MINERVA_RUNTIME_EXTRA_SOURCE + | src/security/mnv_lut.c + | src/security/mnv_outauth.c + | ) + | set(MINERVA_RUNTIME_EXTRA_SOURCE_ABS "${'$'}{MINERVA_RUNTIME_ROOT}/${'$'}{MINERVA_RUNTIME_EXTRA_SOURCE}") + | list(FIND MINERVA_RUNTIME_TARGET_SOURCES "${'$'}{MINERVA_RUNTIME_EXTRA_SOURCE}" MINERVA_RUNTIME_EXTRA_SOURCE_REL_FOUND) + | list(FIND MINERVA_RUNTIME_TARGET_SOURCES "${'$'}{MINERVA_RUNTIME_EXTRA_SOURCE_ABS}" MINERVA_RUNTIME_EXTRA_SOURCE_ABS_FOUND) + | if(EXISTS "${'$'}{MINERVA_RUNTIME_EXTRA_SOURCE_ABS}" AND MINERVA_RUNTIME_EXTRA_SOURCE_REL_FOUND EQUAL -1 AND MINERVA_RUNTIME_EXTRA_SOURCE_ABS_FOUND EQUAL -1) + | target_sources(minerva PRIVATE "${'$'}{MINERVA_RUNTIME_EXTRA_SOURCE_ABS}") + | endif() + | endforeach() + |endif() + | |set(MINERVA_HOST_SOURCES main.c ../generated/weights.c) |if(MINERVA_HOST_ADAPTER_SOURCE) | list(APPEND MINERVA_HOST_SOURCES "${'$'}{MINERVA_HOST_ADAPTER_SOURCE}") @@ -585,7 +647,9 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( |if(MINERVA_HOST_LIBRARY_DIRS) | target_link_directories(${options.projectName}_host PRIVATE ${'$'}{MINERVA_HOST_LIBRARY_DIRS}) |endif() - |if(MINERVA_HOST_LIBRARIES) + |if(TARGET minerva) + | target_link_libraries(${options.projectName}_host PRIVATE minerva) + |elseif(MINERVA_HOST_LIBRARIES) | target_link_libraries(${options.projectName}_host PRIVATE ${'$'}{MINERVA_HOST_LIBRARIES}) |endif() |if(MINERVA_HOST_ADAPTER_SOURCE) @@ -671,8 +735,9 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( | * | * -DMINERVA_HOST_ADAPTER_SOURCE=/path/to/minerva_runtime_adapter.c | * - | * This adapter targets libminerva's public C API: - | * mnv_init, mnv_seed_prng, mnv_run, mnv_verify_output + | * This adapter targets libminerva's current C runtime symbols: + | * mnv_init, mnv_seed_prng, mnv_run_with_model, + | * mnv_verify_output_with_key | * | * SKaiNET host fixtures use normalized float values. libminerva uses Q8 | * activation buffers, so this sample clamps [-1, 1] floats to int8 and @@ -686,6 +751,32 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( |#define MNV_HOST_PRNG_SEED 0x534B4149UL |#endif | + |#ifndef MINERVA_HOST_VERIFY_OUTPUT_WITH_MODEL_KEY + |#define MINERVA_HOST_VERIFY_OUTPUT_WITH_MODEL_KEY 1 + |#endif + | + |#ifndef MINERVA_HOST_RUN_WITH_MODEL + |#define MINERVA_HOST_RUN_WITH_MODEL 1 + |#endif + | + |#if MINERVA_HOST_RUN_WITH_MODEL + |extern mnv_status_t mnv_run_with_model( + | mnv_ctx_t *ctx, + | const mnv_model_t *model, + | const mnv_act_t *input, + | mnv_act_t *output + |); + |#endif + | + |#if defined(MNV_ENABLE_OUTPUT_AUTH) && MINERVA_HOST_VERIFY_OUTPUT_WITH_MODEL_KEY + |extern mnv_status_t mnv_verify_output_with_key( + | const mnv_ctx_t *ctx, + | const uint8_t *device_key, + | const mnv_act_t *input, + | const mnv_act_t *output + |); + |#endif + | |static mnv_ctx_t minerva_host_ctx; |static int minerva_host_initialized = 0; | @@ -733,12 +824,20 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( | q_output[i] = 0; | } | + |#if MINERVA_HOST_RUN_WITH_MODEL + | mnv_status_t status = mnv_run_with_model(&minerva_host_ctx, &mnv_model, q_input, q_output); + |#else | mnv_status_t status = mnv_run(&minerva_host_ctx, q_input, q_output); + |#endif | if (status != MNV_OK) { | return (int)status; | } |#ifdef MNV_ENABLE_OUTPUT_AUTH + |#if MINERVA_HOST_VERIFY_OUTPUT_WITH_MODEL_KEY + | status = mnv_verify_output_with_key(&minerva_host_ctx, mnv_model.key, q_input, q_output); + |#else | status = mnv_verify_output(&minerva_host_ctx, q_input, q_output); + |#endif | if (status != MNV_OK) { | return (int)status; | } @@ -848,7 +947,7 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( context: GraphExportContext ): MinervaHostVerification { val options = request.options - val projectDir = Paths.get(request.bundle.outputDir).normalize() + val projectDir = Paths.get(request.bundle.outputDir).toAbsolutePath().normalize() val tolerance = options.hostVerificationTolerance context.info( stage = GraphExportStage.VERIFICATION, @@ -1126,11 +1225,22 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( ): MinervaHostVerification? { val keyPath = request.options.keyFile?.let(Paths::get) ?: return null if (!Files.isRegularFile(keyPath) || Files.size(keyPath) == 0L || Files.size(keyPath) > 4096L) return null - val keyMaterial = Files.readString(keyPath).trim() - if (keyMaterial.isBlank()) return null + val keyBytes = Files.readAllBytes(keyPath) + if (keyBytes.isEmpty()) return null + val utf8Key = String(keyBytes, Charsets.UTF_8).trim() + val hexKey = keyBytes.joinToString(separator = "") { byte -> + (byte.toInt() and 0xff).toString(16).padStart(2, '0') + } + val keyMaterialCandidates = buildList { + if (utf8Key.isNotBlank()) add(utf8Key) + if (hexKey.isNotBlank()) { + add(hexKey) + add(hexKey.uppercase()) + } + } val secretsExample = projectDir.resolve("include/secrets.example.h") val template = Files.readString(secretsExample) - if (!template.contains(keyMaterial)) return null + if (keyMaterialCandidates.none(template::contains)) return null return failed( code = "minerva.host_verification.secret_leak", message = "Generated Minerva secret template contains real key material.", diff --git a/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSample.kt b/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSample.kt index b0ddfd12..154d6cb1 100644 --- a/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSample.kt +++ b/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSample.kt @@ -35,6 +35,7 @@ internal object MinervaTinyMlpExportSample { calibrationNpz = envPath(env, "MINERVA_CALIBRATION_NPZ"), runCmakeBuild = envFlag(env, "MINERVA_RUN_CMAKE"), runCTest = envFlag(env, "MINERVA_RUN_CTEST"), + hostVerificationTolerance = envFloat(env, "MINERVA_HOST_TOLERANCE"), hostOutputPath = envPath(env, "MINERVA_HOST_OUTPUT_PATH"), hostAdapterSource = envPath(env, "MINERVA_HOST_ADAPTER_SOURCE"), hostIncludeDirs = envPath(env, "MINERVA_HOST_INCLUDE_DIRS"), @@ -128,6 +129,7 @@ internal object MinervaTinyMlpExportSample { calibrationNpz: String? = null, runCmakeBuild: Boolean = false, runCTest: Boolean = false, + hostVerificationTolerance: Float? = null, hostOutputPath: String? = null, hostAdapterSource: String? = null, hostIncludeDirs: String? = null, @@ -163,6 +165,7 @@ internal object MinervaTinyMlpExportSample { compilerScript = compilerScript, keyFile = keyFile, calibrationNpz = calibrationNpz, + hostVerificationTolerance = hostVerificationTolerance ?: 1.0e-3f, metadata = metadata ) } @@ -252,4 +255,8 @@ internal object MinervaTinyMlpExportSample { private fun envFlag(env: Map, name: String): Boolean { return env[name]?.equals("true", ignoreCase = true) == true } + + private fun envFloat(env: Map, name: String): Float? { + return env[name]?.trim()?.takeIf { it.isNotEmpty() }?.toFloat() + } } diff --git a/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackagerTest.kt b/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackagerTest.kt index 87b0fe3e..b5f28155 100644 --- a/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackagerTest.kt +++ b/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackagerTest.kt @@ -1,5 +1,6 @@ package sk.ainet.compile.minerva +import java.io.IOException import java.nio.file.Files import java.nio.file.Path import kotlin.test.Test @@ -76,11 +77,72 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(Files.isRegularFile(Path.of(output.weightsCPath))) assertTrue(Files.isRegularFile(Path.of(output.weightsHPath))) assertTrue(output.stdout.contains("compiler ok")) - assertTrue(output.commandSummary.contains("--model")) + assertTrue(output.commandSummary.contains("model.npz")) + assertTrue(output.commandSummary.contains("--quant q8")) + assertFalse(output.commandSummary.contains("--model")) + assertFalse(output.commandSummary.contains("--quantization")) assertTrue(context.diagnostics.any { it.code == "minerva.compiler.completed" }) assertTrue(context.artifacts.any { it.role == GraphExportArtifactRole.SOURCE && it.path.endsWith("weights.c") }) } + @Test + fun pythonAdapterUsesCurrentLibminervaCompilerCli() { + val root = tempDir("compiler-cli") + val fakeExecutable = root.resolve("fake-python") + val compilerScript = root.resolve("minerva_compile.py") + val keyFile = root.resolve("device.key") + val calibrationNpz = root.resolve("calibration.npz") + Files.writeString(compilerScript, "# fake script marker\n") + Files.write(keyFile, ByteArray(32) { index -> index.toByte() }) + Files.write(calibrationNpz, byteArrayOf(1, 2, 3)) + Files.writeString( + fakeExecutable, + """ + |#!/bin/sh + |out="" + |while [ "${'$'}#" -gt 0 ]; do + | case "${'$'}1" in + | --out-dir) + | shift + | out="${'$'}1" + | ;; + | esac + | shift + |done + |mkdir -p "${'$'}out" + |printf '%s\n' 'int minerva_weights = 1;' > "${'$'}out/weights.c" + |printf '%s\n' '#pragma once' > "${'$'}out/weights.h" + | + """.trimMargin() + ) + assertTrue(fakeExecutable.toFile().setExecutable(true)) + + val (options, intermediate, npzModel) = artifacts( + outputDir = root.resolve("out").toString(), + projectName = "CompilerCliMlp", + compilerScript = compilerScript.toString(), + pythonExecutable = fakeExecutable.toString(), + keyFile = keyFile.toString(), + calibrationNpz = calibrationNpz.toString() + ) + + val output = PythonMinervaCompilerAdapter().compile( + MinervaCompilerRequest(options, intermediate, npzModel), + minervaContext(options) + ) + + assertTrue(output.commandSummary.contains("minerva_compile.py")) + assertTrue(output.commandSummary.contains("model.npz --out-dir")) + assertTrue(output.commandSummary.contains("--target atmega328p")) + assertTrue(output.commandSummary.contains("--quant q8")) + assertTrue(output.commandSummary.contains("--key ")) + assertTrue(output.commandSummary.contains("--calibrate")) + assertFalse(output.commandSummary.contains(keyFile.toString())) + assertFalse(output.commandSummary.contains("--key-file")) + assertFalse(output.commandSummary.contains("--runtime-root")) + assertEquals(0, output.exitCode) + } + @Test fun projectPackagerWritesManifestSamplesAndSecretTemplate() { val root = tempDir("packager") @@ -105,7 +167,7 @@ class MinervaJvmCompilerAndPackagerTest { weightsCPath = weightsC.toString(), weightsHPath = weightsH.toString(), debugWeightsPath = debugWeights.toString(), - commandSummary = "fake-minerva --key-file " + commandSummary = "fake-minerva model.npz --key " ) val context = minervaContext(options) @@ -127,6 +189,9 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(Files.isRegularFile(projectDir.resolve("firmware/main.c"))) val hostCmake = Files.readString(projectDir.resolve("host/CMakeLists.txt")) assertTrue(hostCmake.contains("MINERVA_HOST_ADAPTER_SOURCE")) + assertTrue(hostCmake.contains("add_subdirectory")) + assertTrue(hostCmake.contains("minerva_define_from_weights")) + assertTrue(hostCmake.contains("target_sources(minerva")) assertTrue(hostCmake.contains("target_link_libraries")) assertTrue(hostCmake.contains("set_tests_properties")) val hostMain = Files.readString(projectDir.resolve("host/main.c")) @@ -137,7 +202,9 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(adapterExample.contains("minerva_run_inference")) assertTrue(adapterExample.contains("mnv_init")) assertTrue(adapterExample.contains("mnv_seed_prng")) + assertTrue(adapterExample.contains("mnv_run_with_model")) assertTrue(adapterExample.contains("mnv_run")) + assertTrue(adapterExample.contains("mnv_verify_output_with_key")) assertTrue(adapterExample.contains("mnv_verify_output")) assertTrue(adapterExample.contains("MNV_INPUT_SIZE")) assertTrue(adapterExample.contains("MNV_OUTPUT_SIZE")) @@ -147,7 +214,7 @@ class MinervaJvmCompilerAndPackagerTest { assertFalse(secretsExample.contains("REAL_SECRET_KEY_MATERIAL")) val manifest = Files.readString(projectDir.resolve("manifest.json")) assertTrue(manifest.contains("\"target\": \"atmega328p\"")) - assertTrue(manifest.contains("\"compilerCommand\": \"fake-minerva --key-file \"")) + assertTrue(manifest.contains("\"compilerCommand\": \"fake-minerva model.npz --key \"")) assertTrue(manifest.contains("\"referenceInputPath\": \"host/reference-input.txt\"")) assertTrue(manifest.contains("\"referenceOutputPath\": \"host/reference-output.txt\"")) assertTrue(manifest.contains("\"generatedFileSha256\": {")) @@ -166,6 +233,54 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(context.diagnostics.any { it.code == "minerva.packaging.completed" }) } + @Test + fun projectPackagerNormalizesCurrentLibminervaLayerClosers() { + val root = tempDir("packager-libminerva-normalize") + val outputRoot = root.resolve("package") + val projectName = "NormalizePackagedMlp" + val generatedDir = outputRoot.resolve(projectName).resolve("generated") + val compilerDir = root.resolve("compiler") + Files.createDirectories(generatedDir) + Files.createDirectories(compilerDir) + val weightsC = generatedDir.resolve("weights.c") + val weightsH = compilerDir.resolve("weights.h") + Files.writeString( + weightsC, + """ + |const mnv_layer_desc_t mnv_layers[2] PROGMEM = { + | [0] = { + | .weights = NULL, + | }}, + | [1] = { + | .weights = NULL, + | }}, + |}; + | + """.trimMargin() + ) + Files.writeString(weightsH, "#pragma once\n") + val (options, intermediate, npzModel) = artifacts( + outputDir = outputRoot.toString(), + projectName = projectName, + compilerScript = root.resolve("compile.py").toString() + ) + val compilerOutput = MinervaCompilerOutput( + outputDir = compilerDir.toString(), + weightsCPath = weightsC.toString(), + weightsHPath = weightsH.toString(), + commandSummary = "fake-minerva model.npz" + ) + + val bundle = JvmMinervaProjectPackager().packageProject( + MinervaProjectPackageRequest(options, intermediate, npzModel, compilerOutput), + minervaContext(options) + ) + + val packagedWeights = Files.readString(Path.of(bundle.outputDir).resolve("generated/weights.c")) + assertFalse(packagedWeights.contains("}},")) + assertTrue(packagedWeights.contains(" },\n [1] = {")) + } + @Test fun hostRuntimeAdapterExampleCompilesAgainstPublicMinervaApiShim() { val root = tempDir("adapter-compile") @@ -194,11 +309,23 @@ class MinervaJvmCompilerAndPackagerTest { |typedef int mnv_status_t; |typedef int8_t mnv_act_t; |typedef struct mnv_ctx_t { int verified; } mnv_ctx_t; - |typedef struct mnv_model_t { int version; } mnv_model_t; + |typedef struct mnv_model_t { int version; const uint8_t *key; } mnv_model_t; |mnv_status_t mnv_init(mnv_ctx_t *ctx, const mnv_model_t *model); |void mnv_seed_prng(mnv_ctx_t *ctx, uint32_t seed); |mnv_status_t mnv_run(mnv_ctx_t *ctx, const mnv_act_t *input, mnv_act_t *output); + |mnv_status_t mnv_run_with_model( + | mnv_ctx_t *ctx, + | const mnv_model_t *model, + | const mnv_act_t *input, + | mnv_act_t *output + |); |mnv_status_t mnv_verify_output(const mnv_ctx_t *ctx, const mnv_act_t *input, const mnv_act_t *output); + |mnv_status_t mnv_verify_output_with_key( + | const mnv_ctx_t *ctx, + | const uint8_t *device_key, + | const mnv_act_t *input, + | const mnv_act_t *output + |); | """.trimMargin() ) @@ -236,6 +363,21 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(fixture.context.diagnostics.any { it.code == "minerva.host_verification.passed" }) } + @Test + fun hostVerifierHandlesBinaryKeyMaterialDuringSecretHygieneCheck() { + val root = tempDir("host-verify-binary-key") + val keyFile = root.resolve("device.key") + Files.write(keyFile, ByteArray(32) { index -> (index * 7).toByte() }) + val fixture = packagedProject(root, "BinaryKeyVerify") + val request = fixture.request.copy( + options = fixture.request.options.copy(keyFile = keyFile.toString()) + ) + + val verification = JvmMinervaHostVerifier().verify(request, fixture.context) + + assertEquals(MinervaHostVerificationStatus.PASSED, verification.status) + } + @Test fun hostVerifierPassesAdapterConfigurationToCMake() { val fixture = packagedProject(tempDir("host-verify-cmake-adapter"), "AdapterCmakeVerify") @@ -280,6 +422,95 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(log.contains("-DMINERVA_HOST_LIBRARIES=minerva;crypto")) } + @Test + fun hostVerifierUsesAbsoluteCmakePathsForRelativeBundleOutput() { + val relativeRoot = Path.of("build/minerva-relative-${System.nanoTime()}") + val fixture = packagedProject(relativeRoot, "RelativeCmakeVerify") + val toolDir = tempDir("fake-cmake-relative") + val fakeCmake = toolDir.resolve("cmake") + Files.writeString( + fakeCmake, + """ + |#!/bin/sh + |exit 0 + | + """.trimMargin() + ) + assertTrue(fakeCmake.toFile().setExecutable(true)) + val request = fixture.request.copy( + options = fixture.request.options.copy( + metadata = fixture.request.options.metadata + mapOf( + MinervaHostVerificationMetadata.RUN_CMAKE_BUILD to "true", + MinervaHostVerificationMetadata.CMAKE_EXECUTABLE to fakeCmake.toString() + ) + ) + ) + + val verification = JvmMinervaHostVerifier().verify(request, fixture.context) + + assertEquals(MinervaHostVerificationStatus.PASSED, verification.status) + val log = Files.readString(fixture.projectDir.resolve("host/build/cmake-configure.log")) + assertTrue(log.contains("-S ${fixture.projectDir.resolve("host").toAbsolutePath().normalize()}")) + assertTrue(log.contains("-B ${fixture.projectDir.resolve("host/build").toAbsolutePath().normalize()}")) + } + + @Test + fun hostVerifierBuildsRuntimeCheckoutAndRunsCTest() { + if (!commandAvailable("cmake")) return + val root = tempDir("host-verify-runtime-checkout") + val fixture = packagedProject(root, "CheckoutRuntimeVerify") + val secretIncludeDir = root.resolve("secret-include") + val runtimeRoot = root.resolve("libminerva") + Files.createDirectories(secretIncludeDir) + Files.writeString(secretIncludeDir.resolve("secrets.h"), "#pragma once\n") + writeFakeRuntimeCheckout(runtimeRoot) + Files.writeString( + fixture.projectDir.resolve("include/weights.h"), + """ + |#pragma once + |#include "minerva.h" + |#define MNV_INPUT_SIZE 4U + |#define MNV_NUM_LAYERS 2U + |#define MNV_LAYER_0_SIZE 3U + |#define MNV_LAYER_1_SIZE 2U + |#define MNV_OUTPUT_SIZE 3U + |extern const mnv_model_t mnv_model; + | + """.trimMargin() + ) + Files.writeString( + fixture.projectDir.resolve("generated/weights.c"), + """ + |#include "weights.h" + |#include "secrets.h" + |const mnv_model_t mnv_model = {0}; + | + """.trimMargin() + ) + val request = fixture.request.copy( + options = fixture.request.options.copy( + runtimeRoot = runtimeRoot.toString(), + hostVerificationTolerance = 1.0f, + metadata = fixture.request.options.metadata + mapOf( + MinervaHostVerificationMetadata.RUN_CMAKE_BUILD to "true", + MinervaHostVerificationMetadata.RUN_CTEST to "true", + MinervaHostVerificationMetadata.HOST_ADAPTER_SOURCE to + fixture.projectDir.resolve("host/runtime_adapter.example.c").toString(), + MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS to secretIncludeDir.toString() + ) + ) + ) + + val verification = JvmMinervaHostVerifier().verify(request, fixture.context) + + assertEquals(MinervaHostVerificationStatus.PASSED, verification.status) + assertEquals(MinervaHostVerificationStatus.PASSED, verification.hostBuildStatus) + assertEquals(MinervaHostVerificationStatus.PASSED, verification.hostRunStatus) + assertEquals(MinervaHostVerificationStatus.PASSED, verification.parityStatus) + assertEquals(3, verification.observedOutput.size) + assertTrue(Files.readString(fixture.projectDir.resolve("host/build/ctest.log")).contains("100% tests passed")) + } + @Test fun hostVerifierComparesConfiguredHostOutput() { val fixture = packagedProject(tempDir("host-verify-output"), "OutputVerify") @@ -367,7 +598,8 @@ class MinervaJvmCompilerAndPackagerTest { projectName: String, compilerScript: String? = null, pythonExecutable: String = "python3", - keyFile: String? = null + keyFile: String? = null, + calibrationNpz: String? = null ): Triple { val options = minervaTestOptions( outputDir = outputDir, @@ -376,6 +608,7 @@ class MinervaJvmCompilerAndPackagerTest { compilerScript = compilerScript, pythonExecutable = pythonExecutable, keyFile = keyFile, + calibrationNpz = calibrationNpz, runHostVerification = false ) val context = minervaContext(options) @@ -408,7 +641,7 @@ class MinervaJvmCompilerAndPackagerTest { outputDir = compilerDir.toString(), weightsCPath = weightsC.toString(), weightsHPath = weightsH.toString(), - commandSummary = "fake-minerva --model model.npz" + commandSummary = "fake-minerva model.npz" ) val context = minervaContext(options) val bundle = JvmMinervaProjectPackager().packageProject( @@ -433,6 +666,124 @@ class MinervaJvmCompilerAndPackagerTest { return Files.createTempDirectory("skainet-minerva-$prefix-") } + private fun commandAvailable(command: String): Boolean { + return try { + ProcessBuilder(command, "--version").start().waitFor() == 0 + } catch (_: IOException) { + false + } + } + + private fun writeFakeRuntimeCheckout(root: Path) { + val includeDir = root.resolve("include") + val sourceDir = root.resolve("src") + Files.createDirectories(includeDir) + Files.createDirectories(sourceDir) + Files.writeString( + root.resolve("CMakeLists.txt"), + """ + |cmake_minimum_required(VERSION 3.20) + |project(FakeMinerva C) + |add_library(minerva STATIC src/minerva.c) + |target_include_directories(minerva PUBLIC include) + | + """.trimMargin() + ) + Files.writeString( + includeDir.resolve("minerva.h"), + """ + |#pragma once + |#include + |#define MNV_OK 0 + |#define MNV_ENABLE_OUTPUT_AUTH 1 + |#ifndef MNV_INPUT_SIZE + |#error "MNV_INPUT_SIZE was not propagated" + |#endif + |#if MNV_INPUT_SIZE != 4U + |#error "MNV_INPUT_SIZE did not come from generated weights.h" + |#endif + |#if MNV_OUTPUT_SIZE != 3U + |#error "MNV_OUTPUT_SIZE did not come from generated weights.h" + |#endif + |typedef int mnv_status_t; + |typedef int8_t mnv_act_t; + |typedef struct mnv_ctx_t { int initialized; } mnv_ctx_t; + |typedef struct mnv_model_t { int version; const uint8_t *key; } mnv_model_t; + |mnv_status_t mnv_init(mnv_ctx_t *ctx, const mnv_model_t *model); + |void mnv_seed_prng(mnv_ctx_t *ctx, uint32_t seed); + |mnv_status_t mnv_run(mnv_ctx_t *ctx, const mnv_act_t *input, mnv_act_t *output); + |mnv_status_t mnv_run_with_model( + | mnv_ctx_t *ctx, + | const mnv_model_t *model, + | const mnv_act_t *input, + | mnv_act_t *output + |); + |mnv_status_t mnv_verify_output(const mnv_ctx_t *ctx, const mnv_act_t *input, const mnv_act_t *output); + |mnv_status_t mnv_verify_output_with_key( + | const mnv_ctx_t *ctx, + | const uint8_t *device_key, + | const mnv_act_t *input, + | const mnv_act_t *output + |); + | + """.trimMargin() + ) + Files.writeString( + sourceDir.resolve("minerva.c"), + """ + |#include "minerva.h" + | + |mnv_status_t mnv_init(mnv_ctx_t *ctx, const mnv_model_t *model) { + | (void)model; + | ctx->initialized = 1; + | return MNV_OK; + |} + | + |void mnv_seed_prng(mnv_ctx_t *ctx, uint32_t seed) { + | (void)ctx; + | (void)seed; + |} + | + |mnv_status_t mnv_run(mnv_ctx_t *ctx, const mnv_act_t *input, mnv_act_t *output) { + | (void)ctx; + | (void)input; + | output[0] = 127; + | output[1] = 127; + | output[2] = 127; + | return MNV_OK; + |} + | + |mnv_status_t mnv_run_with_model( + | mnv_ctx_t *ctx, + | const mnv_model_t *model, + | const mnv_act_t *input, + | mnv_act_t *output + |) { + | (void)model; + | return mnv_run(ctx, input, output); + |} + | + |mnv_status_t mnv_verify_output(const mnv_ctx_t *ctx, const mnv_act_t *input, const mnv_act_t *output) { + | (void)ctx; + | (void)input; + | (void)output; + | return MNV_OK; + |} + | + |mnv_status_t mnv_verify_output_with_key( + | const mnv_ctx_t *ctx, + | const uint8_t *device_key, + | const mnv_act_t *input, + | const mnv_act_t *output + |) { + | (void)device_key; + | return mnv_verify_output(ctx, input, output); + |} + | + """.trimMargin() + ) + } + private data class PackagedProjectFixture( val request: MinervaHostVerificationRequest, val context: GraphExportContext, diff --git a/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSampleTest.kt b/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSampleTest.kt index 970bdb86..bf03f951 100644 --- a/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSampleTest.kt +++ b/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaTinyMlpExportSampleTest.kt @@ -17,7 +17,7 @@ class MinervaTinyMlpExportSampleTest { fun sampleGraphIsCompatibleAndLowersToNpz() { val graph = MinervaTinyMlpExportSample.tinyMlpGraph() val options = MinervaTinyMlpExportSample.exportOptions( - compilerScript = "/opt/libminerva/tools/compile_model.py", + compilerScript = "/opt/libminerva/compiler/minerva_compile.py", runtimeRoot = "/opt/libminerva", keyFile = "/secure/project/device.key", calibrationNpz = "/secure/project/calibration.npz" @@ -44,9 +44,10 @@ class MinervaTinyMlpExportSampleTest { @Test fun sampleOptionsCarryHostVerificationMetadata() { val options = MinervaTinyMlpExportSample.exportOptions( - compilerScript = "/opt/libminerva/tools/compile_model.py", + compilerScript = "/opt/libminerva/compiler/minerva_compile.py", runCmakeBuild = true, runCTest = true, + hostVerificationTolerance = 0.75f, hostOutputPath = "host-output.txt", hostAdapterSource = "/project/minerva_adapter.c", hostIncludeDirs = "/opt/libminerva/include", @@ -57,6 +58,7 @@ class MinervaTinyMlpExportSampleTest { assertEquals("minerva-tiny-mlp", options.metadata["sample"]) assertEquals("true", options.metadata[MinervaHostVerificationMetadata.RUN_CMAKE_BUILD]) assertEquals("true", options.metadata[MinervaHostVerificationMetadata.RUN_CTEST]) + assertEquals(0.75f, options.hostVerificationTolerance) assertEquals("host-output.txt", options.metadata[MinervaHostVerificationMetadata.HOST_OUTPUT_PATH]) assertEquals("/project/minerva_adapter.c", options.metadata[MinervaHostVerificationMetadata.HOST_ADAPTER_SOURCE]) assertEquals("/opt/libminerva/include", options.metadata[MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS]) From 7f71d792a17f63258e98199bb616c3cc5c9b7d6d Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 8 Jun 2026 16:37:33 +0200 Subject: [PATCH 2/3] docs(minerva): add getting started and explanation pages Refs #687 --- README.md | 15 ++- docs/export/minerva.md | 8 +- docs/modules/ROOT/nav.adoc | 2 + .../minerva-secure-mcu-export.adoc | 81 ++++++++++++ .../ROOT/pages/how-to/minerva-export.adoc | 2 + docs/modules/ROOT/pages/index.adoc | 12 +- .../tutorials/minerva-getting-started.adoc | 125 ++++++++++++++++++ docs/modules/ROOT/pages/using/index.adoc | 7 + 8 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc create mode 100644 docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc diff --git a/README.md b/README.md index e2c4cf6c..2d3c2508 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ matches what you want to try first. | Run tensor operations | [Quickstart](#quickstart) (below) | 2–5 min | | Build and train a neural net | [Hello Neural Net](#hello-neural-net) (below) | 5 min | | Run a local GGUF model | [SKaiNET Transformers starter](https://github.com/SKaiNET-developers/SKaiNET-transformers#start-in-5-minutes) | 5 min after model setup | +| Export a secure MCU bundle | [Minerva getting started](docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc) | 10 min without firmware flashing | Working in Java? SKaiNET ships first-class Java support — see the [Java getting-started guide](docs/modules/ROOT/pages/tutorials/java-getting-started.adoc). @@ -153,6 +154,18 @@ deployment, the StableHLO path for native and edge targets. --- +## Important Addition: Minerva Secure MCU Export + +SKaiNET now includes a Minerva export backend for secure MCU deployment. It is a sibling to StableHLO and Arduino/C99 export: it starts from a supported `ComputeGraph`, lowers static MLPs to a Minerva compiler input, invokes libminerva when configured, and packages generated weights, host fixtures, firmware skeletons, and a fingerprinted `manifest.json`. + +Start here: + +- [Minerva getting started](docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc) — run the maintained tiny MLP dry sample, then the real libminerva runtime profile. +- [Minerva export how-to](docs/modules/ROOT/pages/how-to/minerva-export.adoc) — configure compiler paths, keys, calibration, CMake/CTest host verification, and troubleshooting. +- [How Minerva secure MCU export fits](docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc) — understand why Minerva is not an Arduino replacement and when to choose StableHLO instead. + +--- + ## Features ### Kotlin Multiplatform @@ -190,7 +203,7 @@ deployment, the StableHLO path for native and edge targets. - Export supported static MLP graphs to Minerva project bundles for secure MCU inference - Emits compiler NPZ input, libminerva weights, a fingerprinted manifest, host harness, firmware example, and host verification results -- Start with the [Minerva export guide](docs/modules/ROOT/pages/how-to/minerva-export.adoc) +- Start with the [Minerva getting started guide](docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc) ### Compiler: MLIR / StableHLO diff --git a/docs/export/minerva.md b/docs/export/minerva.md index fea922e5..a1380f2f 100644 --- a/docs/export/minerva.md +++ b/docs/export/minerva.md @@ -1,6 +1,12 @@ # Minerva Secure MCU Export -Minerva export packages a supported SKaiNET compute graph for secure MCU inference through libminerva. The maintained docs-site version is [`docs/modules/ROOT/pages/how-to/minerva-export.adoc`](../modules/ROOT/pages/how-to/minerva-export.adoc); this Markdown entrypoint keeps the repository path requested by the planning issue and is friendly to GitHub browsing. +Minerva export packages a supported SKaiNET compute graph for secure MCU inference through libminerva. The maintained docs-site pages are: + +- [Minerva getting started](../modules/ROOT/pages/tutorials/minerva-getting-started.adoc) +- [Minerva export how-to](../modules/ROOT/pages/how-to/minerva-export.adoc) +- [How Minerva secure MCU export fits](../modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc) + +This Markdown entrypoint keeps the repository path requested by the planning issue and is friendly to GitHub browsing. ## Setup diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 278537e1..4c0217e2 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -5,6 +5,7 @@ * Tutorials ** xref:tutorials/java-getting-started.adoc[Java getting started] ** xref:tutorials/hlo-getting-started.adoc[StableHLO getting started] +** xref:tutorials/minerva-getting-started.adoc[Minerva getting started] ** xref:tutorials/graph-dsl.adoc[Graph DSL] ** xref:tutorials/turboquant-getting-started.adoc[TurboQuant: KV-cache compression] * How-to guides @@ -22,6 +23,7 @@ ** xref:reference/api.adoc[API reference (Dokka)] * Explanation ** xref:explanation/skainet-for-ai.adoc[SKaiNET for AI/ML] +** xref:explanation/minerva-secure-mcu-export.adoc[How Minerva secure MCU export fits] ** xref:explanation/operator-design.adoc[Operator documentation system] ** xref:explanation/theory/index.adoc[Mathematical theory] *** xref:explanation/theory/matmul.adoc[Matrix multiplication] diff --git a/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc b/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc new file mode 100644 index 00000000..e26b0336 --- /dev/null +++ b/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc @@ -0,0 +1,81 @@ += How Minerva Secure MCU Export Fits +:description: Conceptual overview of SKaiNET's Minerva export backend and how it relates to StableHLO and Arduino/C99 export. + +Minerva export is SKaiNET's secure MCU packaging backend. It takes a supported `ComputeGraph`, lowers it to a compact Minerva layer model, writes a NumPy `.npz` compiler input for libminerva, invokes the Minerva compiler when configured, and packages the generated C artifacts into a reviewable project bundle. + +The important distinction is scope. StableHLO is a portable compiler IR path for broad native and accelerator deployment. Arduino/C99 export is standalone generated C for small devices without an external secure runtime. Minerva export is for the cases where the deployment artifact must be a libminerva project with secure runtime integration, generated weights, host verification, and manifest provenance. + +== Same Export Level, Different Target + +StableHLO and Minerva sit at the same architectural level in SKaiNET: both are graph export backends. They share the source boundary, diagnostics model, and export result style, but they produce different artifacts. + +[cols="1,2,2",options="header"] +|=== +| Backend | Output | Primary target + +| StableHLO +| MLIR module text in the StableHLO dialect. +| Portable compiler flows such as IREE, native code, accelerators, and ecosystem tooling. + +| Arduino / C99 +| Standalone C99 and Arduino library structure. +| Simple MCU deployment with static memory allocation and no secure runtime dependency. + +| Minerva +| Minerva compiler input, generated libminerva weights, host and firmware harnesses, manifest, and verification evidence. +| Secure MCU deployment through libminerva. +|=== + +Minerva is therefore not a mode of the Arduino exporter. It is a smaller, stricter sibling backend with a security-oriented package shape. + +== Source Models + +Minerva export starts from a SKaiNET `ComputeGraph`. That graph can be produced in several ways: + +* a model written in the Kotlin neural-network DSL, +* a traced representative forward pass, +* a hand-built graph, +* a model-import workflow that validates and converts into the supported graph shape. + +ONNX is not special in the Minerva pipeline. It can be useful as an input format for inspection or conversion, but the first Minerva implementation does not include a general ONNX-to-Minerva importer. The contract is the SKaiNET graph handed to `MinervaExportFacade`. + +== Why the Scope Is Narrow + +The phase-one backend intentionally supports static sequential MLPs only: + +* known rank-2 tensor shapes, +* dense `MatMul` layers with optional bias `Add`, +* `Relu`, `Sigmoid`, or `Tanh` activations, +* Q8 quantization, +* ATmega328P target metadata. + +This keeps failures early and understandable. If a graph contains attention, convolution, branching, dynamic shapes, or an unsupported imported operator, Minerva rejects it before invoking libminerva. For general compiler coverage, use StableHLO. + +== What the Bundle Proves + +The generated bundle is meant to be auditable: + +* `generated/model.npz` is the compiler input SKaiNET produced. +* `generated/weights.c` and `include/weights.h` are generated compiler artifacts. +* `host/` contains reference fixtures, a host harness, and CMake integration. +* `firmware/` contains a starting point for target integration. +* `manifest.json` fingerprints generated files and records export metadata. + +Host verification checks the generated structure, validates NPZ contents, guards against accidental secret leakage, and can build and run the generated C harness when libminerva and CMake are available. + +== Secret Handling + +The repository-generated files must stay free of real device secrets. `secrets.example.h` is a placeholder. Real keys belong in private provisioning systems or local, untracked include directories used only for host verification and firmware integration. + +The compiler command summary recorded in `manifest.json` redacts key-file arguments. The manifest should prove which artifacts were generated together, but it should not contain material that can provision or emulate a real device. + +== When to Choose Minerva + +Choose Minerva when all of these are true: + +* the graph fits the supported static MLP contract, +* the target is a secure MCU flow managed by libminerva, +* release evidence needs a manifest and generated-file fingerprints, +* host verification matters before firmware handoff. + +Choose StableHLO when you need broad operator coverage or a general compiler ecosystem. Choose Arduino/C99 when the deployment should be plain generated C without libminerva. diff --git a/docs/modules/ROOT/pages/how-to/minerva-export.adoc b/docs/modules/ROOT/pages/how-to/minerva-export.adoc index 4843b58f..2b2513bd 100644 --- a/docs/modules/ROOT/pages/how-to/minerva-export.adoc +++ b/docs/modules/ROOT/pages/how-to/minerva-export.adoc @@ -4,6 +4,8 @@ Minerva export packages a supported SKaiNET compute graph for secure MCU inferen Use this backend when the output must be a Minerva project bundle with compiler input, generated weights, firmware and host harnesses, a manifest, and host verification metadata. +New to this backend? Start with xref:tutorials/minerva-getting-started.adoc[Minerva getting started] for the first dry run and real-runtime profile, then return here for the detailed option reference. For the conceptual model, see xref:explanation/minerva-secure-mcu-export.adoc[How Minerva secure MCU export fits]. + == When to Use Each Export Path [cols="1,2",options="header"] diff --git a/docs/modules/ROOT/pages/index.adoc b/docs/modules/ROOT/pages/index.adoc index 37f92107..81a60961 100644 --- a/docs/modules/ROOT/pages/index.adoc +++ b/docs/modules/ROOT/pages/index.adoc @@ -1,12 +1,13 @@ = SKaiNET -:description: Kotlin Multiplatform tensor engine with a graph IR, pluggable backends, and StableHLO export. +:description: Kotlin Multiplatform tensor engine with a graph IR, pluggable backends, StableHLO export, and Minerva secure MCU export. SKaiNET is a Kotlin Multiplatform tensor / compile / graph engine. It provides a tensor DSL, execution contexts, a graph IR, model loaders (GGUF, SafeTensors, ONNX), quantization primitives (Q4_K, Q8_0, ternary, TurboQuant), a StableHLO emitter for cross- -platform compile targets, and a pluggable backend API that CPU, -GPU, and NPU backends can implement independently. +platform compile targets, Minerva secure MCU export for supported +static MLPs, and a pluggable backend API that CPU, GPU, and NPU +backends can implement independently. This site is split by what you're here to do: @@ -19,6 +20,11 @@ training a small network. Inside, content follows the https://diataxis.fr/[Diátaxis] framework: Tutorials (learning-oriented), How-to guides (task-oriented), Reference (lookup), Explanation (background). Java consumers — see the "From Java" callout on that page. +* xref:tutorials/minerva-getting-started.adoc[*Minerva secure MCU export*] ++ +You need to package a supported static MLP for libminerva: start with +the tiny dry-run sample, then run the real-runtime profile, inspect +`manifest.json`, and hand off the generated host and firmware bundle. * xref:contributing/index.adoc[*Contributing to SKaiNET*] + You're modifying SKaiNET itself — building from source, adding diff --git a/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc b/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc new file mode 100644 index 00000000..7d782124 --- /dev/null +++ b/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc @@ -0,0 +1,125 @@ += Minerva Getting Started +:description: First run for exporting a tiny SKaiNET graph to a Minerva secure MCU bundle. + +This tutorial walks through the smallest useful Minerva export path: run the maintained tiny MLP sample, inspect the generated bundle, and then connect the same export facade to your own compatible graph. + +Minerva export is for secure MCU deployment. It does not replace the general StableHLO path or the standalone Arduino/C99 code generator. It packages a narrow, supported SKaiNET graph into the files libminerva expects: `model.npz`, generated weights, host and firmware harnesses, a manifest, and host verification evidence. + +== Before You Start + +You need: + +* A JDK supported by the repository build. +* A local SKaiNET checkout. +* Optional for the first dry run: no libminerva checkout is required. +* Required for the real runtime proof: a libminerva checkout or install directory with `compiler/minerva_compile.py`, plus CMake when host verification should build and run. + +The phase-one Minerva backend supports static sequential MLP graphs with known shapes, Q8 quantization, and the ATmega328P target. + +== 1. Run the Dry Sample + +From the repository root: + +[source,bash] +---- +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaTinyMlpSample +---- + +When `MINERVA_COMPILER_SCRIPT` is not set, the sample still validates the graph, lowers it to the Minerva intermediate model, and writes the in-memory NPZ compiler input. This is the fastest way to check that the Minerva module is usable without installing libminerva first. + +Expected result: + +[source,text] +---- +MINERVA_COMPILER_SCRIPT is not set; running dry validation through NPZ generation. +Minerva export status: FAILED +Dry validation completed: graph is compatible and model.npz was generated in memory. +---- + +The status is `FAILED` because the real compiler prerequisite is intentionally missing. The dry run is still useful because compatibility and NPZ generation have already executed. + +== 2. Run Against libminerva + +Point SKaiNET at a libminerva checkout: + +[source,bash] +---- +export MINERVA_RUNTIME_ROOT=/opt/libminerva +export MINERVA_COMPILER_SCRIPT="$MINERVA_RUNTIME_ROOT/compiler/minerva_compile.py" +---- + +Then run the local proof helper: + +[source,bash] +---- +MINERVA_RUNTIME_ROOT="$MINERVA_RUNTIME_ROOT" ./scripts/run-minerva-real-runtime-profile.sh +---- + +The helper creates local, untracked profile inputs under `build/minerva-real-runtime-profile`: + +* a generated device key, +* a small calibration archive, +* a host-only `secrets.h`, +* a host-only AVR `pgmspace.h` compatibility shim. + +It then runs the gated `minervaHostVerification` Gradle task with CMake and CTest enabled. The default tolerance is deliberately loose for this profile because current libminerva Q8 host output is treated as a runtime smoke proof, not a strict floating-point parity test. + +== 3. Inspect the Bundle + +A successful run writes: + +[source,text] +---- +build/minerva/TinySecureMlp/ + manifest.json + generated/ + model.npz + weights.c + include/ + weights.h + secrets.example.h + host/ + CMakeLists.txt + main.c + runtime_adapter.example.c + reference-input.txt + reference-output.txt + observed-output.txt + firmware/ + main.c +---- + +Start with `manifest.json`. It records the target, quantization, compiler command summary, generated file hashes, host verification status, and fixture paths. The manifest is the handoff record between the source model, SKaiNET export, libminerva compiler output, and firmware integration. + +Do not copy real device keys into the bundle. `include/secrets.example.h` is intentionally a placeholder file. + +== 4. Export Your Own Compatible Graph + +The public entry point is `MinervaExportFacade`: + +[source,kotlin] +---- +import sk.ainet.compile.minerva.MinervaExportFacade +import sk.ainet.compile.minerva.MinervaExportOptions + +val options = MinervaExportOptions( + outputDir = "build/minerva", + projectName = "MySecureMlp", + compilerScript = "/opt/libminerva/compiler/minerva_compile.py", + runtimeRoot = "/opt/libminerva", + calibrationNpz = "/secure/project/calibration.npz", + keyFile = "/secure/project/device.key" +) + +val result = MinervaExportFacade().exportGraph(graph, options) +val bundle = result.requireSuccess() +println(bundle.manifestPath) +---- + +The `graph` can come from the Kotlin DSL, a traced forward pass, a hand-built `ComputeGraph`, or an import pipeline that you reduce to the supported static MLP contract before export. Minerva is not an ONNX-only path; ONNX is just one possible source if you convert or validate it before calling the facade. + +== 5. Continue + +* xref:how-to/minerva-export.adoc[Minerva export how-to] covers every option, metadata key, and troubleshooting case. +* xref:explanation/minerva-secure-mcu-export.adoc[How Minerva secure MCU export fits] explains why Minerva is a sibling backend beside StableHLO and Arduino/C99 export. +* xref:reference/graph-export-architecture.adoc[Graph export architecture] describes the shared export contracts used by StableHLO and Minerva. diff --git a/docs/modules/ROOT/pages/using/index.adoc b/docs/modules/ROOT/pages/using/index.adoc index 078e76f9..b2cc3b7e 100644 --- a/docs/modules/ROOT/pages/using/index.adoc +++ b/docs/modules/ROOT/pages/using/index.adoc @@ -18,6 +18,7 @@ The Using SKaiNET section is for the engineer who: - Builds tensors, runs forward passes, trains small models. - Loads pre-trained weights (GGUF, SafeTensors, ONNX). - Targets StableHLO / IREE for cross-platform deployment. +- Exports supported static MLPs to Minerva secure MCU bundles. - Reads the operator catalog to discover what's supported on which backend. @@ -46,6 +47,12 @@ specifically: The rest of this section is written in Kotlin. The concepts transfer directly; only the syntax differs. +== Export and deployment paths + +- xref:tutorials/hlo-getting-started.adoc[StableHLO getting started] — lower graphs to portable MLIR for IREE-compatible compiler flows. +- xref:tutorials/minerva-getting-started.adoc[Minerva getting started] — export a tiny static MLP to a secure MCU bundle. +- xref:how-to/arduino-c-codegen.adoc[Generate C for Arduino] — generate standalone C99 for small-device deployment without libminerva. + [NOTE] ==== LLM-specific Java runtimes (Llama, Gemma, Qwen, BERT) moved to the From 93c71ef58eb32e83ce738e44ce58bd6bbcd12a25 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 8 Jun 2026 17:39:19 +0200 Subject: [PATCH 3/3] feat(minerva): add secure MCU export examples Refs #687 --- README.md | 8 + docs/export/minerva.md | 8 + .../minerva-secure-mcu-export.adoc | 9 + .../ROOT/pages/how-to/minerva-export.adoc | 11 + .../tutorials/minerva-getting-started.adoc | 44 ++- .../skainet-compile-minerva/build.gradle.kts | 56 +++ .../examples/MinervaSecureMcuExportSamples.kt | 371 ++++++++++++++++++ .../MinervaSecureMcuExportSamplesTest.kt | 96 +++++ 8 files changed, 599 insertions(+), 4 deletions(-) create mode 100644 skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamples.kt create mode 100644 skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamplesTest.kt diff --git a/README.md b/README.md index 2d3c2508..3471cf92 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,14 @@ Start here: - [Minerva export how-to](docs/modules/ROOT/pages/how-to/minerva-export.adoc) — configure compiler paths, keys, calibration, CMake/CTest host verification, and troubleshooting. - [How Minerva secure MCU export fits](docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc) — understand why Minerva is not an Arduino replacement and when to choose StableHLO instead. +Runnable examples: + +```bash +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples \ + -Pminerva.example=sensor-classifier +``` + --- ## Features diff --git a/docs/export/minerva.md b/docs/export/minerva.md index a1380f2f..2b068b21 100644 --- a/docs/export/minerva.md +++ b/docs/export/minerva.md @@ -172,6 +172,14 @@ The generated firmware example intentionally contains integration placeholders. ./gradlew :skainet-compile:skainet-compile-minerva:runMinervaTinyMlpSample ``` +Additional secure MCU examples live in `MinervaSecureMcuExportSamples`: + +```bash +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples \ + -Pminerva.example=sensor-classifier +``` + Without `MINERVA_COMPILER_SCRIPT`, the task runs a dry validation through compatibility, lowering, and in-memory NPZ generation. Add `-Pminerva.compilerScript`, `-Pminerva.runtimeRoot`, `-Pminerva.calibrationNpz`, and `-Pminerva.keyFile` to run the real compiler path. `MinervaTinyMlpExportSampleTest` validates the sample graph and NPZ generation without real device keys. ## Export Path Choice diff --git a/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc b/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc index e26b0336..e439f0ad 100644 --- a/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc +++ b/docs/modules/ROOT/pages/explanation/minerva-secure-mcu-export.adoc @@ -39,6 +39,15 @@ Minerva export starts from a SKaiNET `ComputeGraph`. That graph can be produced ONNX is not special in the Minerva pipeline. It can be useful as an input format for inspection or conversion, but the first Minerva implementation does not include a general ONNX-to-Minerva importer. The contract is the SKaiNET graph handed to `MinervaExportFacade`. +== Example Patterns + +The maintained examples intentionally stay close to libminerva's own MCU story: + +* `sensor-classifier` uses an 8->16->8->4 static MLP. Four slots can be direct ADC inputs, and the remaining slots can hold rolling features or zero padding. Firmware can map the four outputs to class LEDs or action states. +* `safety-guard` uses a 6->10->4->3 static MLP for protect / warn / allow decisions. It demonstrates the same Minerva export path with a different input contract and class vocabulary. + +Both examples are source-side SKaiNET graphs. libminerva still owns the encrypted weight packaging, runtime initialization, integrity checks, and secure MCU execution. + == Why the Scope Is Narrow The phase-one backend intentionally supports static sequential MLPs only: diff --git a/docs/modules/ROOT/pages/how-to/minerva-export.adoc b/docs/modules/ROOT/pages/how-to/minerva-export.adoc index 2b2513bd..a8852226 100644 --- a/docs/modules/ROOT/pages/how-to/minerva-export.adoc +++ b/docs/modules/ROOT/pages/how-to/minerva-export.adoc @@ -262,6 +262,17 @@ Run the sample after configuring libminerva: ./gradlew :skainet-compile:skainet-compile-minerva:runMinervaTinyMlpSample ---- +Run the richer secure MCU examples: + +[source,bash] +---- +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples \ + -Pminerva.example=safety-guard +---- + +`sensor-classifier` mirrors libminerva's ATmega sensor-classification shape with eight input features and four output classes. `safety-guard` shows a smaller health-decision classifier with three output classes. Both examples are dry-run friendly and switch to real compiler execution when the normal `MINERVA_*` runtime settings are present. + Without `MINERVA_COMPILER_SCRIPT`, the sample task runs a dry validation through compatibility, lowering, and in-memory NPZ generation. Configure the runtime with Gradle properties or matching environment variables to run the real compiler and host verification: [source,bash] diff --git a/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc b/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc index 7d782124..b58af6d7 100644 --- a/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc +++ b/docs/modules/ROOT/pages/tutorials/minerva-getting-started.adoc @@ -38,7 +38,31 @@ Dry validation completed: graph is compatible and model.npz was generated in mem The status is `FAILED` because the real compiler prerequisite is intentionally missing. The dry run is still useful because compatibility and NPZ generation have already executed. -== 2. Run Against libminerva +== 2. Try the Secure MCU Examples + +The Minerva module also ships two SKaiNET examples inspired by libminerva's secure MCU flow: + +* `sensor-classifier`: an 8-feature, 4-class ATmega-style sensor classifier that maps naturally to ADC inputs and class LEDs. +* `safety-guard`: a 6-feature health classifier for `protect`, `warn`, and `allow` decisions. + +Run both examples in dry mode: + +[source,bash] +---- +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples +---- + +Run only one: + +[source,bash] +---- +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples \ + -Pminerva.example=sensor-classifier +---- + +Both examples use `MinervaExportFacade` and produce the same Minerva intermediate and `model.npz` path as the tiny sample. Without `MINERVA_COMPILER_SCRIPT`, they stop after dry validation with a compiler prerequisite failure. + +== 3. Run Against libminerva Point SKaiNET at a libminerva checkout: @@ -64,7 +88,19 @@ The helper creates local, untracked profile inputs under `build/minerva-real-run It then runs the gated `minervaHostVerification` Gradle task with CMake and CTest enabled. The default tolerance is deliberately loose for this profile because current libminerva Q8 host output is treated as a runtime smoke proof, not a strict floating-point parity test. -== 3. Inspect the Bundle +You can also run the secure MCU examples against the same configured runtime: + +[source,bash] +---- +./gradlew :skainet-compile:skainet-compile-minerva:runMinervaSecureMcuExamples \ + -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" \ + -Pminerva.runtimeRoot="$MINERVA_RUNTIME_ROOT" \ + -Pminerva.calibrationNpz="$MINERVA_CALIBRATION_NPZ" \ + -Pminerva.keyFile="$MINERVA_KEY_FILE" \ + -Pminerva.hostVerification.tolerance="${MINERVA_HOST_TOLERANCE:-1.0}" +---- + +== 4. Inspect the Bundle A successful run writes: @@ -93,7 +129,7 @@ Start with `manifest.json`. It records the target, quantization, compiler comman Do not copy real device keys into the bundle. `include/secrets.example.h` is intentionally a placeholder file. -== 4. Export Your Own Compatible Graph +== 5. Export Your Own Compatible Graph The public entry point is `MinervaExportFacade`: @@ -118,7 +154,7 @@ println(bundle.manifestPath) The `graph` can come from the Kotlin DSL, a traced forward pass, a hand-built `ComputeGraph`, or an import pipeline that you reduce to the supported static MLP contract before export. Minerva is not an ONNX-only path; ONNX is just one possible source if you convert or validate it before calling the facade. -== 5. Continue +== 6. Continue * xref:how-to/minerva-export.adoc[Minerva export how-to] covers every option, metadata key, and troubleshooting case. * xref:explanation/minerva-secure-mcu-export.adoc[How Minerva secure MCU export fits] explains why Minerva is a sibling backend beside StableHLO and Arduino/C99 export. diff --git a/skainet-compile/skainet-compile-minerva/build.gradle.kts b/skainet-compile/skainet-compile-minerva/build.gradle.kts index 8f4fbab3..ea990370 100644 --- a/skainet-compile/skainet-compile-minerva/build.gradle.kts +++ b/skainet-compile/skainet-compile-minerva/build.gradle.kts @@ -127,3 +127,59 @@ tasks.register("runMinervaTinyMlpSample") { environment("MINERVA_HOST_LIBRARIES", it) } } + +tasks.register("runMinervaSecureMcuExamples") { + group = "application" + description = "Runs Minerva secure MCU export examples for SKaiNET graphs." + + dependsOn(tasks.named("jvmJar")) + + mainClass.set("sk.ainet.compile.minerva.examples.MinervaSecureMcuExportSamples") + workingDir = rootProject.projectDir + + classpath = files( + jvmMainCompilation.runtimeDependencyFiles, + tasks.named("jvmJar").get().outputs.files + ) + + providers.gradleProperty("minerva.example").orNull?.let { + args(it) + } + + minervaCompilerScript.orElse(providers.environmentVariable("MINERVA_COMPILER_SCRIPT")).orNull?.let { + environment("MINERVA_COMPILER_SCRIPT", it) + } + minervaRuntimeRoot.orElse(providers.environmentVariable("MINERVA_RUNTIME_ROOT")).orNull?.let { + environment("MINERVA_RUNTIME_ROOT", it) + } + minervaKeyFile.orElse(providers.environmentVariable("MINERVA_KEY_FILE")).orNull?.let { + environment("MINERVA_KEY_FILE", it) + } + minervaCalibrationNpz.orElse(providers.environmentVariable("MINERVA_CALIBRATION_NPZ")).orNull?.let { + environment("MINERVA_CALIBRATION_NPZ", it) + } + minervaRunCmakeBuildForSample.orNull?.let { + environment("MINERVA_RUN_CMAKE", it) + } + minervaRunCTestForSample.orNull?.let { + environment("MINERVA_RUN_CTEST", it) + } + minervaHostVerificationTolerance.orElse(providers.environmentVariable("MINERVA_HOST_TOLERANCE")).orNull?.let { + environment("MINERVA_HOST_TOLERANCE", it) + } + minervaHostOutputPath.orElse(providers.environmentVariable("MINERVA_HOST_OUTPUT_PATH")).orNull?.let { + environment("MINERVA_HOST_OUTPUT_PATH", it) + } + minervaHostAdapterSource.orElse(providers.environmentVariable("MINERVA_HOST_ADAPTER_SOURCE")).orNull?.let { + environment("MINERVA_HOST_ADAPTER_SOURCE", it) + } + minervaHostIncludeDirs.orElse(providers.environmentVariable("MINERVA_HOST_INCLUDE_DIRS")).orNull?.let { + environment("MINERVA_HOST_INCLUDE_DIRS", it) + } + minervaHostLibraryDirs.orElse(providers.environmentVariable("MINERVA_HOST_LIBRARY_DIRS")).orNull?.let { + environment("MINERVA_HOST_LIBRARY_DIRS", it) + } + minervaHostLibraries.orElse(providers.environmentVariable("MINERVA_HOST_LIBRARIES")).orNull?.let { + environment("MINERVA_HOST_LIBRARIES", it) + } +} diff --git a/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamples.kt b/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamples.kt new file mode 100644 index 00000000..57f608fd --- /dev/null +++ b/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamples.kt @@ -0,0 +1,371 @@ +package sk.ainet.compile.minerva.examples + +import sk.ainet.compile.export.GraphExportStatus +import sk.ainet.compile.minerva.MinervaExportFacade +import sk.ainet.compile.minerva.MinervaExportFailureKind +import sk.ainet.compile.minerva.MinervaExportOptions +import sk.ainet.compile.minerva.MinervaHostVerificationMetadata +import sk.ainet.lang.graph.DefaultComputeGraph +import sk.ainet.lang.graph.GraphEdge +import sk.ainet.lang.graph.GraphNode +import sk.ainet.lang.tensor.ops.AddOperation +import sk.ainet.lang.tensor.ops.InputOperation +import sk.ainet.lang.tensor.ops.MatmulOperation +import sk.ainet.lang.tensor.ops.ReluOperation +import sk.ainet.lang.tensor.ops.SigmoidOperation +import sk.ainet.lang.tensor.ops.TanhOperation +import sk.ainet.lang.tensor.ops.TensorSpec +import sk.ainet.lang.types.DType + +/** + * Runnable SKaiNET examples inspired by libminerva's secure MCU quickstart + * and ATmega328P sensor classification demo. + */ +internal object MinervaSecureMcuExportSamples { + + @JvmStatic + fun main(args: Array) { + val selected = args.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet() + val scenarios = scenarios().filter { scenario -> + selected.isEmpty() || scenario.id.lowercase() in selected + } + require(scenarios.isNotEmpty()) { + "Unknown Minerva example '${args.joinToString(" ")}'. Available: ${ + scenarios().joinToString { it.id } + }" + } + + val env = System.getenv() + scenarios.forEach { scenario -> + runScenario(scenario, env) + } + } + + internal fun scenarios(): List { + return listOf(sensorClassifier(), safetyGuard()) + } + + internal fun sensorClassifier(): MinervaExampleScenario { + return MinervaExampleScenario( + id = "sensor-classifier", + projectName = "SecureSensorClassifier", + description = "8-feature MCU sensor classifier with four output classes.", + graph = sequentialMlpGraph( + inputName = "sensor_q8_window", + inputWidth = 8, + layers = listOf( + DenseLayerSpec("hidden0", outputWidth = 16, activation = ExampleActivation.RELU, weightStart = -0.18f), + DenseLayerSpec("hidden1", outputWidth = 8, activation = ExampleActivation.RELU, weightStart = 0.12f), + DenseLayerSpec("class_logits", outputWidth = 4, activation = ExampleActivation.SIGMOID, weightStart = -0.07f) + ) + ), + labels = listOf("idle", "warmup", "nominal", "service"), + notes = listOf( + "A0-A3 can feed the first four input slots after ADC-to-Q8 scaling.", + "Remaining slots can hold rolling deltas, averages, or zero padding.", + "Firmware can map argmax output classes to LEDs, relays, or telemetry states." + ) + ) + } + + internal fun safetyGuard(): MinervaExampleScenario { + return MinervaExampleScenario( + id = "safety-guard", + projectName = "SecureSafetyGuard", + description = "Small health classifier for protect / warn / allow decisions.", + graph = sequentialMlpGraph( + inputName = "health_window", + inputWidth = 6, + layers = listOf( + DenseLayerSpec("feature_mix", outputWidth = 10, activation = ExampleActivation.TANH, weightStart = 0.09f), + DenseLayerSpec("guard_hidden", outputWidth = 4, activation = ExampleActivation.RELU, weightStart = -0.14f), + DenseLayerSpec("guard_logits", outputWidth = 3, activation = ExampleActivation.SIGMOID, weightStart = 0.04f) + ) + ), + labels = listOf("protect", "warn", "allow"), + notes = listOf( + "Inputs can represent temperature, voltage, current, vibration, and two rolling features.", + "Use host verification whenever calibration, keys, compiler, or runtime changes.", + "Keep real keys outside the generated bundle and source control." + ) + ) + } + + internal fun exportOptions( + scenario: MinervaExampleScenario, + env: Map = emptyMap() + ): MinervaExportOptions { + val runCmakeBuild = envFlag(env, "MINERVA_RUN_CMAKE") + val runCTest = envFlag(env, "MINERVA_RUN_CTEST") + val metadata = mutableMapOf( + "sample" to "minerva-${scenario.id}", + "sampleDescription" to scenario.description, + "classLabels" to scenario.labels.joinToString("|"), + "sourceShape" to "skainet-compute-graph", + "runtimePattern" to "libminerva-secure-mcu" + ) + if (runCmakeBuild) metadata[MinervaHostVerificationMetadata.RUN_CMAKE_BUILD] = "true" + if (runCTest) metadata[MinervaHostVerificationMetadata.RUN_CTEST] = "true" + envPath(env, "MINERVA_HOST_OUTPUT_PATH")?.let { + metadata[MinervaHostVerificationMetadata.HOST_OUTPUT_PATH] = it + } + envPath(env, "MINERVA_HOST_ADAPTER_SOURCE")?.let { + metadata[MinervaHostVerificationMetadata.HOST_ADAPTER_SOURCE] = it + } + envPath(env, "MINERVA_HOST_INCLUDE_DIRS")?.let { + metadata[MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS] = it + } + envPath(env, "MINERVA_HOST_LIBRARY_DIRS")?.let { + metadata[MinervaHostVerificationMetadata.HOST_LIBRARY_DIRS] = it + } + envPath(env, "MINERVA_HOST_LIBRARIES")?.let { + metadata[MinervaHostVerificationMetadata.HOST_LIBRARIES] = it + } + + return MinervaExportOptions( + outputDir = "build/minerva-examples", + projectName = scenario.projectName, + runtimeRoot = envPath(env, "MINERVA_RUNTIME_ROOT"), + compilerScript = envPath(env, "MINERVA_COMPILER_SCRIPT"), + keyFile = envPath(env, "MINERVA_KEY_FILE"), + calibrationNpz = envPath(env, "MINERVA_CALIBRATION_NPZ"), + hostVerificationTolerance = envFloat(env, "MINERVA_HOST_TOLERANCE") ?: 1.0e-3f, + metadata = metadata + ) + } + + private fun runScenario(scenario: MinervaExampleScenario, env: Map) { + val options = exportOptions(scenario, env) + val result = MinervaExportFacade().exportGraph(scenario.graph, options) + + println("Minerva example: ${scenario.id}") + println("Description: ${scenario.description}") + println("Labels: ${scenario.labels.joinToString(", ")}") + scenario.notes.forEach { note -> println("Note: $note") } + println("Export status: ${result.status}") + result.compatibilityReport?.let { report -> + println("Layers: ${report.layerCount}") + println("Estimated SRAM bytes: ${report.estimatedSramBytes}") + } + result.bundle?.let { bundle -> + println("Project bundle: ${bundle.outputDir}") + println("Manifest: ${bundle.manifestPath}") + } + result.hostVerification?.let { verification -> + println("Host verification: ${verification.status}") + } + if (result.status == GraphExportStatus.FAILED && + result.failure?.kind == MinervaExportFailureKind.COMPILER_PREREQUISITE_FAILED + ) { + println("Dry validation completed: graph is compatible and model.npz was generated in memory.") + println() + return + } + if (result.failed) { + error(result.failure?.message ?: "Minerva example '${scenario.id}' failed.") + } + println() + } + + private fun sequentialMlpGraph( + inputName: String, + inputWidth: Int, + layers: List + ): DefaultComputeGraph { + require(inputWidth > 0) { "inputWidth must be positive" } + require(layers.isNotEmpty()) { "at least one layer is required" } + + val nodes = mutableListOf() + val edges = mutableListOf() + val inputSpec = spec(inputName, 1, inputWidth) + val input = inputNode("input", inputSpec) + nodes += input + + var producer = input + var producerSpec = inputSpec + var inputSlotWidth = inputWidth + + layers.forEachIndexed { index, layer -> + val layerPrefix = "${index}_${layer.id}" + val weightSpec = spec( + name = "${layer.id}_weights", + inputSlotWidth, + layer.outputWidth, + values = patternedValues(inputSlotWidth * layer.outputWidth, layer.weightStart) + ) + val weight = inputNode("${layerPrefix}_weights", weightSpec) + val matmulSpec = spec("${layer.id}_matmul", 1, layer.outputWidth) + val matmul = matmulNode("${layerPrefix}_matmul", producerSpec, weightSpec, matmulSpec) + val biasSpec = spec( + name = "${layer.id}_bias", + 1, + layer.outputWidth, + values = patternedValues(layer.outputWidth, start = layer.weightStart / 3.0f) + ) + val bias = inputNode("${layerPrefix}_bias", biasSpec) + val biasedSpec = spec("${layer.id}_biased", 1, layer.outputWidth) + val add = addNode("${layerPrefix}_bias_add", matmulSpec, biasSpec, biasedSpec) + + nodes += listOf(weight, matmul, bias, add) + edges += edge("${producer.id}_to_${matmul.id}", producer, matmul, producerSpec, destinationInputIndex = 0) + edges += edge("${weight.id}_to_${matmul.id}", weight, matmul, weightSpec, destinationInputIndex = 1) + edges += edge("${matmul.id}_to_${add.id}", matmul, add, matmulSpec, destinationInputIndex = 0) + edges += edge("${bias.id}_to_${add.id}", bias, add, biasSpec, destinationInputIndex = 1) + + val activated = activationNode( + id = "${layerPrefix}_${layer.activation.id}", + activation = layer.activation, + input = biasedSpec, + output = spec(layer.outputName(index == layers.lastIndex), 1, layer.outputWidth) + ) + if (activated == null) { + producer = add + producerSpec = biasedSpec + } else { + nodes += activated + edges += edge("${add.id}_to_${activated.id}", add, activated, biasedSpec) + producer = activated + producerSpec = activated.outputs.single() + } + inputSlotWidth = layer.outputWidth + } + + return graphOf(nodes, edges) + } + + private fun activationNode( + id: String, + activation: ExampleActivation, + input: TensorSpec, + output: TensorSpec + ): GraphNode? { + val operation = when (activation) { + ExampleActivation.LINEAR -> return null + ExampleActivation.RELU -> ReluOperation() + ExampleActivation.SIGMOID -> SigmoidOperation() + ExampleActivation.TANH -> TanhOperation() + } + return GraphNode( + id = id, + operation = operation, + inputs = listOf(input), + outputs = listOf(output) + ) + } + + private fun inputNode(id: String, output: TensorSpec): GraphNode { + return GraphNode( + id = id, + operation = InputOperation(), + inputs = emptyList(), + outputs = listOf(output) + ) + } + + private fun matmulNode( + id: String, + left: TensorSpec, + right: TensorSpec, + output: TensorSpec + ): GraphNode { + return GraphNode( + id = id, + operation = MatmulOperation(), + inputs = listOf(left, right), + outputs = listOf(output) + ) + } + + private fun addNode( + id: String, + left: TensorSpec, + right: TensorSpec, + output: TensorSpec + ): GraphNode { + return GraphNode( + id = id, + operation = AddOperation(), + inputs = listOf(left, right), + outputs = listOf(output) + ) + } + + private fun spec(name: String, vararg shape: Int, values: List? = null): TensorSpec { + val metadata: Map = values?.let { mapOf("values" to it.toFloatArray()) } ?: emptyMap() + return TensorSpec(name, shape.toList(), "Float32", metadata = metadata) + } + + private fun patternedValues(count: Int, start: Float): List { + return List(count) { index -> + val wave = ((index % 7) - 3) * 0.025f + start + wave + (index / 7) * 0.003f + } + } + + private fun graphOf(nodes: List, edges: List): DefaultComputeGraph { + val graph = DefaultComputeGraph() + nodes.forEach { graph.addNode(it) } + edges.forEach { graph.addEdge(it) } + return graph + } + + private fun edge( + id: String, + source: GraphNode, + destination: GraphNode, + spec: TensorSpec, + destinationInputIndex: Int = 0 + ): GraphEdge { + return GraphEdge( + id = id, + source = source, + destination = destination, + destinationInputIndex = destinationInputIndex, + tensorSpec = spec + ) + } + + private fun envPath(env: Map, name: String): String? { + return env[name]?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun envFlag(env: Map, name: String): Boolean { + return env[name]?.equals("true", ignoreCase = true) == true + } + + private fun envFloat(env: Map, name: String): Float? { + return env[name]?.trim()?.takeIf { it.isNotEmpty() }?.toFloat() + } +} + +internal data class MinervaExampleScenario( + val id: String, + val projectName: String, + val description: String, + val graph: DefaultComputeGraph, + val labels: List, + val notes: List +) + +private data class DenseLayerSpec( + val id: String, + val outputWidth: Int, + val activation: ExampleActivation, + val weightStart: Float +) { + init { + require(id.isNotBlank()) { "id cannot be blank" } + require(outputWidth > 0) { "outputWidth must be positive" } + } + + fun outputName(isLast: Boolean): String { + return if (isLast) "y" else "${id}_${activation.id}" + } +} + +private enum class ExampleActivation(val id: String) { + LINEAR("linear"), + RELU("relu"), + SIGMOID("sigmoid"), + TANH("tanh") +} diff --git a/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamplesTest.kt b/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamplesTest.kt new file mode 100644 index 00000000..37f2ad4f --- /dev/null +++ b/skainet-compile/skainet-compile-minerva/src/jvmTest/kotlin/sk/ainet/compile/minerva/examples/MinervaSecureMcuExportSamplesTest.kt @@ -0,0 +1,96 @@ +package sk.ainet.compile.minerva.examples + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import sk.ainet.compile.export.GraphExportArtifactRole +import sk.ainet.compile.export.GraphExportStatus +import sk.ainet.compile.minerva.MinervaActivation +import sk.ainet.compile.minerva.MinervaCompatibilityValidator +import sk.ainet.compile.minerva.MinervaExportFacade +import sk.ainet.compile.minerva.MinervaExportFailureKind +import sk.ainet.compile.minerva.MinervaHostVerificationMetadata + +class MinervaSecureMcuExportSamplesTest { + + @Test + fun sensorClassifierMatchesLibminervaAtmegaDemoShape() { + val scenario = MinervaSecureMcuExportSamples.sensorClassifier() + val options = MinervaSecureMcuExportSamples.exportOptions(scenario) + val report = MinervaCompatibilityValidator().validate(scenario.graph, options) + + assertTrue(report.compatible, report.issues.joinToString { it.message }) + assertEquals(3, report.layerCount) + + val result = MinervaExportFacade().exportGraph(scenario.graph, options) + + assertEquals(GraphExportStatus.FAILED, result.status) + assertEquals(MinervaExportFailureKind.COMPILER_PREREQUISITE_FAILED, result.failure?.kind) + val intermediate = assertNotNull(result.intermediate) + assertEquals(listOf(1, 8), intermediate.input.shape) + assertEquals(listOf(1, 4), intermediate.output.shape) + assertEquals( + listOf(MinervaActivation.RELU, MinervaActivation.RELU, MinervaActivation.SIGMOID), + intermediate.layers.map { it.activation } + ) + assertEquals("idle|warmup|nominal|service", options.metadata["classLabels"]) + assertTrue(assertNotNull(result.npzModel).bytes.isNotEmpty()) + assertTrue(result.artifacts.any { it.role == GraphExportArtifactRole.INTERMEDIATE && it.path == "model.npz" }) + } + + @Test + fun safetyGuardUsesDifferentInputAndOutputContract() { + val scenario = MinervaSecureMcuExportSamples.safetyGuard() + val options = MinervaSecureMcuExportSamples.exportOptions(scenario) + val result = MinervaExportFacade().exportGraph(scenario.graph, options) + + assertEquals(GraphExportStatus.FAILED, result.status) + assertEquals(MinervaExportFailureKind.COMPILER_PREREQUISITE_FAILED, result.failure?.kind) + val intermediate = assertNotNull(result.intermediate) + assertEquals(3, intermediate.layerCount) + assertEquals(listOf(1, 6), intermediate.input.shape) + assertEquals(listOf(1, 3), intermediate.output.shape) + assertEquals( + listOf(MinervaActivation.TANH, MinervaActivation.RELU, MinervaActivation.SIGMOID), + intermediate.layers.map { it.activation } + ) + assertEquals("protect|warn|allow", options.metadata["classLabels"]) + } + + @Test + fun exampleOptionsCarryRealRuntimeMetadata() { + val scenario = MinervaSecureMcuExportSamples.sensorClassifier() + val options = MinervaSecureMcuExportSamples.exportOptions( + scenario, + env = mapOf( + "MINERVA_COMPILER_SCRIPT" to "/opt/libminerva/compiler/minerva_compile.py", + "MINERVA_RUNTIME_ROOT" to "/opt/libminerva", + "MINERVA_KEY_FILE" to "/secure/project/device.key", + "MINERVA_CALIBRATION_NPZ" to "/secure/project/calibration.npz", + "MINERVA_RUN_CMAKE" to "true", + "MINERVA_RUN_CTEST" to "true", + "MINERVA_HOST_TOLERANCE" to "0.8", + "MINERVA_HOST_OUTPUT_PATH" to "host-output.txt", + "MINERVA_HOST_ADAPTER_SOURCE" to "/project/minerva_adapter.c", + "MINERVA_HOST_INCLUDE_DIRS" to "/project/minerva-secrets", + "MINERVA_HOST_LIBRARY_DIRS" to "/opt/libminerva/lib", + "MINERVA_HOST_LIBRARIES" to "minerva" + ) + ) + + assertEquals("/opt/libminerva/compiler/minerva_compile.py", options.compilerScript) + assertEquals("/opt/libminerva", options.runtimeRoot) + assertEquals("/secure/project/device.key", options.keyFile) + assertEquals("/secure/project/calibration.npz", options.calibrationNpz) + assertEquals(0.8f, options.hostVerificationTolerance) + assertEquals("minerva-sensor-classifier", options.metadata["sample"]) + assertEquals("true", options.metadata[MinervaHostVerificationMetadata.RUN_CMAKE_BUILD]) + assertEquals("true", options.metadata[MinervaHostVerificationMetadata.RUN_CTEST]) + assertEquals("host-output.txt", options.metadata[MinervaHostVerificationMetadata.HOST_OUTPUT_PATH]) + assertEquals("/project/minerva_adapter.c", options.metadata[MinervaHostVerificationMetadata.HOST_ADAPTER_SOURCE]) + assertEquals("/project/minerva-secrets", options.metadata[MinervaHostVerificationMetadata.HOST_INCLUDE_DIRS]) + assertEquals("/opt/libminerva/lib", options.metadata[MinervaHostVerificationMetadata.HOST_LIBRARY_DIRS]) + assertEquals("minerva", options.metadata[MinervaHostVerificationMetadata.HOST_LIBRARIES]) + } +}