diff --git a/docs/export/minerva.md b/docs/export/minerva.md index 7d8a6ef6..d344df0e 100644 --- a/docs/export/minerva.md +++ b/docs/export/minerva.md @@ -65,13 +65,18 @@ build/minerva/TinySecureMlp/ host/ CMakeLists.txt main.c + reference-input.txt + reference-output.txt + observed-output.txt # optional output from a real host run firmware/ main.c ``` ## Host Verification and CI -Host verification checks package structure, generated weight files, `model.npz` integrity, placeholder secret hygiene, and SKaiNET reference output generation. Add these metadata keys to opt into CMake, CTest, and parity comparison with a host output file: +Host verification checks package structure, generated weight files, `model.npz` integrity, placeholder secret hygiene, and SKaiNET reference fixture generation. The packager writes deterministic `host/reference-input.txt` and `host/reference-output.txt` files and records them in `manifest.json`. A real host run can write comma- or whitespace-separated float outputs to `host/observed-output.txt` for zero-config parity comparison. + +Add these metadata keys to opt into CMake, CTest, and parity comparison with a custom host output file: ```kotlin metadata = mapOf( @@ -81,6 +86,8 @@ metadata = mapOf( ) ``` +`HOST_OUTPUT_PATH` is optional when the host run writes `host/observed-output.txt`. + CI recipe: ```bash @@ -88,11 +95,13 @@ CI recipe: ./gradlew :skainet-compile:skainet-compile-minerva:minervaHostVerification \ -Pminerva.hostVerification.enabled=true \ -Pminerva.runtimeRoot="$MINERVA_RUNTIME_ROOT" \ - -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" -cmake -S build/minerva/TinySecureMlp/host -B build/minerva/TinySecureMlp/host/build -ctest --test-dir build/minerva/TinySecureMlp/host/build --output-on-failure + -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" \ + -Pminerva.calibrationNpz="$MINERVA_CALIBRATION_NPZ" \ + -Pminerva.keyFile="$MINERVA_KEY_FILE" ``` +`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. + ## ONNX to Minerva Use the existing ONNX loader to inspect a model and reject unsupported operators before constructing a compatible SKaiNET `ComputeGraph`: diff --git a/docs/modules/ROOT/pages/how-to/minerva-export.adoc b/docs/modules/ROOT/pages/how-to/minerva-export.adoc index 0b25f05e..0909e035 100644 --- a/docs/modules/ROOT/pages/how-to/minerva-export.adoc +++ b/docs/modules/ROOT/pages/how-to/minerva-export.adoc @@ -128,15 +128,18 @@ build/minerva/TinySecureMlp/ host/ CMakeLists.txt main.c + reference-input.txt + reference-output.txt + observed-output.txt # optional output from a real host run firmware/ main.c ---- -The manifest records the target, quantization, libminerva root, compiler command summary, NPZ schema version, layer count, and generated files. `secrets.example.h` contains placeholder values only. +The manifest records the target, quantization, libminerva root, compiler command summary, NPZ schema version, layer count, reference fixture paths, and generated files. `secrets.example.h` contains placeholder values only. == Host Verification -Host verification always checks the package structure, generated weight files, `model.npz` integrity, and placeholder secret hygiene. It also computes the SKaiNET reference output for a deterministic reference input. +Host verification always checks the package structure, generated weight files, `model.npz` integrity, and placeholder secret hygiene. It also writes and validates deterministic `host/reference-input.txt` and `host/reference-output.txt` fixtures. Use these metadata keys to opt into external host checks: @@ -151,6 +154,8 @@ metadata = mapOf( `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. + Local CI recipe: [source,bash] @@ -159,12 +164,12 @@ Local CI recipe: ./gradlew :skainet-compile:skainet-compile-minerva:minervaHostVerification \ -Pminerva.hostVerification.enabled=true \ -Pminerva.runtimeRoot="$MINERVA_RUNTIME_ROOT" \ - -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" -cmake -S build/minerva/TinySecureMlp/host -B build/minerva/TinySecureMlp/host/build -ctest --test-dir build/minerva/TinySecureMlp/host/build --output-on-failure + -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" \ + -Pminerva.calibrationNpz="$MINERVA_CALIBRATION_NPZ" \ + -Pminerva.keyFile="$MINERVA_KEY_FILE" ---- -The Gradle `minervaHostVerification` task is gated. It only runs when the `minerva.hostVerification.enabled`, `minerva.runtimeRoot`, and `minerva.compilerScript` properties are present. +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. == Firmware Integration @@ -227,6 +232,16 @@ Without `MINERVA_COMPILER_SCRIPT`, the sample task runs a dry validation through -Pminerva.hostVerification.runCTest=true ---- +For CI, prefer the gated verification profile: + +[source,bash] +---- +./gradlew :skainet-compile:skainet-compile-minerva:minervaHostVerification \ + -Pminerva.hostVerification.enabled=true \ + -Pminerva.compilerScript="$MINERVA_COMPILER_SCRIPT" \ + -Pminerva.runtimeRoot="$MINERVA_RUNTIME_ROOT" +---- + The sample graph is covered by `MinervaTinyMlpExportSampleTest`, which validates compatibility, lowering, and NPZ generation without requiring real device keys. == Troubleshooting diff --git a/skainet-compile/skainet-compile-minerva/build.gradle.kts b/skainet-compile/skainet-compile-minerva/build.gradle.kts index e5a17305..6b22084d 100644 --- a/skainet-compile/skainet-compile-minerva/build.gradle.kts +++ b/skainet-compile/skainet-compile-minerva/build.gradle.kts @@ -39,20 +39,31 @@ val minervaCalibrationNpz = providers.gradleProperty("minerva.calibrationNpz") val minervaRunCmakeBuild = providers.gradleProperty("minerva.hostVerification.runCmakeBuild") val minervaRunCTest = providers.gradleProperty("minerva.hostVerification.runCTest") val minervaHostOutputPath = providers.gradleProperty("minerva.hostVerification.hostOutputPath") +val minervaRunCmakeBuildForSample = minervaRunCmakeBuild + .orElse(providers.environmentVariable("MINERVA_RUN_CMAKE")) + .orElse(minervaHostVerificationEnabled.map { it.toString() }) +val minervaRunCTestForSample = minervaRunCTest + .orElse(providers.environmentVariable("MINERVA_RUN_CTEST")) + .orElse(minervaHostVerificationEnabled.map { it.toString() }) val jvmMainCompilation = kotlin.targets.getByName("jvm").compilations.getByName("main") tasks.register("minervaHostVerification") { group = "verification" - description = "Gated lifecycle hook for external Minerva host verification in CI." + description = "Runs external Minerva compiler and host verification when explicitly configured." enabled = minervaHostVerificationEnabled.get() && minervaRuntimeRoot.isPresent && minervaCompilerScript.isPresent if (enabled) { - dependsOn("jvmTest") + dependsOn("jvmTest", "runMinervaTinyMlpSample") } inputs.property("minerva.runtimeRoot", minervaRuntimeRoot.orElse("")) inputs.property("minerva.compilerScript", minervaCompilerScript.orElse("")) + inputs.property("minerva.calibrationNpz", minervaCalibrationNpz.orElse("")) + inputs.property("minerva.keyFile", minervaKeyFile.orElse("")) + inputs.property("minerva.hostVerification.runCmakeBuild", minervaRunCmakeBuildForSample) + inputs.property("minerva.hostVerification.runCTest", minervaRunCTestForSample) + inputs.property("minerva.hostVerification.hostOutputPath", minervaHostOutputPath.orElse("")) } tasks.register("runMinervaTinyMlpSample") { @@ -62,6 +73,7 @@ tasks.register("runMinervaTinyMlpSample") { dependsOn(tasks.named("jvmJar")) mainClass.set("sk.ainet.compile.minerva.examples.MinervaTinyMlpExportSample") + workingDir = rootProject.projectDir classpath = files( jvmMainCompilation.runtimeDependencyFiles, @@ -80,10 +92,10 @@ tasks.register("runMinervaTinyMlpSample") { minervaCalibrationNpz.orElse(providers.environmentVariable("MINERVA_CALIBRATION_NPZ")).orNull?.let { environment("MINERVA_CALIBRATION_NPZ", it) } - minervaRunCmakeBuild.orElse(providers.environmentVariable("MINERVA_RUN_CMAKE")).orNull?.let { + minervaRunCmakeBuildForSample.orNull?.let { environment("MINERVA_RUN_CMAKE", it) } - minervaRunCTest.orElse(providers.environmentVariable("MINERVA_RUN_CTEST")).orNull?.let { + minervaRunCTestForSample.orNull?.let { environment("MINERVA_RUN_CTEST", it) } minervaHostOutputPath.orElse(providers.environmentVariable("MINERVA_HOST_OUTPUT_PATH")).orNull?.let { diff --git a/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackager.kt b/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackager.kt index 3ab4100d..2ce1161e 100644 --- a/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackager.kt +++ b/skainet-compile/skainet-compile-minerva/src/jvmMain/kotlin/sk/ainet/compile/minerva/MinervaJvmCompilerAndPackager.kt @@ -12,6 +12,10 @@ import sk.ainet.compile.export.GraphExportArtifactRole import sk.ainet.compile.export.GraphExportContext import sk.ainet.compile.export.GraphExportStage +private const val HOST_REFERENCE_INPUT_FILE = "reference-input.txt" +private const val HOST_REFERENCE_OUTPUT_FILE = "reference-output.txt" +private const val HOST_OBSERVED_OUTPUT_FILE = "observed-output.txt" + public actual object MinervaPlatformExportDefaults { public actual fun compilerAdapter(): MinervaCompilerAdapter = PythonMinervaCompilerAdapter() @@ -362,7 +366,29 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( } val secretsExample = includeDir.resolve("secrets.example.h") Files.writeString(secretsExample, secretsExampleHeader(options)) - val generatedPaths = mutableListOf(modelPath, weightsC, weightsH, secretsExample) + val referenceInput = MinervaReferenceEvaluator.referenceInput(request.intermediate.input) + val referenceOutput = try { + MinervaReferenceEvaluator.evaluate(request.intermediate, referenceInput) + } catch (exception: RuntimeException) { + throw MinervaPackagingException( + message = "Failed to create Minerva reference output fixture: ${exception.message ?: exception.toString()}", + code = "minerva.packaging.reference_fixture_failed", + remediation = "Ensure lowered Minerva weights and biases contain numeric initializer values.", + details = mapOf("projectName" to options.projectName) + ) + } + val referenceInputPath = hostDir.resolve(HOST_REFERENCE_INPUT_FILE) + val referenceOutputPath = hostDir.resolve(HOST_REFERENCE_OUTPUT_FILE) + Files.writeString(referenceInputPath, floatValuesText(referenceInput)) + Files.writeString(referenceOutputPath, floatValuesText(referenceOutput)) + val generatedPaths = mutableListOf( + modelPath, + weightsC, + weightsH, + secretsExample, + referenceInputPath, + referenceOutputPath + ) debugWeights?.let(generatedPaths::add) if (options.generateHostHarness) { val hostCmake = hostDir.resolve("CMakeLists.txt") @@ -461,6 +487,7 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( val role = when { path.fileName.toString().endsWith(".h") -> GraphExportArtifactRole.HEADER path.fileName.toString().endsWith(".npz") -> GraphExportArtifactRole.INTERMEDIATE + path.fileName.toString().startsWith("reference-") -> GraphExportArtifactRole.TEST_REPORT else -> GraphExportArtifactRole.SOURCE } context.addArtifact( @@ -492,6 +519,8 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( "layers" to request.intermediate.layerCount.toString(), "hostHarness" to options.generateHostHarness.toString(), "firmwareExample" to options.generateFirmwareExample.toString(), + "referenceInputPath" to jsonString("host/$HOST_REFERENCE_INPUT_FILE"), + "referenceOutputPath" to jsonString("host/$HOST_REFERENCE_OUTPUT_FILE"), "manifestPath" to jsonString(manifestPath), "generatedFiles" to generatedFiles.joinToString(prefix = "[", postfix = "]") { jsonString(it) } ) @@ -533,19 +562,22 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( private fun hostMain(request: MinervaProjectPackageRequest): String { val inputCount = request.intermediate.input.elementCount val outputCount = request.intermediate.output.elementCount + val referenceInput = MinervaReferenceEvaluator.referenceInput(request.intermediate.input) return """ |#include |#include |#include "weights.h" | |int main(void) { - | float input[$inputCount] = {0}; + | float input[$inputCount] = {${cFloatInitializer(referenceInput)}}; | float output[$outputCount] = {0}; | | /* Link this harness with libminerva and call the runtime inference entry point here. */ | (void)input; | (void)output; | puts("Minerva host harness packaged successfully."); + | puts("Reference input: $HOST_REFERENCE_INPUT_FILE"); + | puts("Write observed output to: $HOST_OBSERVED_OUTPUT_FILE"); | return 0; |} | @@ -583,6 +615,14 @@ public class JvmMinervaProjectPackager @kotlin.jvm.JvmOverloads constructor( .replace('\\', '/') } + private fun floatValuesText(values: List): String { + return values.joinToString(separator = "\n", postfix = "\n") { it.toString() } + } + + private fun cFloatInitializer(values: List): String { + return values.joinToString(separator = ", ") { "${it}f" } + } + private fun jsonString(value: String): String { val escaped = buildString { value.forEach { char -> @@ -635,8 +675,32 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( details = mapOf("reason" to (exception.message ?: exception.toString())) ) } + val referenceInput = try { + readFloatOutput(projectDir.resolve("host/$HOST_REFERENCE_INPUT_FILE")) + } catch (exception: IllegalArgumentException) { + return failed( + code = "minerva.host_verification.reference_input_invalid", + message = "Packaged Minerva reference input fixture could not be parsed.", + tolerance = tolerance, + remediation = "Regenerate the Minerva package so host/reference-input.txt contains finite float values.", + details = mapOf("reason" to (exception.message ?: exception.toString())) + ) + } + if (referenceInput.size != request.intermediate.input.elementCount) { + return failed( + code = "minerva.host_verification.reference_input_shape_mismatch", + message = "Packaged Minerva reference input length does not match the lowered model input tensor.", + tolerance = tolerance, + remediation = "Regenerate the Minerva package from the current lowered graph.", + details = mapOf( + "expected" to request.intermediate.input.elementCount.toString(), + "observed" to referenceInput.size.toString() + ) + ) + } + val expectedOutput = try { - MinervaReferenceEvaluator.evaluate(request.intermediate) + MinervaReferenceEvaluator.evaluate(request.intermediate, referenceInput) } catch (exception: RuntimeException) { return failed( code = "minerva.host_verification.reference_unavailable", @@ -646,6 +710,7 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( details = mapOf("reason" to (exception.message ?: exception.toString())) ) } + validateReferenceOutputFixture(projectDir, expectedOutput, tolerance)?.let { return it } var hostBuildStatus = MinervaHostVerificationStatus.SKIPPED var hostRunStatus = MinervaHostVerificationStatus.SKIPPED @@ -661,6 +726,7 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( } val hostOutputPath = options.metadata[MinervaHostVerificationMetadata.HOST_OUTPUT_PATH] + ?: "host/$HOST_OBSERVED_OUTPUT_FILE".takeIf { Files.isRegularFile(projectDir.resolve(it)) } val observedOutput = if (hostOutputPath != null) { val outputPath = resolveProjectPath(projectDir, hostOutputPath) try { @@ -732,6 +798,8 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( observedOutput = observedOutput, details = mapOf( "projectDir" to projectDir.toString(), + "referenceInputPath" to "host/$HOST_REFERENCE_INPUT_FILE", + "referenceOutputPath" to "host/$HOST_REFERENCE_OUTPUT_FILE", "referenceOutputValues" to expectedOutput.size.toString() ) ) @@ -749,6 +817,57 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( return verification } + private fun validateReferenceOutputFixture( + projectDir: Path, + expectedOutput: List, + tolerance: Float + ): MinervaHostVerification? { + val outputPath = projectDir.resolve("host/$HOST_REFERENCE_OUTPUT_FILE") + val fixtureOutput = try { + readFloatOutput(outputPath) + } catch (exception: IllegalArgumentException) { + return failed( + code = "minerva.host_verification.reference_output_invalid", + message = "Packaged Minerva reference output fixture could not be parsed.", + tolerance = tolerance, + expectedOutput = expectedOutput, + remediation = "Regenerate the Minerva package so host/reference-output.txt contains finite float values.", + details = mapOf( + "referenceOutputPath" to outputPath.toString(), + "reason" to (exception.message ?: exception.toString()) + ) + ) + } + if (fixtureOutput.size != expectedOutput.size) { + return failed( + code = "minerva.host_verification.reference_output_shape_mismatch", + message = "Packaged Minerva reference output length does not match the SKaiNET reference output length.", + tolerance = tolerance, + expectedOutput = expectedOutput, + observedOutput = fixtureOutput, + remediation = "Regenerate the Minerva package from the current lowered graph.", + details = mapOf( + "expected" to expectedOutput.size.toString(), + "observed" to fixtureOutput.size.toString() + ) + ) + } + val maxAbsoluteError = MinervaReferenceEvaluator.maxAbsoluteError(expectedOutput, fixtureOutput) + if (maxAbsoluteError > tolerance) { + return failed( + code = "minerva.host_verification.reference_output_mismatch", + message = "Packaged Minerva reference output differs from the SKaiNET reference evaluator.", + tolerance = tolerance, + maxAbsoluteError = maxAbsoluteError, + expectedOutput = expectedOutput, + observedOutput = fixtureOutput, + remediation = "Regenerate the Minerva package and inspect lowered layer metadata for stale fixture files.", + details = mapOf("maxAbsoluteError" to maxAbsoluteError.toString()) + ) + } + return null + } + private fun structuralFailure( request: MinervaHostVerificationRequest, projectDir: Path @@ -760,6 +879,8 @@ public class JvmMinervaHostVerifier @kotlin.jvm.JvmOverloads constructor( add(projectDir.resolve("generated/weights.c")) add(projectDir.resolve("include/weights.h")) add(projectDir.resolve("include/secrets.example.h")) + add(projectDir.resolve("host/$HOST_REFERENCE_INPUT_FILE")) + add(projectDir.resolve("host/$HOST_REFERENCE_OUTPUT_FILE")) if (request.options.generateHostHarness) { add(projectDir.resolve("host/CMakeLists.txt")) add(projectDir.resolve("host/main.c")) 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 99164fb4..42fc6187 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 @@ -120,19 +120,29 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue(Files.isRegularFile(projectDir.resolve("generated/weights.c"))) assertTrue(Files.isRegularFile(projectDir.resolve("generated/weights_debug.npz"))) assertTrue(Files.isRegularFile(projectDir.resolve("include/weights.h"))) + assertTrue(Files.isRegularFile(projectDir.resolve("host/reference-input.txt"))) + assertTrue(Files.isRegularFile(projectDir.resolve("host/reference-output.txt"))) assertTrue(Files.isRegularFile(projectDir.resolve("host/main.c"))) assertTrue(Files.isRegularFile(projectDir.resolve("firmware/main.c"))) assertTrue(Files.readString(projectDir.resolve("host/CMakeLists.txt")).contains("add_test")) + val hostMain = Files.readString(projectDir.resolve("host/main.c")) + assertTrue(hostMain.contains("0.25f, 0.5f, 0.75f, 1.0f")) + assertTrue(hostMain.contains("observed-output.txt")) val secretsExample = Files.readString(projectDir.resolve("include/secrets.example.h")) assertTrue(secretsExample.contains("replace-with-device-key")) 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("\"referenceInputPath\": \"host/reference-input.txt\"")) + assertTrue(manifest.contains("\"referenceOutputPath\": \"host/reference-output.txt\"")) assertEquals("manifest.json", bundle.manifestPath) assertTrue(bundle.generatedFiles.contains("generated/weights_debug.npz")) assertTrue(bundle.generatedFiles.contains("include/secrets.example.h")) + assertTrue(bundle.generatedFiles.contains("host/reference-input.txt")) + assertTrue(bundle.generatedFiles.contains("host/reference-output.txt")) assertTrue(context.artifacts.any { it.role == GraphExportArtifactRole.PROJECT_DIRECTORY }) + assertTrue(context.artifacts.any { it.role == GraphExportArtifactRole.TEST_REPORT }) assertTrue(context.diagnostics.any { it.code == "minerva.packaging.completed" }) } @@ -172,6 +182,23 @@ class MinervaJvmCompilerAndPackagerTest { assertEquals(baseline.expectedOutput, verification.observedOutput) } + @Test + fun hostVerifierUsesDefaultObservedOutputPath() { + val fixture = packagedProject(tempDir("host-verify-default-output"), "DefaultOutputVerify") + val baseline = JvmMinervaHostVerifier().verify(fixture.request, fixture.context) + Files.writeString( + fixture.projectDir.resolve("host/observed-output.txt"), + baseline.expectedOutput.joinToString(separator = "\n") + ) + + val verification = JvmMinervaHostVerifier().verify(fixture.request, fixture.context) + + assertEquals(MinervaHostVerificationStatus.PASSED, verification.status) + assertEquals(MinervaHostVerificationStatus.PASSED, verification.hostRunStatus) + assertEquals(MinervaHostVerificationStatus.PASSED, verification.parityStatus) + assertEquals(baseline.expectedOutput, verification.observedOutput) + } + @Test fun hostVerifierFailsWhenHostOutputExceedsTolerance() { val fixture = packagedProject(tempDir("host-verify-mismatch"), "MismatchVerify") @@ -191,6 +218,19 @@ class MinervaJvmCompilerAndPackagerTest { assertTrue((verification.maxAbsoluteError ?: 0.0f) > verification.tolerance) } + @Test + fun hostVerifierFailsWhenReferenceOutputFixtureDrifts() { + val fixture = packagedProject(tempDir("host-verify-reference-drift"), "ReferenceDriftVerify") + Files.writeString(fixture.projectDir.resolve("host/reference-output.txt"), "999 999 999") + + val verification = JvmMinervaHostVerifier().verify(fixture.request, fixture.context) + + assertEquals(MinervaHostVerificationStatus.FAILED, verification.status) + assertEquals("minerva.host_verification.reference_output_mismatch", verification.code) + assertEquals(MinervaHostVerificationStatus.FAILED, verification.parityStatus) + assertTrue((verification.maxAbsoluteError ?: 0.0f) > verification.tolerance) + } + @Test fun hostVerifierFailsWhenPackagedModelWasTampered() { val fixture = packagedProject(tempDir("host-verify-tampered"), "TamperedVerify")