From 6c4f5c5960fb1e9cbe9ec22ce2730ee0629716d3 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Tue, 27 Oct 2026 19:08:33 +0200 Subject: [PATCH 1/3] test(algorithm): pin the Exaflow t-test contract of PR #656 Exaflow PR #656 (algorithms/t-test-corrections) changes t-test values, not their shape: one-sided bounds use t_{1-alpha}, p-values come from the survival function (3.9e-235 instead of 0), a one-row group no longer nulls the independent t-test, and the paired spec now labels x "Measurement 1" and y "Measurement 2" (mean_diff = x - y). The backend passes results and specifications through untouched, so none of this needs backend code. These tests pin what that relies on, across the Gson read -> Jackson write hop of a preview or saved run: - the open side of a one-sided CI stays the string "Infinity"; - a tiny p-value keeps its value; - null statistics stay present as null; - the paired x/y labels and alternative enums reach the UI as sent; - one-sided run parameters reach Exaflow unchanged. --- .../algorithm/ExaflowTTestContractTest.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java diff --git a/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java b/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java new file mode 100644 index 00000000..7e2028ad --- /dev/null +++ b/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java @@ -0,0 +1,99 @@ +package hbp.mip.algorithm; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.reflect.TypeToken; +import hbp.mip.utils.JsonConverters; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the t-test result and specification shapes of Exaflow PR #656 (algorithms/t-test-corrections) + * across the backend's Gson read -> Jackson write hop, which both the preview response and a saved + * run's stored result take. The backend passes t-test results through untouched, so none of the PR's + * changes needs backend code; these tests keep it that way. + */ +class ExaflowTTestContractTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + private JsonNode roundTrip(String exaflowResult) throws Exception { + Object result = JsonConverters.convertJsonStringToObject(exaflowResult, Object.class); + return objectMapper.readTree(JsonConverters.convertObjectToJsonString(result)); + } + + @Test + void keepsTheOpenSideOfAOneSidedConfidenceIntervalAsAString() throws Exception { + // Real ttest_onesample response, alt_hypothesis=greater, from the PR #656 deployment. + JsonNode result = roundTrip(""" + {"n_obs":217,"std":0.536580284502321,"t_stat":175.26155251671003,"df":216, + "p":3.89674018742522e-235,"mean_diff":4.6339797235023035,"se_diff":0.036425443183801726, + "ci_upper":"Infinity","ci_lower":4.57380712601384,"cohens_d":11.897529424554714} + """); + + assertThat(result.get("ci_upper").isTextual()).isTrue(); + assertThat(result.get("ci_upper").asText()).isEqualTo("Infinity"); + assertThat(result.get("ci_lower").asDouble()).isEqualTo(4.57380712601384); + // The survival-function p-value no longer underflows to 0 and must not be rounded to it here. + assertThat(result.get("p").asDouble()).isEqualTo(3.89674018742522e-235); + } + + @Test + void keepsNullStatisticsWhenExaflowCannotComputeThem() throws Exception { + // Before PR #656 an independent t-test with a one-row group returned nulls; a zero-variance + // sample still can. The fields stay present as null rather than disappearing. + JsonNode result = roundTrip(""" + {"t_stat":null,"df":14.0,"p":null,"mean_diff":0.0624,"se_diff":null, + "ci_upper":null,"ci_lower":null,"cohens_d":null} + """); + + assertThat(result.has("p")).isTrue(); + assertThat(result.get("p").isNull()).isTrue(); + assertThat(result.get("ci_lower").isNull()).isTrue(); + assertThat(result.get("mean_diff").asDouble()).isEqualTo(0.0624); + } + + @Test + void forwardsThePairedMeasurementLabelsAsExaflowDefinesThem() { + // PR #656 labels x "Measurement 1" and y "Measurement 2"; mean_diff is x - y. + String payload = """ + [{"name":"ttest_paired","label":"Paired t-test", + "documentation":"Compare the mean of paired differences (measurement 1 minus measurement 2)", + "y":{"label":"Measurement 2","desc":"Second numerical measurement in each pair.","max_count":1}, + "x":{"label":"Measurement 1","desc":"First numerical measurement in each pair.","max_count":1}, + "parameters":{"alt_hypothesis":{"enums":{"type":"list","source":["two-sided","less","greater"]}}}}] + """; + + List algorithms = JsonConverters.convertJsonStringToObject(payload, + new TypeToken>() { + }.getType()); + AlgorithmSpecificationDTO paired = algorithms.getFirst(); + + assertThat(paired.x().label()).isEqualTo("Measurement 1"); + assertThat(paired.y().label()).isEqualTo("Measurement 2"); + assertThat(paired.documentation()).contains("measurement 1 minus measurement 2"); + assertThat(paired.parameters().get("alt_hypothesis").enums().source()) + .containsExactly("two-sided", "less", "greater"); + } + + @Test + void sendsOneSidedRunParametersToExaflowUnchanged() throws Exception { + AnalysisRequestDTO request = new AnalysisRequestDTO( + "00000000-0000-0000-0000-000000000656", + new AnalysisRequestDTO.AnalysisInputDataDTO( + "dementia:0.1", List.of("edsd0"), null, null, List.of("x1", "y1")), + null, + new AnalysisRequestDTO.AnalysisAlgorithmDTO( + "ttest_paired", List.of("x1"), List.of("y1"), Map.of("alt_hypothesis", "less", "alpha", 0.05)), + null); + + JsonNode sent = objectMapper.readTree(JsonConverters.convertObjectToJsonString(request)); + + assertThat(sent.at("/algorithm/parameters/alt_hypothesis").asText()).isEqualTo("less"); + assertThat(sent.at("/algorithm/parameters/alpha").asDouble()).isEqualTo(0.05); + assertThat(sent.at("/algorithm/x/0").asText()).isEqualTo("x1"); + } +} From dcf1c9cc621b55675b9b66fbb3668e04f5293c41 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Wed, 28 Oct 2026 13:59:51 +0200 Subject: [PATCH 2/3] test(algorithm): pin the t-test contract through Spring's configured mapper The t-test contract test serialized with a plain ObjectMapper, so it missed application.yml's non_null inclusion. Through the real mapper, null statistics are dropped from the response, not sent as null; the UI reads a missing statistic the same as null. The test now asserts that. SpringJsonMapper builds the mapper Spring MVC uses from application.yml. The result, specification and request tests go through it. The one-sided request test now reads UI JSON instead of serializing a Java Map it built itself. --- src/test/java/hbp/mip/SpringJsonMapper.java | 30 +++++++++++ .../algorithm/ExaflowTTestContractTest.java | 53 ++++++++++--------- 2 files changed, 59 insertions(+), 24 deletions(-) create mode 100644 src/test/java/hbp/mip/SpringJsonMapper.java diff --git a/src/test/java/hbp/mip/SpringJsonMapper.java b/src/test/java/hbp/mip/SpringJsonMapper.java new file mode 100644 index 00000000..c01290b3 --- /dev/null +++ b/src/test/java/hbp/mip/SpringJsonMapper.java @@ -0,0 +1,30 @@ +package hbp.mip; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration; +import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import tools.jackson.databind.json.JsonMapper; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * The JSON mapper Spring MVC uses for request and response bodies, configured from application.yml + * (including {@code spring.jackson.default-property-inclusion}). Tests that pin what the UI sends or + * receives use it instead of a plain ObjectMapper, which does not see that configuration. + */ +public final class SpringJsonMapper { + public static final JsonMapper INSTANCE = create(); + + private SpringJsonMapper() { + } + + private static JsonMapper create() { + AtomicReference mapper = new AtomicReference<>(); + new ApplicationContextRunner() + .withInitializer(new ConfigDataApplicationContextInitializer()) + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .run(context -> mapper.set(context.getBean(JsonMapper.class))); + return mapper.get(); + } +} diff --git a/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java b/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java index 7e2028ad..642edf91 100644 --- a/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java +++ b/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java @@ -3,26 +3,26 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.gson.reflect.TypeToken; +import hbp.mip.SpringJsonMapper; import hbp.mip.utils.JsonConverters; import org.junit.jupiter.api.Test; import java.util.List; -import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; /** * Pins the t-test result and specification shapes of Exaflow PR #656 (algorithms/t-test-corrections) - * across the backend's Gson read -> Jackson write hop, which both the preview response and a saved - * run's stored result take. The backend passes t-test results through untouched, so none of the PR's - * changes needs backend code; these tests keep it that way. + * across the backend's Gson read -> Spring Jackson write hop, which both the preview response and a + * saved run's stored result take. The backend passes t-test results through untouched, so none of + * the PR's changes needs backend code; these tests keep it that way. */ class ExaflowTTestContractTest { private final ObjectMapper objectMapper = new ObjectMapper(); private JsonNode roundTrip(String exaflowResult) throws Exception { Object result = JsonConverters.convertJsonStringToObject(exaflowResult, Object.class); - return objectMapper.readTree(JsonConverters.convertObjectToJsonString(result)); + return objectMapper.readTree(SpringJsonMapper.INSTANCE.writeValueAsString(result)); } @Test @@ -42,22 +42,22 @@ void keepsTheOpenSideOfAOneSidedConfidenceIntervalAsAString() throws Exception { } @Test - void keepsNullStatisticsWhenExaflowCannotComputeThem() throws Exception { + void dropsNullStatisticsWhenExaflowCannotComputeThem() throws Exception { // Before PR #656 an independent t-test with a one-row group returned nulls; a zero-variance - // sample still can. The fields stay present as null rather than disappearing. + // sample still can. application.yml's non_null inclusion drops them from the response; the + // UI reads a missing statistic the same as null. JsonNode result = roundTrip(""" {"t_stat":null,"df":14.0,"p":null,"mean_diff":0.0624,"se_diff":null, "ci_upper":null,"ci_lower":null,"cohens_d":null} """); - assertThat(result.has("p")).isTrue(); - assertThat(result.get("p").isNull()).isTrue(); - assertThat(result.get("ci_lower").isNull()).isTrue(); + assertThat(result.has("p")).isFalse(); + assertThat(result.has("ci_lower")).isFalse(); assertThat(result.get("mean_diff").asDouble()).isEqualTo(0.0624); } @Test - void forwardsThePairedMeasurementLabelsAsExaflowDefinesThem() { + void forwardsThePairedMeasurementLabelsAsExaflowDefinesThem() throws Exception { // PR #656 labels x "Measurement 1" and y "Measurement 2"; mean_diff is x - y. String payload = """ [{"name":"ttest_paired","label":"Paired t-test", @@ -70,25 +70,30 @@ void forwardsThePairedMeasurementLabelsAsExaflowDefinesThem() { List algorithms = JsonConverters.convertJsonStringToObject(payload, new TypeToken>() { }.getType()); - AlgorithmSpecificationDTO paired = algorithms.getFirst(); + JsonNode paired = objectMapper.readTree(SpringJsonMapper.INSTANCE.writeValueAsString(algorithms)).get(0); - assertThat(paired.x().label()).isEqualTo("Measurement 1"); - assertThat(paired.y().label()).isEqualTo("Measurement 2"); - assertThat(paired.documentation()).contains("measurement 1 minus measurement 2"); - assertThat(paired.parameters().get("alt_hypothesis").enums().source()) + assertThat(paired.at("/x/label").asText()).isEqualTo("Measurement 1"); + assertThat(paired.at("/y/label").asText()).isEqualTo("Measurement 2"); + assertThat(paired.at("/documentation").asText()).contains("measurement 1 minus measurement 2"); + assertThat(paired.at("/parameters/alt_hypothesis/enums/source")).extracting(JsonNode::asText) .containsExactly("two-sided", "less", "greater"); } @Test void sendsOneSidedRunParametersToExaflowUnchanged() throws Exception { - AnalysisRequestDTO request = new AnalysisRequestDTO( - "00000000-0000-0000-0000-000000000656", - new AnalysisRequestDTO.AnalysisInputDataDTO( - "dementia:0.1", List.of("edsd0"), null, null, List.of("x1", "y1")), - null, - new AnalysisRequestDTO.AnalysisAlgorithmDTO( - "ttest_paired", List.of("x1"), List.of("y1"), Map.of("alt_hypothesis", "less", "alpha", 0.05)), - null); + // UI JSON in through Spring's mapper, out to Exaflow through JsonConverters. + AnalysisRequestDTO request = SpringJsonMapper.INSTANCE.readValue(""" + { + "request_id": "00000000-0000-0000-0000-000000000656", + "inputdata": {"data_model": "dementia:0.1", "datasets": ["edsd0"], "variables": ["x1", "y1"]}, + "algorithm": { + "name": "ttest_paired", + "x": ["x1"], + "y": ["y1"], + "parameters": {"alt_hypothesis": "less", "alpha": 0.05} + } + } + """, AnalysisRequestDTO.class); JsonNode sent = objectMapper.readTree(JsonConverters.convertObjectToJsonString(request)); From 40194d9aeb40734821b3b5eaa7598a645e260b44 Mon Sep 17 00:00:00 2001 From: KFilippopolitis Date: Wed, 28 Oct 2026 14:19:47 +0200 Subject: [PATCH 3/3] fix(analysis)!: pass Exaflow results through as the JSON Exaflow sent Results were parsed with Gson into Objects and written back with Spring's Jackson. Gson turned every number into a double (n_obs 217 became 217.0), and application.yml's non_null inclusion dropped every null inside the result, at any depth (a t-test p of null disappeared). AnalysisService now returns a 200 body as RawJson, and ExperimentDTO serves a stored result as RawJson. Both Jackson 2 (JsonConverters, storing a kept run) and Spring's Jackson 3 write RawJson unchanged. Exaflow writes results with pydantic model_dump_json, which emits no bare NaN or Infinity, so the passed-through text is always valid JSON. BREAKING CHANGE: result integers are no longer sent as doubles, and null fields in results are sent as null instead of being omitted. --- .../hbp/mip/algorithm/AnalysisService.java | 3 ++- .../hbp/mip/experiment/ExperimentDTO.java | 7 ++--- src/main/java/hbp/mip/utils/RawJson.java | 11 ++++++++ .../algorithm/ExaflowTTestContractTest.java | 25 ++++++++--------- .../experiment/ExperimentDTOResultTest.java | 27 +++++++++++++++++++ 5 files changed, 55 insertions(+), 18 deletions(-) create mode 100644 src/main/java/hbp/mip/utils/RawJson.java create mode 100644 src/test/java/hbp/mip/experiment/ExperimentDTOResultTest.java diff --git a/src/main/java/hbp/mip/algorithm/AnalysisService.java b/src/main/java/hbp/mip/algorithm/AnalysisService.java index 73353c48..4bbe20fc 100644 --- a/src/main/java/hbp/mip/algorithm/AnalysisService.java +++ b/src/main/java/hbp/mip/algorithm/AnalysisService.java @@ -5,6 +5,7 @@ import hbp.mip.utils.HTTPUtil; import hbp.mip.utils.JsonConverters; import hbp.mip.utils.Logger; +import hbp.mip.utils.RawJson; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -60,7 +61,7 @@ public AnalysisResultDTO runAnalysis(UUID requestId, AnalysisRequestDTO analysis private static Object convertResponseToAnalysisResult(Logger logger, int code, StringBuilder responseBody) { return switch (code) { - case 200 -> JsonConverters.convertJsonStringToObject(responseBody.toString(), Object.class); + case 200 -> new RawJson(responseBody.toString()); case 400, 460, 461, 462, 512, 513 -> convertExaflowResponseToAnalysisResult(responseBody.toString()); case 500 -> convertExaflowResponseToAnalysisResult(GENERIC_ERROR_MESSAGE); default -> handleUnexpectedResponseCode(logger, code); diff --git a/src/main/java/hbp/mip/experiment/ExperimentDTO.java b/src/main/java/hbp/mip/experiment/ExperimentDTO.java index 99a56796..d4eae6bf 100644 --- a/src/main/java/hbp/mip/experiment/ExperimentDTO.java +++ b/src/main/java/hbp/mip/experiment/ExperimentDTO.java @@ -3,6 +3,7 @@ import hbp.mip.algorithm.AnalysisRequestDTO; import hbp.mip.user.UserDTO; import hbp.mip.utils.JsonConverters; +import hbp.mip.utils.RawJson; import java.util.Date; import java.util.UUID; @@ -30,11 +31,7 @@ public ExperimentDTO(ExperimentDAO experimentDAO, boolean includeResult) { experimentDAO.getFinished(), experimentDAO.isShared(), experimentDAO.isViewed(), - includeResult - ? JsonConverters.convertJsonStringToObject( - String.valueOf(experimentDAO.getResult()), - Object.class) - : null, + includeResult && experimentDAO.getResult() != null ? new RawJson(experimentDAO.getResult()) : null, experimentDAO.getStatus(), JsonConverters.convertJsonStringToObject(experimentDAO.getAnalysis(), AnalysisRequestDTO.class), experimentDAO.getMipVersion()); diff --git a/src/main/java/hbp/mip/utils/RawJson.java b/src/main/java/hbp/mip/utils/RawJson.java new file mode 100644 index 00000000..962d4f4d --- /dev/null +++ b/src/main/java/hbp/mip/utils/RawJson.java @@ -0,0 +1,11 @@ +package hbp.mip.utils; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * JSON text that serializers write unchanged. Analysis results pass through as Exaflow sent them, so + * integers stay integers and explicit nulls survive the application-wide {@code non_null} inclusion. + */ +public record RawJson(@JsonValue @JsonRawValue String json) { +} diff --git a/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java b/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java index 642edf91..52282a19 100644 --- a/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java +++ b/src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java @@ -5,6 +5,7 @@ import com.google.gson.reflect.TypeToken; import hbp.mip.SpringJsonMapper; import hbp.mip.utils.JsonConverters; +import hbp.mip.utils.RawJson; import org.junit.jupiter.api.Test; import java.util.List; @@ -12,17 +13,17 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Pins the t-test result and specification shapes of Exaflow PR #656 (algorithms/t-test-corrections) - * across the backend's Gson read -> Spring Jackson write hop, which both the preview response and a - * saved run's stored result take. The backend passes t-test results through untouched, so none of - * the PR's changes needs backend code; these tests keep it that way. + * Pins the t-test result and specification shapes of Exaflow PR #656 (algorithms/t-test-corrections). + * Results pass through as {@link RawJson}: stored for a kept run by {@link JsonConverters}, then written + * to the UI by Spring's configured mapper. None of the PR's changes needs backend code; these tests + * keep it that way. */ class ExaflowTTestContractTest { private final ObjectMapper objectMapper = new ObjectMapper(); private JsonNode roundTrip(String exaflowResult) throws Exception { - Object result = JsonConverters.convertJsonStringToObject(exaflowResult, Object.class); - return objectMapper.readTree(SpringJsonMapper.INSTANCE.writeValueAsString(result)); + String stored = JsonConverters.convertObjectToJsonString(new RawJson(exaflowResult)); + return objectMapper.readTree(SpringJsonMapper.INSTANCE.writeValueAsString(new RawJson(stored))); } @Test @@ -42,18 +43,18 @@ void keepsTheOpenSideOfAOneSidedConfidenceIntervalAsAString() throws Exception { } @Test - void dropsNullStatisticsWhenExaflowCannotComputeThem() throws Exception { + void keepsNullStatisticsWhenExaflowCannotComputeThem() throws Exception { // Before PR #656 an independent t-test with a one-row group returned nulls; a zero-variance - // sample still can. application.yml's non_null inclusion drops them from the response; the - // UI reads a missing statistic the same as null. + // sample still can. They stay explicit nulls despite application.yml's non_null inclusion. JsonNode result = roundTrip(""" - {"t_stat":null,"df":14.0,"p":null,"mean_diff":0.0624,"se_diff":null, + {"n_obs":15,"t_stat":null,"df":14.0,"p":null,"mean_diff":0.0624,"se_diff":null, "ci_upper":null,"ci_lower":null,"cohens_d":null} """); - assertThat(result.has("p")).isFalse(); - assertThat(result.has("ci_lower")).isFalse(); + assertThat(result.get("p").isNull()).isTrue(); + assertThat(result.get("ci_lower").isNull()).isTrue(); assertThat(result.get("mean_diff").asDouble()).isEqualTo(0.0624); + assertThat(result.get("n_obs").isIntegralNumber()).isTrue(); } @Test diff --git a/src/test/java/hbp/mip/experiment/ExperimentDTOResultTest.java b/src/test/java/hbp/mip/experiment/ExperimentDTOResultTest.java new file mode 100644 index 00000000..b94f2876 --- /dev/null +++ b/src/test/java/hbp/mip/experiment/ExperimentDTOResultTest.java @@ -0,0 +1,27 @@ +package hbp.mip.experiment; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import hbp.mip.SpringJsonMapper; +import hbp.mip.user.UserDAO; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExperimentDTOResultTest { + + @Test + void servesTheStoredResultAsExaflowSentIt() throws Exception { + ExperimentDAO experiment = new ExperimentDAO(); + experiment.setCreatedBy(new UserDAO("user", "User", "user@example.org", "sub")); + experiment.setResult("{\"n_obs\":217,\"p\":null,\"table\":{\"se\":null}}"); + + JsonNode result = new ObjectMapper() + .readTree(SpringJsonMapper.INSTANCE.writeValueAsString(new ExperimentDTO(experiment, true))) + .get("result"); + + assertThat(result.get("n_obs").isIntegralNumber()).isTrue(); + assertThat(result.get("p").isNull()).isTrue(); + assertThat(result.at("/table/se").isNull()).isTrue(); + } +}