Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/java/hbp/mip/algorithm/AnalysisService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -62,7 +63,7 @@ public AnalysisResultDTO runAnalysis(UUID requestId, AnalysisRequestDTO analysis

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, 413, 460, 461, 462, 512, 513 -> convertExaflowResponseToAnalysisResult(responseBody.toString());
case 500 -> convertExaflowResponseToAnalysisResult(GENERIC_ERROR_MESSAGE);
// Exaflow is at its concurrent-analysis limit; its own message names internal components,
Expand Down
7 changes: 2 additions & 5 deletions src/main/java/hbp/mip/experiment/ExperimentDTO.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/hbp/mip/utils/RawJson.java
Original file line number Diff line number Diff line change
@@ -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) {
}
105 changes: 105 additions & 0 deletions src/test/java/hbp/mip/algorithm/ExaflowTTestContractTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
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.SpringJsonMapper;
import hbp.mip.utils.JsonConverters;
import hbp.mip.utils.RawJson;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

/**
* 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 {
String stored = JsonConverters.convertObjectToJsonString(new RawJson(exaflowResult));
return objectMapper.readTree(SpringJsonMapper.INSTANCE.writeValueAsString(new RawJson(stored)));
}

@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. They stay explicit nulls despite application.yml's non_null inclusion.
JsonNode result = roundTrip("""
{"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.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
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",
"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<AlgorithmSpecificationDTO> algorithms = JsonConverters.convertJsonStringToObject(payload,
new TypeToken<List<AlgorithmSpecificationDTO>>() {
}.getType());
JsonNode paired = objectMapper.readTree(SpringJsonMapper.INSTANCE.writeValueAsString(algorithms)).get(0);

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 {
// 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));

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");
}
}
27 changes: 27 additions & 0 deletions src/test/java/hbp/mip/experiment/ExperimentDTOResultTest.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading