From 03fe83d38628a7fa678879d566be6372bba309d4 Mon Sep 17 00:00:00 2001 From: Ashish Babu Z Date: Sun, 26 Jul 2026 10:10:49 +0530 Subject: [PATCH 1/2] feat(AF-645): pure-Java logistic regression trainer and evaluator --- .../internal/LogisticRegressionTrainer.java | 118 ++++++++++++++++++ .../ai/internal/ModelEvaluator.java | 72 +++++++++++ .../ai/internal/TrainedApprovalModel.java | 54 ++++++++ .../LogisticRegressionTrainerTest.java | 110 ++++++++++++++++ .../ai/internal/ModelEvaluatorTest.java | 50 ++++++++ .../ai/internal/TrainedApprovalModelTest.java | 43 +++++++ 6 files changed, 447 insertions(+) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java new file mode 100644 index 00000000..093459d8 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java @@ -0,0 +1,118 @@ +package com.bablsoft.accessflow.ai.internal; + +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class LogisticRegressionTrainer { + + public TrainedApprovalModel train(double[][] features, boolean[] labels, List featureNames, double lambda, int maxIterations) { + int n = features.length; + if (n == 0) { + throw new IllegalArgumentException("No features"); + } + int m = featureNames.size(); + + double[] means = new double[m]; + double[] stddevs = new double[m]; + + // Calculate means + for (int j = 0; j < m; j++) { + double sum = 0; + for (int i = 0; i < n; i++) { + sum += features[i][j]; + } + means[j] = sum / n; + } + + // Calculate stddevs (sample stddev) + for (int j = 0; j < m; j++) { + double sumSq = 0; + for (int i = 0; i < n; i++) { + double diff = features[i][j] - means[j]; + sumSq += diff * diff; + } + if (n > 1) { + stddevs[j] = Math.sqrt(sumSq / (n - 1)); + } else { + stddevs[j] = 0.0; + } + if (stddevs[j] < 1e-9) { + stddevs[j] = 1.0; + } + } + + // Standardize features + double[][] scaled = new double[n][m]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < m; j++) { + scaled[i][j] = (features[i][j] - means[j]) / stddevs[j]; + } + } + + // Gradient descent + double[] w = new double[m]; + double b = 0; + double lr = 0.1; + double prevLoss = Double.MAX_VALUE; + + for (int iter = 0; iter < maxIterations; iter++) { + double[] dw = new double[m]; + double db = 0; + double loss = 0; + + for (int i = 0; i < n; i++) { + double logit = b; + for (int j = 0; j < m; j++) { + logit += w[j] * scaled[i][j]; + } + + double p = 1.0 / (1.0 + Math.exp(-logit)); + double y = labels[i] ? 1.0 : 0.0; + + // Avoid log(0) + double pSafe = Math.max(1e-15, Math.min(1.0 - 1e-15, p)); + loss -= (y * Math.log(pSafe) + (1.0 - y) * Math.log(1.0 - pSafe)); + + double dz = p - y; + db += dz; + for (int j = 0; j < m; j++) { + dw[j] += dz * scaled[i][j]; + } + } + + loss /= n; + for (int j = 0; j < m; j++) { + loss += (lambda / 2.0) * w[j] * w[j]; + dw[j] = (dw[j] / n) + (lambda * w[j]); + } + db /= n; + + if (prevLoss - loss < 1e-7 && prevLoss - loss >= 0) { + break; + } + prevLoss = loss; + + for (int j = 0; j < m; j++) { + w[j] -= lr * dw[j]; + } + b -= lr * db; + } + + Map weightsMap = new HashMap<>(); + Map meansMap = new HashMap<>(); + Map stddevsMap = new HashMap<>(); + + for (int j = 0; j < m; j++) { + String name = featureNames.get(j); + weightsMap.put(name, w[j]); + meansMap.put(name, means[j]); + stddevsMap.put(name, stddevs[j]); + } + + return new TrainedApprovalModel("v1", featureNames, b, weightsMap, meansMap, stddevsMap); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java new file mode 100644 index 00000000..3cfe6498 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java @@ -0,0 +1,72 @@ +package com.bablsoft.accessflow.ai.internal; + +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +@Component +public class ModelEvaluator { + + public double calculateAuc(double[] probabilities, boolean[] labels) { + int n = probabilities.length; + if (n == 0) { + return 0.5; + } + + List preds = new ArrayList<>(n); + long nPos = 0; + long nNeg = 0; + + for (int i = 0; i < n; i++) { + preds.add(new Prediction(probabilities[i], labels[i])); + if (labels[i]) { + nPos++; + } else { + nNeg++; + } + } + + if (nPos == 0 || nNeg == 0) { + return 0.5; + } + + preds.sort(Comparator.comparingDouble(Prediction::prob)); + + double sumPosRanks = 0; + int i = 0; + while (i < n) { + int j = i; + while (j < n && Math.abs(preds.get(j).prob() - preds.get(i).prob()) < 1e-9) { + j++; + } + double avgRank = (i + 1 + j) / 2.0; + for (int k = i; k < j; k++) { + if (preds.get(k).label()) { + sumPosRanks += avgRank; + } + } + i = j; + } + + return (sumPosRanks - (nPos * (nPos + 1.0)) / 2.0) / (double) (nPos * nNeg); + } + + public double calculateAccuracy(double[] probabilities, boolean[] labels, double threshold) { + if (probabilities.length == 0) { + return 1.0; + } + int correct = 0; + for (int i = 0; i < probabilities.length; i++) { + boolean pred = probabilities[i] >= threshold; + if (pred == labels[i]) { + correct++; + } + } + return (double) correct / probabilities.length; + } + + private record Prediction(double prob, boolean label) { + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java new file mode 100644 index 00000000..e1113103 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java @@ -0,0 +1,54 @@ +package com.bablsoft.accessflow.ai.internal; + +import tools.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; + +public record TrainedApprovalModel( + String featureSchemaVersion, + List featureNames, + double intercept, + Map weights, + Map means, + Map stddevs +) { + public TrainedApprovalModel { + featureNames = List.copyOf(featureNames); + weights = Map.copyOf(weights); + means = Map.copyOf(means); + stddevs = Map.copyOf(stddevs); + } + + public double predict(double[] rawFeatures) { + if (rawFeatures.length != featureNames.size()) { + throw new IllegalArgumentException("Feature length mismatch"); + } + double logit = intercept; + for (int i = 0; i < rawFeatures.length; i++) { + String fname = featureNames.get(i); + double val = rawFeatures[i]; + double mean = means.getOrDefault(fname, 0.0); + double std = stddevs.getOrDefault(fname, 1.0); + double scaled = (val - mean) / std; + double w = weights.getOrDefault(fname, 0.0); + logit += w * scaled; + } + return 1.0 / (1.0 + Math.exp(-logit)); + } + + public String toJson(ObjectMapper mapper) { + try { + return mapper.writeValueAsString(this); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static TrainedApprovalModel fromJson(String json, ObjectMapper mapper) { + try { + return mapper.readValue(json, TrainedApprovalModel.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java new file mode 100644 index 00000000..3a605826 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java @@ -0,0 +1,110 @@ +package com.bablsoft.accessflow.ai.internal; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LogisticRegressionTrainerTest { + + @Test + void testLinearlySeparableSyntheticDataset() { + var trainer = new LogisticRegressionTrainer(); + int n = 200; + double[][] X = new double[n][2]; + boolean[] y = new boolean[n]; + + int idx = 0; + for (int i = 0; i < 20; i++) { + for (int j = 0; j < 10; j++) { + double x1 = i * 0.5; + double x2 = j * 1.0; + X[idx][0] = x1; + X[idx][1] = x2; + // linearly separable + y[idx] = x1 > x2; + idx++; + } + } + + var model = trainer.train(X, y, List.of("x1", "x2"), 0.0, 1000); + + int correct = 0; + for (int i = 0; i < n; i++) { + double prob = model.predict(X[i]); + boolean pred = prob >= 0.5; + if (pred == y[i]) { + correct++; + } + } + + double accuracy = (double) correct / n; + assertTrue(accuracy > 0.95, "Accuracy should be > 95%, was: " + accuracy); + } + + @Test + void testDeterminism() { + var trainer = new LogisticRegressionTrainer(); + int n = 20; + double[][] X = new double[n][2]; + boolean[] y = new boolean[n]; + + for (int i = 0; i < n; i++) { + X[i][0] = i; + X[i][1] = n - i; + y[i] = i > 10; + } + + var model1 = trainer.train(X, y, List.of("x1", "x2"), 0.1, 100); + var model2 = trainer.train(X, y, List.of("x1", "x2"), 0.1, 100); + + double[] weights1 = {model1.weights().get("x1"), model1.weights().get("x2")}; + double[] weights2 = {model2.weights().get("x1"), model2.weights().get("x2")}; + + assertArrayEquals(weights1, weights2, 0.0, "Weights must be exact bit-identical"); + assertEquals(model1.intercept(), model2.intercept(), 0.0, "Intercept must be exact bit-identical"); + } + + @Test + void testRegularizationL2Shrinkage() { + var trainer = new LogisticRegressionTrainer(); + int n = 20; + double[][] X = new double[n][2]; + boolean[] y = new boolean[n]; + + for (int i = 0; i < n; i++) { + X[i][0] = i; + X[i][1] = i * 2; + y[i] = i > 10; + } + + var modelLow = trainer.train(X, y, List.of("x1", "x2"), 0.0, 500); + var modelHigh = trainer.train(X, y, List.of("x1", "x2"), 10.0, 500); + + double normLow = Math.pow(modelLow.weights().get("x1"), 2) + Math.pow(modelLow.weights().get("x2"), 2); + double normHigh = Math.pow(modelHigh.weights().get("x1"), 2) + Math.pow(modelHigh.weights().get("x2"), 2); + + assertTrue(normHigh < normLow, "Higher lambda should shrink weights"); + } + + @Test + void testStandardizationAndConstantColumn() { + var trainer = new LogisticRegressionTrainer(); + double[][] X = { + {2.0, 5.0}, + {4.0, 5.0}, + {6.0, 5.0} + }; + boolean[] y = {false, true, true}; + var model = trainer.train(X, y, List.of("x1", "const"), 0.0, 10); + + assertEquals(4.0, model.means().get("x1"), 1e-6); + assertEquals(2.0, model.stddevs().get("x1"), 1e-6); // sample stddev + + assertEquals(5.0, model.means().get("const"), 1e-6); + assertEquals(1.0, model.stddevs().get("const"), 1e-6); // exactly 1.0 because stddev ~ 0 + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java new file mode 100644 index 00000000..49e34454 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java @@ -0,0 +1,50 @@ +package com.bablsoft.accessflow.ai.internal; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ModelEvaluatorTest { + + @Test + void testAucPerfectRanking() { + var eval = new ModelEvaluator(); + double[] probs = {0.1, 0.4, 0.6, 0.9}; + boolean[] labels = {false, false, true, true}; + assertEquals(1.0, eval.calculateAuc(probs, labels), 1e-6); + } + + @Test + void testAucInvertedRanking() { + var eval = new ModelEvaluator(); + double[] probs = {0.9, 0.6, 0.4, 0.1}; + boolean[] labels = {false, false, true, true}; + assertEquals(0.0, eval.calculateAuc(probs, labels), 1e-6); + } + + @Test + void testAucRandomTie() { + var eval = new ModelEvaluator(); + double[] probs = {0.5, 0.5, 0.5, 0.5}; + boolean[] labels = {false, true, false, true}; + assertEquals(0.5, eval.calculateAuc(probs, labels), 1e-6); + } + + @Test + void testAucOneClassEmpty() { + var eval = new ModelEvaluator(); + assertEquals(0.5, eval.calculateAuc(new double[]{0.1, 0.9}, new boolean[]{true, true}), 1e-6); + assertEquals(0.5, eval.calculateAuc(new double[]{0.1, 0.9}, new boolean[]{false, false}), 1e-6); + } + + @Test + void testAccuracy() { + var eval = new ModelEvaluator(); + double[] probs = {0.1, 0.4, 0.6, 0.9}; + boolean[] labels = {false, true, false, true}; + // 0.1 -> false (Correct) + // 0.4 -> false (Incorrect) + // 0.6 -> true (Incorrect) + // 0.9 -> true (Correct) + assertEquals(0.5, eval.calculateAccuracy(probs, labels, 0.5), 1e-6); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java new file mode 100644 index 00000000..70d084dc --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java @@ -0,0 +1,43 @@ +package com.bablsoft.accessflow.ai.internal; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TrainedApprovalModelTest { + @Test + void testJsonRoundTripAndPrediction() { + var mapper = new ObjectMapper(); + var model = new TrainedApprovalModel( + "v1", + List.of("f1", "f2"), + 0.5, + Map.of("f1", 1.0, "f2", -2.0), + Map.of("f1", 10.0, "f2", 20.0), + Map.of("f1", 2.0, "f2", 4.0) + ); + + String json = model.toJson(mapper); + var loaded = TrainedApprovalModel.fromJson(json, mapper); + + assertEquals(model.featureSchemaVersion(), loaded.featureSchemaVersion()); + assertEquals(model.featureNames(), loaded.featureNames()); + assertEquals(model.intercept(), loaded.intercept()); + assertEquals(model.weights(), loaded.weights()); + assertEquals(model.means(), loaded.means()); + assertEquals(model.stddevs(), loaded.stddevs()); + + double prob = model.predict(new double[]{12.0, 16.0}); + double expected = 1.0 / (1.0 + Math.exp(-3.5)); + assertEquals(expected, prob, 1e-6); + + double probLoaded = loaded.predict(new double[]{12.0, 16.0}); + assertEquals(prob, probLoaded, 1e-6); + assertTrue(prob > 0 && prob < 1.0); + } +} From c9b5d2fbfbe11d77db9a5abef6de193bd83e1649 Mon Sep 17 00:00:00 2001 From: Ashish Babu Z Date: Mon, 27 Jul 2026 23:15:09 +0530 Subject: [PATCH 2/2] fix(AF-645): address review feedback on AI trainer and evaluator --- .../ApprovalModelSerializationException.java | 14 ++++ .../internal/LogisticRegressionTrainer.java | 13 +++- .../ai/internal/ModelEvaluator.java | 9 +++ .../ai/internal/TrainedApprovalModel.java | 50 ++++++++++--- .../LogisticRegressionTrainerTest.java | 34 +++++++++ .../ai/internal/ModelEvaluatorTest.java | 34 +++++++++ .../ai/internal/TrainedApprovalModelTest.java | 71 +++++++++++++++++-- 7 files changed, 211 insertions(+), 14 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/ai/internal/ApprovalModelSerializationException.java diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ApprovalModelSerializationException.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ApprovalModelSerializationException.java new file mode 100644 index 00000000..8f1dc155 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ApprovalModelSerializationException.java @@ -0,0 +1,14 @@ +package com.bablsoft.accessflow.ai.internal; + +/** + * Thrown when a {@link TrainedApprovalModel} cannot be serialized to or deserialized from JSON — + * for example when a stored model row is corrupt or the schema version is unrecognized. + * Callers (the retraining job, the serving path) should catch this and record a {@code failed} + * sentinel rather than propagating an anonymous {@link RuntimeException}. + */ +public class ApprovalModelSerializationException extends RuntimeException { + + public ApprovalModelSerializationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java index 093459d8..c2e470cb 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainer.java @@ -12,9 +12,20 @@ public class LogisticRegressionTrainer { public TrainedApprovalModel train(double[][] features, boolean[] labels, List featureNames, double lambda, int maxIterations) { int n = features.length; if (n == 0) { - throw new IllegalArgumentException("No features"); + throw new IllegalArgumentException("No training samples"); } int m = featureNames.size(); + if (labels.length != n) { + throw new IllegalArgumentException( + "labels.length (" + labels.length + ") must equal features.length (" + n + ")"); + } + for (int i = 0; i < n; i++) { + if (features[i].length != m) { + throw new IllegalArgumentException( + "features[" + i + "].length (" + features[i].length + + ") must equal featureNames.size() (" + m + ")"); + } + } double[] means = new double[m]; double[] stddevs = new double[m]; diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java index 3cfe6498..717af8c6 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/ModelEvaluator.java @@ -11,6 +11,10 @@ public class ModelEvaluator { public double calculateAuc(double[] probabilities, boolean[] labels) { int n = probabilities.length; + if (labels.length != n) { + throw new IllegalArgumentException( + "labels.length (" + labels.length + ") must equal probabilities.length (" + n + ")"); + } if (n == 0) { return 0.5; } @@ -54,6 +58,11 @@ public double calculateAuc(double[] probabilities, boolean[] labels) { } public double calculateAccuracy(double[] probabilities, boolean[] labels, double threshold) { + if (labels.length != probabilities.length) { + throw new IllegalArgumentException( + "labels.length (" + labels.length + ") must equal probabilities.length (" + + probabilities.length + ")"); + } if (probabilities.length == 0) { return 1.0; } diff --git a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java index e1113103..dc3df4fd 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java +++ b/backend/src/main/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModel.java @@ -1,6 +1,9 @@ package com.bablsoft.accessflow.ai.internal; +import tools.jackson.core.JacksonException; import tools.jackson.databind.ObjectMapper; + +import java.util.HashSet; import java.util.List; import java.util.Map; @@ -17,20 +20,47 @@ public record TrainedApprovalModel( weights = Map.copyOf(weights); means = Map.copyOf(means); stddevs = Map.copyOf(stddevs); + + // Reject duplicate feature names — a duplicate collapses in the maps and + // would cause predict() to silently double-count the surviving weight. + var seen = new HashSet(); + for (var name : featureNames) { + if (!seen.add(name)) { + throw new IllegalArgumentException( + "Duplicate feature name: '" + name + "'"); + } + } + + // Validate that every declared feature has a coefficient, mean, and stddev. + // A missing entry would cause predict() to silently substitute 0/1 defaults + // and return a plausible-looking but wrong probability. + for (var name : featureNames) { + if (!weights.containsKey(name)) { + throw new IllegalArgumentException("Missing weight for feature: '" + name + "'"); + } + if (!means.containsKey(name)) { + throw new IllegalArgumentException("Missing mean for feature: '" + name + "'"); + } + if (!stddevs.containsKey(name)) { + throw new IllegalArgumentException("Missing stddev for feature: '" + name + "'"); + } + } } public double predict(double[] rawFeatures) { if (rawFeatures.length != featureNames.size()) { - throw new IllegalArgumentException("Feature length mismatch"); + throw new IllegalArgumentException( + "Feature length mismatch: expected " + featureNames.size() + + ", got " + rawFeatures.length); } double logit = intercept; for (int i = 0; i < rawFeatures.length; i++) { - String fname = featureNames.get(i); + var fname = featureNames.get(i); double val = rawFeatures[i]; - double mean = means.getOrDefault(fname, 0.0); - double std = stddevs.getOrDefault(fname, 1.0); + double mean = means.get(fname); + double std = stddevs.get(fname); double scaled = (val - mean) / std; - double w = weights.getOrDefault(fname, 0.0); + double w = weights.get(fname); logit += w * scaled; } return 1.0 / (1.0 + Math.exp(-logit)); @@ -39,16 +69,18 @@ public double predict(double[] rawFeatures) { public String toJson(ObjectMapper mapper) { try { return mapper.writeValueAsString(this); - } catch (Exception e) { - throw new RuntimeException(e); + } catch (JacksonException e) { + throw new ApprovalModelSerializationException( + "Failed to serialize approval model", e); } } public static TrainedApprovalModel fromJson(String json, ObjectMapper mapper) { try { return mapper.readValue(json, TrainedApprovalModel.class); - } catch (Exception e) { - throw new RuntimeException(e); + } catch (JacksonException e) { + throw new ApprovalModelSerializationException( + "Failed to deserialize approval model", e); } } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java index 3a605826..a3449b30 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/LogisticRegressionTrainerTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class LogisticRegressionTrainerTest { @@ -107,4 +108,37 @@ void testStandardizationAndConstantColumn() { assertEquals(5.0, model.means().get("const"), 1e-6); assertEquals(1.0, model.stddevs().get("const"), 1e-6); // exactly 1.0 because stddev ~ 0 } + + @Test + void trainThrowsOnLabelsLengthShorterThanFeatures() { + var trainer = new LogisticRegressionTrainer(); + double[][] X = {{1.0, 2.0}, {3.0, 4.0}, {5.0, 6.0}}; + boolean[] y = {true, false}; // only 2 labels for 3 samples + assertThrows(IllegalArgumentException.class, + () -> trainer.train(X, y, List.of("x1", "x2"), 0.0, 10)); + } + + @Test + void trainThrowsOnLabelsLengthLongerThanFeatures() { + var trainer = new LogisticRegressionTrainer(); + double[][] X = {{1.0, 2.0}, {3.0, 4.0}}; + boolean[] y = {true, false, true}; // 3 labels for 2 samples + assertThrows(IllegalArgumentException.class, + () -> trainer.train(X, y, List.of("x1", "x2"), 0.0, 10)); + } + + @Test + void trainThrowsOnRaggedFeatureRow() { + var trainer = new LogisticRegressionTrainer(); + double[][] X = {{1.0, 2.0}, {3.0}}; // second row has only 1 feature + boolean[] y = {true, false}; + assertThrows(IllegalArgumentException.class, + () -> trainer.train(X, y, List.of("x1", "x2"), 0.0, 10)); + } + @Test + void trainThrowsOnEmptyFeatures() { + var trainer = new LogisticRegressionTrainer(); + assertThrows(IllegalArgumentException.class, + () -> trainer.train(new double[0][0], new boolean[0], List.of("x1"), 0.0, 10)); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java index 49e34454..aab9489c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/ModelEvaluatorTest.java @@ -2,6 +2,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; class ModelEvaluatorTest { @@ -47,4 +48,37 @@ void testAccuracy() { // 0.9 -> true (Correct) assertEquals(0.5, eval.calculateAccuracy(probs, labels, 0.5), 1e-6); } + + @Test + void calculateAucThrowsOnLengthMismatch() { + var eval = new ModelEvaluator(); + assertThrows(IllegalArgumentException.class, + () -> eval.calculateAuc(new double[]{0.1, 0.9}, new boolean[]{true})); + } + + @Test + void calculateAucThrowsWhenLabelsLonger() { + var eval = new ModelEvaluator(); + assertThrows(IllegalArgumentException.class, + () -> eval.calculateAuc(new double[]{0.5}, new boolean[]{true, false})); + } + + @Test + void calculateAccuracyThrowsOnLengthMismatch() { + var eval = new ModelEvaluator(); + assertThrows(IllegalArgumentException.class, + () -> eval.calculateAccuracy(new double[]{0.1, 0.9}, new boolean[]{true}, 0.5)); + } + + @Test + void calculateAucEmptyInput() { + var eval = new ModelEvaluator(); + assertEquals(0.5, eval.calculateAuc(new double[0], new boolean[0]), 1e-6); + } + + @Test + void calculateAccuracyEmptyInput() { + var eval = new ModelEvaluator(); + assertEquals(1.0, eval.calculateAccuracy(new double[0], new boolean[0], 0.5), 1e-6); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java index 70d084dc..be2e575d 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/ai/internal/TrainedApprovalModelTest.java @@ -7,9 +7,11 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; class TrainedApprovalModelTest { + @Test void testJsonRoundTripAndPrediction() { var mapper = new ObjectMapper(); @@ -21,23 +23,84 @@ void testJsonRoundTripAndPrediction() { Map.of("f1", 10.0, "f2", 20.0), Map.of("f1", 2.0, "f2", 4.0) ); - + String json = model.toJson(mapper); var loaded = TrainedApprovalModel.fromJson(json, mapper); - + assertEquals(model.featureSchemaVersion(), loaded.featureSchemaVersion()); assertEquals(model.featureNames(), loaded.featureNames()); assertEquals(model.intercept(), loaded.intercept()); assertEquals(model.weights(), loaded.weights()); assertEquals(model.means(), loaded.means()); assertEquals(model.stddevs(), loaded.stddevs()); - + double prob = model.predict(new double[]{12.0, 16.0}); double expected = 1.0 / (1.0 + Math.exp(-3.5)); assertEquals(expected, prob, 1e-6); - + double probLoaded = loaded.predict(new double[]{12.0, 16.0}); assertEquals(prob, probLoaded, 1e-6); assertTrue(prob > 0 && prob < 1.0); } + + @Test + void predictThrowsOnWrongFeatureLength() { + var model = new TrainedApprovalModel( + "v1", List.of("f1"), + 0.0, Map.of("f1", 1.0), Map.of("f1", 0.0), Map.of("f1", 1.0)); + assertThrows(IllegalArgumentException.class, + () -> model.predict(new double[]{1.0, 2.0})); + } + + @Test + void constructorRejectsDuplicateFeatureNames() { + assertThrows(IllegalArgumentException.class, () -> new TrainedApprovalModel( + "v1", + List.of("f1", "f1"), // duplicate + 0.0, + Map.of("f1", 1.0), + Map.of("f1", 0.0), + Map.of("f1", 1.0))); + } + + @Test + void constructorRejectsMissingWeight() { + assertThrows(IllegalArgumentException.class, () -> new TrainedApprovalModel( + "v1", + List.of("f1", "f2"), + 0.0, + Map.of("f1", 1.0), // f2 weight missing + Map.of("f1", 0.0, "f2", 0.0), + Map.of("f1", 1.0, "f2", 1.0))); + } + + @Test + void constructorRejectsMissingMean() { + assertThrows(IllegalArgumentException.class, () -> new TrainedApprovalModel( + "v1", + List.of("f1", "f2"), + 0.0, + Map.of("f1", 1.0, "f2", 2.0), + Map.of("f1", 0.0), // f2 mean missing + Map.of("f1", 1.0, "f2", 1.0))); + } + + @Test + void constructorRejectsMissingStddev() { + assertThrows(IllegalArgumentException.class, () -> new TrainedApprovalModel( + "v1", + List.of("f1", "f2"), + 0.0, + Map.of("f1", 1.0, "f2", 2.0), + Map.of("f1", 0.0, "f2", 0.0), + Map.of("f1", 1.0))); // f2 stddev missing + } + + @Test + void fromJsonThrowsApprovalModelSerializationExceptionOnCorruptJson() { + var mapper = new ObjectMapper(); + assertThrows(ApprovalModelSerializationException.class, + () -> TrainedApprovalModel.fromJson("{not valid json{{", mapper)); + } + }