diff --git a/src/main/java/network/crypta/node/Location.java b/src/main/java/network/crypta/node/Location.java index 5ac1156937..3bda833a29 100644 --- a/src/main/java/network/crypta/node/Location.java +++ b/src/main/java/network/crypta/node/Location.java @@ -1,5 +1,6 @@ package network.crypta.node; +import network.crypta.support.math.KeyspaceMath; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -107,7 +108,7 @@ public static double distanceAllowInvalid(double a, double b) { * @return the minimal arc length in {@code [0.0, 0.5]} */ private static double simpleDistance(double a, double b) { - return Math.abs(change(a, b)); + return KeyspaceMath.distance(a, b); } /** @@ -122,14 +123,7 @@ private static double simpleDistance(double a, double b) { * @return the signed delta on the unit circle */ public static double change(double from, double to) { - double change = to - from; - if (change > 0.5) { - return change - 1.0; - } - if (change <= -0.5) { - return change + 1.0; - } - return change; + return KeyspaceMath.change(from, to); } /** @@ -145,11 +139,7 @@ public static double change(double from, double to) { * @return the normalized location in {@code [0.0, 1.0)} */ public static double normalize(double rough) { - double normal = rough % 1.0; - if (normal < 0) { - return 1.0 + normal; - } - return normal; + return KeyspaceMath.normalize(rough); } /** diff --git a/src/main/java/network/crypta/node/TimeSkewDetectorCallback.java b/src/main/java/network/crypta/node/TimeSkewDetectorCallback.java index 2f7f458ca5..616a1e0408 100644 --- a/src/main/java/network/crypta/node/TimeSkewDetectorCallback.java +++ b/src/main/java/network/crypta/node/TimeSkewDetectorCallback.java @@ -1,19 +1,21 @@ package network.crypta.node; +import network.crypta.support.math.TimeSkewAlertCallback; + /** * Callback interface for reporting detected local clock skew. * *
Implementations surface a user-facing alert when the system clock appears out of sync. The * exact presentation is application-specific (for example, UI banner, status panel entry, or a * prominent log message). Callers may invoke this callback from non-UI threads; implementations - * that interact with UI toolkits must marshal to the appropriate thread. + * that interact with UI toolkits must be marshaled to the appropriate thread. * *
This interface does not prescribe how often the alert is shown. Implementations should avoid * repeatedly notifying the user if the condition persists. * * @author Florent Daignière <nextgens@freenetproject.org> */ -public interface TimeSkewDetectorCallback { +public interface TimeSkewDetectorCallback extends TimeSkewAlertCallback { /** * Requests that the implementation present a user-visible alert indicating that time skew has @@ -22,5 +24,6 @@ public interface TimeSkewDetectorCallback { *
Side effects depend on the concrete implementation. The call should return promptly; any * long-running work or UI updates should be scheduled asynchronously. */ + @Override void setTimeSkewDetectedUserAlert(); } diff --git a/src/main/java/network/crypta/support/math/DecayingKeyspaceAverage.java b/src/main/java/network/crypta/support/math/DecayingKeyspaceAverage.java index 4279a5d600..3d938558ec 100644 --- a/src/main/java/network/crypta/support/math/DecayingKeyspaceAverage.java +++ b/src/main/java/network/crypta/support/math/DecayingKeyspaceAverage.java @@ -1,7 +1,6 @@ package network.crypta.support.math; import java.io.Serial; -import network.crypta.node.Location; import network.crypta.support.SimpleFieldSet; /** @@ -38,7 +37,7 @@ public final class DecayingKeyspaceAverage implements RunningAverage { /** * Creates a new keyspace-aware decaying running average. * - * @param defaultValue initial normalized value returned by {@link #currentValue()} before any + * @param defaultValue initially normalized value returned by {@link #currentValue()} before any * valid report. Expected in {@code [0.0, 1.0]}. * @param maxReports number of valid reports before decay stabilizes at {@code 1/maxReports}. * Larger values decay more slowly. @@ -92,7 +91,7 @@ public synchronized double currentValue() { * Reports a value in the normalized keyspace range {@code [0.0, 1.0]}. * *
To handle the wrap at {@code 1.0/0.0}, this method computes an unwrapped delta from the - * current normalized average to {@code d} using {@link Location#change(double, double)} and + * current normalized average to {@code d} using {@link KeyspaceMath#change(double, double)} and * updates the underlying average in that unwrapped space. After the update it normalizes the * stored value back into {@code [0.0, 1.0)}. * @@ -107,12 +106,12 @@ public synchronized void report(double d) { // Using an unwrapped representation does not relax the input contract here. throw new IllegalArgumentException("Not a valid normalized key: " + d); double superValue = avg.currentValue(); - double thisValue = Location.normalize(superValue); - double diff = Location.change(thisValue, d); + double thisValue = KeyspaceMath.normalize(superValue); + double diff = KeyspaceMath.change(thisValue, d); double toAverage = (superValue + diff); avg.report(toAverage); // Normalize the stored value back into [0.0, 1.0), so exactly 1.0 becomes 0.0. - avg.setCurrentValue(Location.normalize(avg.currentValue())); + avg.setCurrentValue(KeyspaceMath.normalize(avg.currentValue())); } /** @@ -129,9 +128,9 @@ public synchronized double valueIfReported(double d) { if ((d < 0.0) || (d > 1.0) || Double.isNaN(d) || Double.isInfinite(d)) throw new IllegalArgumentException("Not a valid normalized key: " + d); double superValue = avg.currentValue(); - double thisValue = Location.normalize(superValue); - double diff = Location.change(thisValue, d); - return Location.normalize(avg.valueIfReported(superValue + diff)); + double thisValue = KeyspaceMath.normalize(superValue); + double diff = KeyspaceMath.change(thisValue, d); + return KeyspaceMath.normalize(avg.valueIfReported(superValue + diff)); } /** diff --git a/src/main/java/network/crypta/support/math/KeyspaceMath.java b/src/main/java/network/crypta/support/math/KeyspaceMath.java new file mode 100644 index 0000000000..92b8263c10 --- /dev/null +++ b/src/main/java/network/crypta/support/math/KeyspaceMath.java @@ -0,0 +1,90 @@ +package network.crypta.support.math; + +/** + * Performs wrap-aware arithmetic on Cryptad's normalized unit keyspace. + * + *
This helper centralizes the circular math shared by support-side averages and node-side + * location utilities such as {@code network.crypta.node.Location}. Callers use it when they already + * enforce their own validity checks and need the historical wrap behavior at the {@code 0.0}/{@code + * 1.0} boundary. The methods are pure, allocate no state, and keep the long-standing convention + * that diametrically opposite points produce a positive half-turn. + * + *
The class is intentionally narrow: + * + *
By keeping these semantics in one place, support-layer code such as {@code + * DecayingKeyspaceAverage} can use the same behavior as node-layer location code without depending + * on node-specific APIs. + */ +public final class KeyspaceMath { + + /** Prevents instantiation of this stateless helper class. */ + private KeyspaceMath() {} + + /** + * Returns the shortest signed delta from one normalized position to another. + * + *
Use this when a caller needs to move from a current keyspace position toward a target, and + * the shortest route may cross the wrap boundary. The result matches Cryptad's historical + * location semantics: values greater than {@code 0.5} wrap backward by {@code 1.0}, values at or + * below {@code -0.5} wrap forward by {@code 1.0}, and exactly opposite points produce {@code + * +0.5}. The method does not validate inputs, so callers should normalize or validate first when + * range guarantees matter. + * + * @param from normalized starting position supplied by the caller. + * @param to normalized destination position supplied by the caller. + * @return a signed shortest-path delta on the unit keyspace ring. + */ + public static double change(double from, double to) { + double change = to - from; + if (change > 0.5) { + return change - 1.0; + } + if (change <= -0.5) { + return change + 1.0; + } + return change; + } + + /** + * Returns the shortest absolute arc length between two normalized positions. + * + *
This is a convenience wrapper around {@link #change(double, double)} for callers that need + * only the scale of the shortest route. The computation remains symmetric for any ordered pair + * because it derives from the same signed wrap rules. Like the other helpers in this class, it + * assumes the caller has already decided whether the inputs are valid keyspace coordinates. + * + * @param a first normalized keyspace position to compare. + * @param b second normalized keyspace position to compare. + * @return shortest absolute distance measured on the unit ring. + */ + public static double distance(double a, double b) { + return Math.abs(change(a, b)); + } + + /** + * Maps an arbitrary double into Cryptad's canonical keyspace range. + * + *
The returned value is always in {@code [0.0, 1.0)}, which makes it suitable for storage, + * comparison, and further calls to {@link #change(double, double)} or {@link #distance(double, + * double)}. Multiples of {@code 1.0} collapse onto the same position, so {@code 1.0} becomes + * {@code 0.0}. The implementation intentionally follows Java remainder rules and then adjusts + * negative results, preserving the behavior historically exposed through the node-side {@code + * Location.normalize(double)} helper. + * + * @param rough arbitrary keyspace value that may need wrapping. + * @return equivalent normalized position in the canonical unit range. + */ + public static double normalize(double rough) { + double normal = rough % 1.0; + if (normal < 0) { + return 1.0 + normal; + } + return normal; + } +} diff --git a/src/main/java/network/crypta/support/math/TimeDecayingRunningAverage.java b/src/main/java/network/crypta/support/math/TimeDecayingRunningAverage.java index 58303d6ddb..b3f0c03d55 100644 --- a/src/main/java/network/crypta/support/math/TimeDecayingRunningAverage.java +++ b/src/main/java/network/crypta/support/math/TimeDecayingRunningAverage.java @@ -6,7 +6,6 @@ import java.io.ObjectInputStream; import java.io.Serial; import java.util.function.LongSupplier; -import network.crypta.node.TimeSkewDetectorCallback; import network.crypta.support.SimpleFieldSet; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -128,7 +127,7 @@ public final class TimeDecayingRunningAverage implements RunningAverage { */ double maxReport; - private final transient TimeSkewDetectorCallback timeSkewCallback; + private final transient TimeSkewAlertCallback timeSkewCallback; private transient LongSupplier wallClockTimeSourceMillis; private transient LongSupplier monotonicTimeSourceNanos; @@ -195,7 +194,7 @@ public String toString() { * @param callback optional callback notified when wall‑clock time regresses; may be {@code null} */ public TimeDecayingRunningAverage( - RunningAverageBounds bounds, long halfLife, TimeSkewDetectorCallback callback) { + RunningAverageBounds bounds, long halfLife, TimeSkewAlertCallback callback) { curValue = bounds.defaultValue(); this.defaultValue = bounds.defaultValue(); @@ -232,7 +231,7 @@ public TimeDecayingRunningAverage( RunningAverageBounds bounds, long halfLife, SimpleFieldSet fs, - TimeSkewDetectorCallback callback) { + TimeSkewAlertCallback callback) { curValue = bounds.defaultValue(); this.defaultValue = bounds.defaultValue(); @@ -286,7 +285,7 @@ public TimeDecayingRunningAverage( RunningAverageBounds bounds, long halfLife, SimpleFieldSet fs, - TimeSkewDetectorCallback callback, + TimeSkewAlertCallback callback, LongSupplier wallClockTimeSourceMillis, LongSupplier monotonicTimeSourceNanos) { @@ -352,7 +351,7 @@ public TimeDecayingRunningAverage( RunningAverageBounds bounds, double halfLife, DataInputStream dis, - TimeSkewDetectorCallback callback) + TimeSkewAlertCallback callback) throws IOException { int m = dis.readInt(); if (m != MAGIC) throw new IOException("Invalid magic " + m); @@ -419,7 +418,7 @@ private static final class Snapshot { boolean started; long totalReports; double curValue; - TimeSkewDetectorCallback timeSkewCallback; + TimeSkewAlertCallback timeSkewCallback; LongSupplier wallClockTimeSourceMillis; LongSupplier monotonicTimeSourceNanos; } diff --git a/src/main/java/network/crypta/support/math/TimeSkewAlertCallback.java b/src/main/java/network/crypta/support/math/TimeSkewAlertCallback.java new file mode 100644 index 0000000000..85d951421e --- /dev/null +++ b/src/main/java/network/crypta/support/math/TimeSkewAlertCallback.java @@ -0,0 +1,31 @@ +package network.crypta.support.math; + +/** + * Receives notifications when support-side time checks detect a local clock skew. + * + *
This interface defines the narrow contract that support-layer components use when they need to + * surface a user-visible time-skew condition without depending on node-specific alerting code. In + * practice, callers such as {@code TimeDecayingRunningAverage} invoke it after detecting wall-clock + * or uptime regressions while continuing their monotonic-time calculations. That keeps timing math + * isolated from presentation concerns while still giving higher layers a clear place to register + * operator-facing alerts. + * + *
Implementations may map the notification to a UI banner, a persistent user alert, or another + * operator-facing mechanism. Callers may invoke the callback from non-UI threads and may notify + * more than once if skew is detected repeatedly, so implementations should return quickly and + * perform any deduplication or thread marshaling they require. + */ +public interface TimeSkewAlertCallback { + + /** + * Requests an operator-visible alert for a detected local time-skew condition. + * + *
Callers use this after they have already determined that the local wall clock moved backward + * or otherwise became inconsistent with elapsed monotonic time. The callback is + * notification-only: it does not influence the caller's timing calculations, and it does not + * imply that the condition has cleared. Implementations may schedule UI work or alert + * registration, but they should keep the call itself lightweight so reporting paths remain + * responsive. + */ + void setTimeSkewDetectedUserAlert(); +} diff --git a/src/test/java/network/crypta/node/LocationTest.java b/src/test/java/network/crypta/node/LocationTest.java index 303fe940a2..8aa1c09834 100644 --- a/src/test/java/network/crypta/node/LocationTest.java +++ b/src/test/java/network/crypta/node/LocationTest.java @@ -1,5 +1,6 @@ package network.crypta.node; +import network.crypta.support.math.KeyspaceMath; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; @@ -19,7 +20,7 @@ class LocationTest { // Maximal acceptable difference to consider two doubles equal. private static final double EPSILON = 1e-12; - // Just some valid non corner case locations. + // Just some valid non-corner case locations. private static final double VALID_1 = 0.2; private static final double VALID_2 = 0.75; @@ -118,6 +119,24 @@ void normalize_whenOffsetsApplied_expectValueWrappedIntoRange() { assertEquals(0.0, Location.normalize(1.0), EPSILON); } + @ParameterizedTest + @CsvSource({"-1.75", "-0.25", "0.0", "0.2", "1.0", "1.75"}) + void normalize_whenDelegated_expectSameAsKeyspaceMath(double input) { + assertEquals(KeyspaceMath.normalize(input), Location.normalize(input), EPSILON); + } + + @ParameterizedTest + @CsvSource({"0.2, 0.75", "0.75, 0.2", "0.9, 0.1", "0.1, 0.9", "0.0, 0.5", "0.5, 0.0"}) + void change_whenDelegated_expectSameAsKeyspaceMath(double from, double to) { + assertEquals(KeyspaceMath.change(from, to), Location.change(from, to), EPSILON); + } + + @ParameterizedTest + @CsvSource({"0.2, 0.75", "0.75, 0.2", "0.9, 0.1", "0.1, 0.9", "0.0, 0.5", "0.25, 0.25"}) + void distance_whenDelegated_expectSameAsKeyspaceMath(double from, double to) { + assertEquals(KeyspaceMath.distance(from, to), Location.distance(from, to), EPSILON); + } + @Test void distanceAllowInvalid_whenAnyInvalid_expectSpecifiedSemantics() { // Simple cases. diff --git a/src/test/java/network/crypta/support/math/DecayingKeyspaceAverageTest.java b/src/test/java/network/crypta/support/math/DecayingKeyspaceAverageTest.java index cf56005cd9..0d8dd24173 100644 --- a/src/test/java/network/crypta/support/math/DecayingKeyspaceAverageTest.java +++ b/src/test/java/network/crypta/support/math/DecayingKeyspaceAverageTest.java @@ -1,7 +1,7 @@ package network.crypta.support.math; +import java.util.Objects; import java.util.stream.Stream; -import network.crypta.node.Location; import network.crypta.support.SimpleFieldSet; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -102,7 +102,8 @@ void valueIfReported_whenCalled_doesNotMutateState() { // Assert // Expect normalize(s + 0.5 * change(s, d)) with s=0.25, d=0.75, change=0.5 - double expected = Location.normalize(beforeValue + 0.5 * Location.change(beforeValue, 0.75)); + double expected = + KeyspaceMath.normalize(beforeValue + 0.5 * KeyspaceMath.change(beforeValue, 0.75)); assertThat(predicted, closeTo(expected, EPS)); assertThat(avg.currentValue(), closeTo(beforeValue, EPS)); assertEquals(beforeCount, avg.countReports()); @@ -149,17 +150,18 @@ void changeMaxReports_whenSetToOne_nextReportReplacesCurrent() { DecayingKeyspaceAverage avg = new DecayingKeyspaceAverage(0.0, 2, null); avg.report(0.9); // anchor: s=0.9 - // Act: with maxReports=2, reporting 0.1 moves halfway across shortest path + // Act: with maxReports=2, reporting 0.1 moves halfway across the shortest path avg.report(0.1); - double expectedHalf = Location.normalize(0.9 + 0.5 * Location.change(0.9, 0.1)); + double expectedHalf = KeyspaceMath.normalize(0.9 + 0.5 * KeyspaceMath.change(0.9, 0.1)); assertThat(avg.currentValue(), closeTo(expectedHalf, EPS)); - // Act: increase decay by setting maxReports=1 so next report fully replaces current + // Act: increase decay by setting maxReports=1 so the next report fully replaces the current avg.changeMaxReports(1); avg.report(0.2); - // Assert: new value equals the unwrapped target (normalized) - double expectedFull = Location.normalize(expectedHalf + Location.change(expectedHalf, 0.2)); + // Assert: the new value equals the unwrapped target (normalized) + double expectedFull = + KeyspaceMath.normalize(expectedHalf + KeyspaceMath.change(expectedHalf, 0.2)); assertThat(avg.currentValue(), closeTo(expectedFull, EPS)); } @@ -190,7 +192,7 @@ void copyOf_whenCalled_returnsDeepCopy() { double snapValue = original.currentValue(); long snapReports = original.countReports(); - // Act: take a snapshot using the interface helper and then mutate original + // Act: take a snapshot using the interface helper and then mutate the original RunningAverage snapshot = RunningAverage.copyOf(original); original.report(0.25); @@ -222,7 +224,7 @@ void exportFieldSet_whenCalled_containsTypeAndCurrentValueAndReports() { // Arrange DecayingKeyspaceAverage avg = new DecayingKeyspaceAverage(0.5, 2, null); avg.report(0.5); - avg.report(1.0); // normalizes to 0.75 internally then to [0,1) + avg.report(1.0); // normalizes to 0.75 internally, then to [0,1) double current = avg.currentValue(); long reports = avg.countReports(); @@ -231,8 +233,10 @@ void exportFieldSet_whenCalled_containsTypeAndCurrentValueAndReports() { // Assert assertThat(sfs.get("Type"), equalTo("BootstrappingDecayingRunningAverage")); - assertThat(Double.parseDouble(sfs.get("CurrentValue")), closeTo(current, EPS)); - assertEquals(reports, Long.parseLong(sfs.get("Reports"))); + String currentValue = Objects.requireNonNull(sfs.get("CurrentValue")); + String reportCount = Objects.requireNonNull(sfs.get("Reports")); + assertThat(Double.parseDouble(currentValue), closeTo(current, EPS)); + assertEquals(reports, Long.parseLong(reportCount)); } @Test diff --git a/src/test/java/network/crypta/support/math/KeyspaceMathTest.java b/src/test/java/network/crypta/support/math/KeyspaceMathTest.java new file mode 100644 index 0000000000..eef67e7c45 --- /dev/null +++ b/src/test/java/network/crypta/support/math/KeyspaceMathTest.java @@ -0,0 +1,67 @@ +package network.crypta.support.math; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SuppressWarnings("java:S100") +class KeyspaceMathTest { + + private static final double EPSILON = 1e-12; + + @ParameterizedTest + @CsvSource({ + "0.0, 0.0", + "0.2, 0.2", + "0.75, 0.75", + "1.0, 0.0", + "1.75, 0.75", + "-0.25, 0.75", + "-1.75, 0.25" + }) + void normalize_whenValueProvided_expectCanonicalPosition(double input, double expected) { + // Act + double normalized = KeyspaceMath.normalize(input); + + // Assert + assertEquals(expected, normalized, EPSILON); + } + + @ParameterizedTest + @CsvSource({ + "0.2, 0.75, -0.45", + "0.75, 0.2, 0.45", + "0.9, 0.1, 0.2", + "0.1, 0.9, -0.2", + "0.0, 0.5, 0.5", + "0.5, 0.0, 0.5", + "0.25, 0.25, 0.0" + }) + void change_whenValuesProvided_expectShortestSignedDelta( + double from, double to, double expected) { + // Act + double change = KeyspaceMath.change(from, to); + + // Assert + assertEquals(expected, change, EPSILON); + } + + @ParameterizedTest + @CsvSource({ + "0.2, 0.75, 0.45", + "0.75, 0.2, 0.45", + "0.9, 0.1, 0.2", + "0.1, 0.9, 0.2", + "0.0, 0.5, 0.5", + "0.25, 0.25, 0.0" + }) + void distance_whenValuesProvided_expectShortestAbsoluteArc( + double from, double to, double expected) { + // Act + double distance = KeyspaceMath.distance(from, to); + + // Assert + assertEquals(expected, distance, EPSILON); + } +} diff --git a/src/test/java/network/crypta/support/math/TimeDecayingRunningAverageTest.java b/src/test/java/network/crypta/support/math/TimeDecayingRunningAverageTest.java index 7793cc6fba..9dbc34c034 100644 --- a/src/test/java/network/crypta/support/math/TimeDecayingRunningAverageTest.java +++ b/src/test/java/network/crypta/support/math/TimeDecayingRunningAverageTest.java @@ -6,7 +6,6 @@ import java.io.DataOutputStream; import java.util.Arrays; import java.util.function.LongSupplier; -import network.crypta.node.TimeSkewDetectorCallback; import network.crypta.support.SimpleFieldSet; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,7 +13,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class TimeDecayingRunningAverageTest { @@ -57,10 +58,10 @@ void currentValue_whenNoReports_returnsDefaultValue() { } @Test - @DisplayName("report_whenUptimeNegative_appliesDecay") - void report_whenUptimeNegative_appliesDecay() { + @DisplayName("report_whenWallClockRegressesAndUptimeNegative_appliesDecayAndAlerts") + void report_whenWallClockRegressesAndUptimeNegative_appliesDecayAndAlerts() { // Arrange - TimeSkewDetectorCallback cb = mock(TimeSkewDetectorCallback.class); + TimeSkewAlertCallback cb = mock(TimeSkewAlertCallback.class); LongSupplier wall = wallTimes(10_000, 11_000, 9_000); LongSupplier mono = monoTimesFromMillis(0, 1_000, 2_000); TimeDecayingRunningAverage avg = @@ -73,6 +74,7 @@ void report_whenUptimeNegative_appliesDecay() { // Assert: still decays using monotonic delta despite negative uptime assertTrue(avg.currentValue() >= 0.5 - 1e-9); + verify(cb, atLeastOnce()).setTimeSkewDetectedUserAlert(); } @Test