Merge "Add TrackTracer to trace to a single perfetto track" into main
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
index 480061a..c7875f6 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
@@ -15,6 +15,8 @@
  */
 package com.android.launcher3.icons;
 
+import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG;
+
 import android.content.Context;
 import android.graphics.Bitmap;
 import android.graphics.Bitmap.Config;
@@ -25,6 +27,7 @@
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 
+import com.android.launcher3.icons.cache.CacheLookupFlag;
 import com.android.launcher3.util.FlagOp;
 
 public class BitmapInfo {
@@ -121,6 +124,13 @@
     }
 
     /**
+     * Returns the lookup flag to match this current state of this info
+     */
+    public CacheLookupFlag getMatchingLookupFlag() {
+        return DEFAULT_LOOKUP_FLAG.withUseLowRes(isLowRes());
+    }
+
+    /**
      * BitmapInfo can be stored on disk or other persistent storage
      */
     public boolean canPersist() {
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
index 959f14d..c08efd9 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
@@ -49,7 +49,6 @@
 import android.util.Log;
 import android.util.SparseArray;
 
-import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.VisibleForTesting;
@@ -65,8 +64,6 @@
 import com.android.launcher3.util.FlagOp;
 import com.android.launcher3.util.SQLiteCacheHelper;
 
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
 import java.util.AbstractMap;
 import java.util.Arrays;
 import java.util.Collections;
@@ -88,37 +85,6 @@
     // Empty class name is used for storing package default entry.
     public static final String EMPTY_CLASS_NAME = ".";
 
-    @Retention(RetentionPolicy.SOURCE)
-    @IntDef(value = {
-            LookupFlag.DEFAULT,
-            LookupFlag.USE_LOW_RES,
-            LookupFlag.USE_PACKAGE_ICON,
-            LookupFlag.SKIP_ADD_TO_MEM_CACHE
-    }, flag = true)
-    /** Various options to control cache lookup */
-    public @interface LookupFlag {
-        /**
-         * Default behavior of cache lookup is to load high-res icon with no fallback
-         */
-        int DEFAULT = 0;
-
-        /**
-         * When specified, the cache tries to load the low res version of the entry unless a
-         * high-res is already in memory
-         */
-        int USE_LOW_RES = 1 << 0;
-        /**
-         * When specified, the cache tries to lookup the package entry for the item, if the object
-         * entry fails
-         */
-        int USE_PACKAGE_ICON = 1 << 1;
-        /**
-         * When specified, the entry will not be added to the memory cache if it was not already
-         * added by a previous lookup
-         */
-        int SKIP_ADD_TO_MEM_CACHE = 1 << 2;
-    }
-
     public static class CacheEntry {
 
         @NonNull
@@ -404,7 +370,7 @@
     protected <T> CacheEntry cacheLocked(
             @NonNull final ComponentName componentName, @NonNull final UserHandle user,
             @NonNull final Supplier<T> infoProvider, @NonNull final CachingLogic<T> cachingLogic,
-            @LookupFlag int lookupFlags) {
+            @NonNull CacheLookupFlag lookupFlags) {
         return cacheLocked(
                 componentName,
                 user,
@@ -418,14 +384,13 @@
     protected <T> CacheEntry cacheLocked(
             @NonNull final ComponentName componentName, @NonNull final UserHandle user,
             @NonNull final Supplier<T> infoProvider, @NonNull final CachingLogic<T> cachingLogic,
-            @LookupFlag int lookupFlags, @Nullable final Cursor cursor) {
+            @NonNull CacheLookupFlag lookupFlags, @Nullable final Cursor cursor) {
         assertWorkerThread();
         ComponentKey cacheKey = new ComponentKey(componentName, user);
         CacheEntry entry = mCache.get(cacheKey);
-        final boolean useLowResIcon = (lookupFlags & LookupFlag.USE_LOW_RES) != 0;
+        final boolean useLowResIcon = lookupFlags.useLowRes();
         if (entry == null || (entry.bitmap.isLowRes() && !useLowResIcon)) {
-            boolean addToMemCache = entry != null
-                    || (lookupFlags & LookupFlag.SKIP_ADD_TO_MEM_CACHE) == 0;
+            boolean addToMemCache = entry != null || !lookupFlags.skipAddToMemCache();
             entry = new CacheEntry();
             if (addToMemCache) {
                 mCache.put(cacheKey, entry);
@@ -445,7 +410,7 @@
                         object,
                         entry,
                         cachingLogic,
-                        (lookupFlags & LookupFlag.USE_PACKAGE_ICON) != 0,
+                        lookupFlags.usePackageIcon(),
                         /* usePackageTitle= */ true,
                         componentName,
                         user);
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
new file mode 100644
index 0000000..be93eff
--- /dev/null
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.launcher3.icons.cache
+
+import androidx.annotation.IntDef
+import kotlin.annotation.AnnotationRetention.SOURCE
+
+/** Flags to control cache lookup behavior */
+data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int) {
+
+    /**
+     * Cache will try to load the low res version of the entry unless a high-res is already in
+     * memory
+     */
+    fun useLowRes() = hasFlag(USE_LOW_RES)
+
+    @JvmOverloads fun withUseLowRes(useLowRes: Boolean = true) = updateMask(USE_LOW_RES, useLowRes)
+
+    /** Cache will try to lookup the package entry for the item, if the object entry fails */
+    fun usePackageIcon() = hasFlag(USE_PACKAGE_ICON)
+
+    @JvmOverloads
+    fun withUsePackageIcon(usePackageIcon: Boolean = true) =
+        updateMask(USE_PACKAGE_ICON, usePackageIcon)
+
+    /**
+     * Entry will not be added to the memory cache if it was not already added by a previous lookup
+     */
+    fun skipAddToMemCache() = hasFlag(SKIP_ADD_TO_MEM_CACHE)
+
+    @JvmOverloads
+    fun withSkipAddToMemCache(skipAddToMemCache: Boolean = true) =
+        updateMask(SKIP_ADD_TO_MEM_CACHE, skipAddToMemCache)
+
+    private fun hasFlag(@LookupFlag mask: Int) = flag.and(mask) != 0
+
+    private fun updateMask(@LookupFlag mask: Int, addMask: Boolean) =
+        if (addMask) flagCache[flag.or(mask)] else flagCache[flag.and(mask.inv())]
+
+    @Retention(SOURCE)
+    @IntDef(value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE], flag = true)
+    /** Various options to control cache lookup */
+    private annotation class LookupFlag
+
+    companion object {
+        private const val USE_LOW_RES: Int = 1 shl 0
+        private const val USE_PACKAGE_ICON: Int = 1 shl 1
+        private const val SKIP_ADD_TO_MEM_CACHE: Int = 1 shl 2
+
+        private val flagCache = Array(8) { CacheLookupFlag(it) }
+
+        @JvmField val DEFAULT_LOOKUP_FLAG = CacheLookupFlag(0)
+    }
+}
diff --git a/mechanics/Android.bp b/mechanics/Android.bp
index ae00b5f..7b8748a 100644
--- a/mechanics/Android.bp
+++ b/mechanics/Android.bp
@@ -32,6 +32,7 @@
     static_libs: [
         "androidx.compose.runtime_runtime",
         "androidx.compose.ui_ui-util",
+        "androidx.compose.foundation_foundation-layout",
     ],
     srcs: [
         ":mechanics-srcs",
diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt
new file mode 100644
index 0000000..ac8e654
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/MotionValue.kt
@@ -0,0 +1,920 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics
+
+import androidx.compose.runtime.FloatState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.referentialEqualityPolicy
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.util.lerp
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import com.android.mechanics.debug.DebugInspector
+import com.android.mechanics.debug.FrameData
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import com.android.mechanics.spring.calculateUpdatedState
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.math.max
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Computes an animated [output] value, by mapping the [currentInput] according to the [spec].
+ *
+ * A [MotionValue] represents a single animated value within a larger animation. It takes a
+ * numerical [currentInput] value, typically a spatial value like width, height, or gesture length,
+ * and transforms it into an [output] value using a [MotionSpec].
+ *
+ * ## Mapping Input to Output
+ *
+ * The [MotionSpec] defines the relationship between the input and output values. It does this by
+ * specifying a series of [Mapping] functions and [Breakpoint]s. Breakpoints divide the input domain
+ * into segments. Each segment has an associated [Mapping] function, which determines how input
+ * values within that segment are transformed into output values.
+ *
+ * These [Mapping] functions can be arbitrary, as long as they are
+ * 1. deterministic: When invoked repeatedly for the same input, they must produce the same output.
+ * 2. continuous: meaning infinitesimally small changes in input result in infinitesimally small
+ *    changes in output
+ *
+ * A valid [Mapping] function is one whose graph could be drawn without lifting your pen from the
+ * paper, meaning there are no abrupt jumps or breaks.
+ *
+ * ## Animating Discontinuities
+ *
+ * When the input value crosses a breakpoint, there might be a discontinuity in the output value due
+ * to the switch between mapping functions. `MotionValue` automatically animates these
+ * discontinuities using a spring animation. The spring parameters are defined for each
+ * [Breakpoint].
+ *
+ * ## Guarantees for Choreography
+ *
+ * Breakpoints can also define [Guarantee]s. These guarantees can make the spring animation finish
+ * faster, in response to quick input value changes. Thus, [Guarantee]s allows to maintain a
+ * predictable choreography, even as the input is unpredictably changed by a user's gesture.
+ *
+ * ## Updating the MotionSpec
+ *
+ * The [spec] property can be changed at any time. If the new spec produces a different output for
+ * the current input, the difference will be animated using the spring parameters defined in
+ * [MotionSpec.resetSpring].
+ *
+ * ## Gesture Context
+ *
+ * The [GestureContext] augments the [currentInput] value with the user's intent. The
+ * [GestureContext] is created wherever gesture input is handled. If the motion value is not driven
+ * by a gesture, it is OK for the [GestureContext] to return static values.
+ *
+ * ## Usage
+ *
+ * The [MotionValue] does animate the [output] implicitly, whenever a change in [currentInput],
+ * [spec], or [gestureContext] requires it. The animated value is computed whenever the [output]
+ * property is read, or the latest once the animation frame is complete.
+ * 1. Create an instance, providing the input value, gesture context, and an initial spec.
+ * 2. Call [keepRunning] in a coroutine scope, and keep the coroutine running while the
+ *    `MotionValue` is in use.
+ * 3. Access the animated output value through the [output] property.
+ *
+ * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to
+ * animate.
+ *
+ * @param currentInput Provides the current input value.
+ * @param gestureContext The [GestureContext] augmenting the [currentInput].
+ * @param label An optional label to aid in debugging.
+ * @param stableThreshold A threshold value (in output units) that determines when the
+ *   [MotionValue]'s internal spring animation is considered stable.
+ */
+class MotionValue(
+    private val currentInput: () -> Float,
+    private val gestureContext: GestureContext,
+    initialSpec: MotionSpec = MotionSpec.Empty,
+    val label: String? = null,
+    private val stableThreshold: Float = 0.01f,
+) : FloatState {
+
+    /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */
+    var spec by mutableStateOf(initialSpec)
+
+    /** Animated [output] value. */
+    val output: Float
+        get() = currentDirectMapped + currentAnimatedDelta
+
+    /**
+     * [output] value, but without animations.
+     *
+     * This value always reports the target value, even before a animation is finished.
+     *
+     * While [isStable], [outputTarget] and [output] are the same value.
+     */
+    val outputTarget: Float
+        get() = currentDirectMapped + currentAnimation.targetValue
+
+    /** The [output] exposed as [FloatState]. */
+    override val floatValue: Float
+        get() = output
+
+    /** Whether an animation is currently running. */
+    val isStable: Boolean
+        get() = currentSpringState == SpringState.AtRest
+
+    /**
+     * Keeps the [MotionValue]'s animated output running.
+     *
+     * Clients must call [keepRunning], and keep the coroutine running while the [MotionValue] is in
+     * use. When disposing this [MotionValue], cancel the coroutine.
+     *
+     * Internally, this method does suspend, unless there are animations ongoing.
+     */
+    suspend fun keepRunning(): Nothing = coroutineScope {
+        check(!isActive) { "keepRunning() invoked while already running" }
+        isActive = true
+        try {
+            // The purpose of this implementation is to run an animation frame (via withFrameNanos)
+            // whenever the input changes, or the spring is still settling, but otherwise just
+            // suspend.
+
+            // Used to suspend when no animations are running, and to wait for a wakeup signal.
+            val wakeupChannel = Channel<Unit>(capacity = Channel.CONFLATED)
+
+            // `true` while the spring is settling.
+            var runAnimationFrames = !isStable
+            launch {
+                // TODO(b/383979536) use a SnapshotStateObserver instead
+                snapshotFlow {
+                        // observe all input values
+                        var result = spec.hashCode()
+                        result = result * 31 + currentInput().hashCode()
+                        result = result * 31 + currentDirection.hashCode()
+                        result = result * 31 + currentGestureDistance.hashCode()
+
+                        // Track whether the spring needs animation frames to finish
+                        // In fact, whether the spring is settling is the only relevant bit to
+                        // export from here. For everything else, just cause the flow to emit a
+                        // different value (hence the hashing)
+                        (result shl 1) + if (isStable) 0 else 1
+                    }
+                    .collect { hashedState ->
+                        // while the 'runAnimationFrames' bit was set on the result
+                        runAnimationFrames = (hashedState and 1) != 0
+                        // nudge the animation runner in case its sleeping.
+                        wakeupChannel.send(Unit)
+                    }
+            }
+
+            while (true) {
+                if (!runAnimationFrames) {
+                    // While the spring does not need animation frames (its stable), wait until
+                    // woken up - this can be for a single frame after an input change.
+                    debugIsAnimating = false
+                    wakeupChannel.receive()
+                }
+
+                debugIsAnimating = true
+                withFrameNanos { frameTimeNanos -> currentAnimationTimeNanos = frameTimeNanos }
+
+                // At this point, the complete frame is done (including layout, drawing and
+                // everything else). What follows next is similar what one would do in a
+                // `SideEffect`, were this composable code:
+                // If during the last frame, a new animation was started, or a new segment entered,
+                // this state is copied over. If nothing changed, the computed `current*` state will
+                // be the same, it won't have a side effect.
+
+                // Capturing the state here is required since crossing a breakpoint is an event -
+                // the code has to record that this happened.
+
+                // Important - capture all values first, and only afterwards update the state.
+                // Interleaving read and update might trigger immediate re-computations.
+                val newSegment = currentSegment
+                val newGuaranteeState = currentGuaranteeState
+                val newAnimation = currentAnimation
+                val newSpringState = currentSpringState
+
+                // Capture the last frames input.
+                lastFrameTimeNanos = currentAnimationTimeNanos
+                lastInput = currentInput()
+                lastGestureDistance = currentGestureDistance
+                // Not capturing currentDirection and spec explicitly, they are included in
+                // lastSegment
+
+                // Update the state to the computed `current*` values
+                lastSegment = newSegment
+                lastGuaranteeState = newGuaranteeState
+                lastAnimation = newAnimation
+                lastSpringState = newSpringState
+                debugInspector?.run {
+                    frame =
+                        FrameData(
+                            lastInput,
+                            currentDirection,
+                            lastGestureDistance,
+                            lastFrameTimeNanos,
+                            lastSpringState,
+                            lastSegment,
+                            lastAnimation,
+                        )
+                }
+            }
+
+            // Keep the compiler happy - the while (true) {} above will not complete, yet the
+            // compiler wants a return value.
+            @Suppress("UNREACHABLE_CODE") awaitCancellation()
+        } finally {
+            isActive = false
+        }
+    }
+
+    companion object {
+        /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */
+        fun createDerived(
+            source: MotionValue,
+            initialSpec: MotionSpec = MotionSpec.Empty,
+            label: String? = null,
+            stableThreshold: Float = 0.01f,
+        ): MotionValue {
+            return MotionValue(
+                currentInput = source::output,
+                gestureContext = source.gestureContext,
+                initialSpec = initialSpec,
+                label = label,
+                stableThreshold = stableThreshold,
+            )
+        }
+
+        internal const val TAG = "MotionValue"
+    }
+
+    // ---- Last frame's state ---------------------------------------------------------------------
+
+    // The following state values prefixed with `last*` contain the state of the last completed
+    // frame. These values are updated in [keepRunning], once [withFrameNanos] returns.
+    // The `last*` state is what the `current*` computations further down are based on.
+
+    /**
+     * The segment in use, defined by the min/max [Breakpoint]s and the [Mapping] in between. This
+     * implicitly also captures the [InputDirection] and [MotionSpec].
+     */
+    private var lastSegment: SegmentData by
+        mutableStateOf(
+            spec.segmentAtInput(currentInput(), currentDirection),
+            referentialEqualityPolicy(),
+        )
+
+    /**
+     * State of the [Guarantee]. Its interpretation is defined by the [lastSegment]'s
+     * [SegmentData.entryBreakpoint]'s [Breakpoint.guarantee]. If that breakpoint has no guarantee,
+     * this value will be [GuaranteeState.Inactive].
+     *
+     * This is the maximal guarantee value seen so far, as well as the guarantee's start value, and
+     * is used to compute the spring-tightening fraction.
+     */
+    private inline var lastGuaranteeState: GuaranteeState
+        get() = GuaranteeState(_lastGuaranteeStatePacked)
+        set(value) {
+            _lastGuaranteeStatePacked = value.packedValue
+        }
+
+    /** Backing field for [lastGuaranteeState], to avoid auto-boxing. */
+    private var _lastGuaranteeStatePacked: Long by
+        mutableLongStateOf(GuaranteeState.Inactive.packedValue)
+
+    /**
+     * The state of an ongoing animation of a discontinuity.
+     *
+     * The spring animation is described by the [DiscontinuityAnimation.springStartState], which
+     * tracks the oscillation of the spring until the displacement is guaranteed not to exceed
+     * [stableThreshold] anymore. The spring animation started at
+     * [DiscontinuityAnimation.springStartTimeNanos], and uses the
+     * [DiscontinuityAnimation.springParameters]. The displacement's origin is at
+     * [DiscontinuityAnimation.targetValue].
+     *
+     * This state does not have to be updated every frame, even as an animation is ongoing: the
+     * spring animation can be computed with the same start parameters, and as time progresses, the
+     * [SpringState.calculateUpdatedState] is passed an ever larger `elapsedNanos` on each frame.
+     *
+     * The [DiscontinuityAnimation.targetValue] is a delta to the direct mapped output value from
+     * the [SegmentData.mapping]. It might accumulate the target value - it is not required to reset
+     * when the animation ends.
+     */
+    private var lastAnimation: DiscontinuityAnimation by
+        mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy())
+
+    // ---- Last frame's input and output ----------------------------------------------------------
+
+    // The state below captures relevant input values (including frame time) and the computed spring
+    // state, thus are updated on every frame. To avoid excessive invalidations, these must only be
+    // read from [currentAnimation] and [currentGuaranteeState], when starting a new animation.
+
+    /**
+     * Last frame's spring state, based on initial origin values in [lastAnimation], carried-forward
+     * to [lastFrameTimeNanos].
+     */
+    private inline var lastSpringState: SpringState
+        get() = SpringState(_lastSpringStatePacked)
+        set(value) {
+            _lastSpringStatePacked = value.packedValue
+        }
+
+    /** Backing field for [lastSpringState], to avoid auto-boxing. */
+    private var _lastSpringStatePacked: Long by
+        mutableLongStateOf(lastAnimation.springStartState.packedValue)
+
+    /** The time of the last frame, in nanoseconds. */
+    private var lastFrameTimeNanos by mutableLongStateOf(-1L)
+
+    /** The [currentInput] of the last frame */
+    private var lastInput by mutableFloatStateOf(currentInput())
+
+    /** The [currentGestureDistance] input of the last frame. */
+    private var lastGestureDistance by mutableFloatStateOf(currentGestureDistance)
+
+    // ---- Declarative Update ---------------------------------------------------------------------
+
+    // All the below contains the magic to compute the updated [MotionValue] state.
+    // The code is strictly ordered by dependencies - code is only ever allowed to access a value
+    // that is placed above in this file, to avoid cyclic dependencies.
+
+    /**
+     * The current frame's animation time, updated by [keepRunning] while an animation is running or
+     * the input changed.
+     */
+    private var currentAnimationTimeNanos by mutableLongStateOf(-1L)
+
+    /** [gestureContext]'s [GestureContext.direction], exists solely for consistent naming. */
+    private inline val currentDirection: InputDirection
+        get() = gestureContext.direction
+
+    /** [gestureContext]'s [GestureContext.distance], exists solely for consistent naming. */
+    private inline val currentGestureDistance: Float
+        get() = gestureContext.distance
+
+    /**
+     * The current segment, which defines the [Mapping] function used to transform the input to the
+     * output.
+     *
+     * While both [spec] and [currentDirection] remain the same, and [currentInput] is within the
+     * segment (see [SegmentData.isValidForInput]), this is [lastSegment].
+     *
+     * Otherwise, [MotionSpec.onChangeSegment] is queried for an up-dated segment.
+     */
+    private val currentSegment: SegmentData by derivedStateOf {
+        val lastSegment = lastSegment
+        val input = currentInput()
+        val direction = currentDirection
+
+        val specChanged = lastSegment.spec != spec
+        if (specChanged || !lastSegment.isValidForInput(input, direction)) {
+            spec.onChangeSegment(lastSegment, input, direction)
+        } else {
+            lastSegment
+        }
+    }
+
+    /**
+     * Describes how the [currentSegment] is different from last frame's [lastSegment].
+     *
+     * This affects how the discontinuities are animated and [Guarantee]s applied.
+     */
+    private enum class SegmentChangeType {
+        /**
+         * The segment has the same key, this is considered equivalent.
+         *
+         * Only the [GuaranteeState] needs to be kept updated.
+         */
+        Same,
+
+        /**
+         * The segment's direction changed, however the min / max breakpoints remain the same: This
+         * is a direction change within a segment.
+         *
+         * The delta between the mapping must be animated with the reset spring, and there is no
+         * guarantee associated with the change.
+         */
+        SameOppositeDirection,
+
+        /**
+         * The segment and its direction change. This is a direction change that happened over a
+         * segment boundary.
+         *
+         * The direction change might have happened outside the [lastSegment] already, since a
+         * segment can't be exited at the entry side.
+         */
+        Direction,
+
+        /**
+         * The segment changed, due to the [currentInput] advancing in the [currentDirection],
+         * crossing one or more breakpoints.
+         *
+         * The guarantees of all crossed breakpoints have to be applied. The [GuaranteeState] must
+         * be reset, and a new [DiscontinuityAnimation] is started.
+         */
+        Traverse,
+
+        /**
+         * The spec was changed and added or removed the previous and/or current segment.
+         *
+         * The [MotionValue] does not have a semantic understanding of this change, hence the
+         * difference output produced by the previous and current mapping are animated with the
+         * [MotionSpec.resetSpring]
+         */
+        Spec,
+    }
+
+    /** Computes the [SegmentChangeType] between [lastSegment] and [currentSegment]. */
+    private val segmentChangeType: SegmentChangeType
+        get() {
+            val currentSegment = currentSegment
+            val lastSegment = lastSegment
+
+            if (currentSegment.key == lastSegment.key) {
+                return SegmentChangeType.Same
+            }
+
+            if (
+                currentSegment.key.minBreakpoint == lastSegment.key.minBreakpoint &&
+                    currentSegment.key.maxBreakpoint == lastSegment.key.maxBreakpoint
+            ) {
+                return SegmentChangeType.SameOppositeDirection
+            }
+
+            val currentSpec = currentSegment.spec
+            val lastSpec = lastSegment.spec
+            if (currentSpec !== lastSpec) {
+                // Determine/guess whether the segment change was due to the changed spec, or
+                // whether lastSpec would return the same segment key for the update input.
+                val lastSpecSegmentForSameInput =
+                    lastSpec.segmentAtInput(currentInput(), gestureContext.direction).key
+                if (currentSegment.key != lastSpecSegmentForSameInput) {
+                    // Note: this might not be correct if the new [MotionSpec.segmentHandlers] were
+                    // involved.
+                    return SegmentChangeType.Spec
+                }
+            }
+
+            return if (currentSegment.direction == lastSegment.direction) {
+                SegmentChangeType.Traverse
+            } else {
+                SegmentChangeType.Direction
+            }
+        }
+
+    /**
+     * Computes the fraction of [position] between [lastInput] and [currentInput].
+     *
+     * Essentially, this determines fractionally when [position] was crossed, between the current
+     * frame and the last frame.
+     *
+     * Since frames are updated periodically, not continuously, crossing a breakpoint happened
+     * sometime between the last frame's start and this frame's start.
+     *
+     * This fraction is used to estimate the time when a breakpoint was crossed since last frame,
+     * and simplifies the logic of crossing multiple breakpoints in one frame, as it offers the
+     * springs and guarantees time to be updated correctly.
+     *
+     * Of course, this is a simplification that assumes the input velocity was uniform during the
+     * last frame, but that is likely good enough.
+     */
+    private fun lastFrameFractionOfPosition(position: Float): Float {
+        return ((position - lastInput) / (currentInput() - lastInput)).coerceIn(0f, 1f)
+    }
+
+    /**
+     * The [GuaranteeState] for [currentSegment].
+     *
+     * Without a segment change, this carries forward [lastGuaranteeState], adjusted to the new
+     * input if needed.
+     *
+     * If a segment change happened, this is a new [GuaranteeState] for the [currentSegment]. Any
+     * remaining [lastGuaranteeState] will be consumed in [currentAnimation].
+     */
+    private val currentGuaranteeState: GuaranteeState by derivedStateOf {
+        val currentSegment = currentSegment
+        val entryBreakpoint = currentSegment.entryBreakpoint
+
+        // First, determine the origin of the guarantee computations
+        val guaranteeOriginState =
+            when (segmentChangeType) {
+                // Still in the segment, the origin is carried over from the last frame
+                SegmentChangeType.Same -> lastGuaranteeState
+                // The direction changed within the same segment, no guarantee to enforce.
+                SegmentChangeType.SameOppositeDirection ->
+                    return@derivedStateOf GuaranteeState.Inactive
+                // The spec changes, there is no guarantee associated with the animation.
+                SegmentChangeType.Spec -> return@derivedStateOf GuaranteeState.Inactive
+                SegmentChangeType.Direction -> {
+                    // Direction changed over a segment boundary. To make up for the
+                    // directionChangeSlop, the guarantee starts at the current input.
+                    GuaranteeState.withStartValue(
+                        when (entryBreakpoint.guarantee) {
+                            is Guarantee.InputDelta -> currentInput()
+                            is Guarantee.GestureDistance -> gestureContext.distance
+                            is Guarantee.None -> return@derivedStateOf GuaranteeState.Inactive
+                        }
+                    )
+                }
+
+                SegmentChangeType.Traverse -> {
+                    // Traversed over a segment boundary, the guarantee going forward is determined
+                    // by the [entryBreakpoint].
+                    GuaranteeState.withStartValue(
+                        when (entryBreakpoint.guarantee) {
+                            is Guarantee.InputDelta -> entryBreakpoint.position
+                            is Guarantee.GestureDistance -> {
+                                // Guess the [GestureDistance] origin - since the gesture distance
+                                // is sampled, interpolate it according to when the breakpoint was
+                                // crossed in the last frame.
+                                val fractionalBreakpointPos =
+                                    lastFrameFractionOfPosition(entryBreakpoint.position)
+
+                                lerp(
+                                    lastGestureDistance,
+                                    gestureContext.distance,
+                                    fractionalBreakpointPos,
+                                )
+                            }
+
+                            // No guarantee to enforce.
+                            is Guarantee.None -> return@derivedStateOf GuaranteeState.Inactive
+                        }
+                    )
+                }
+            }
+
+        // Finally, update the origin state with the current guarantee value.
+        guaranteeOriginState.withCurrentValue(
+            when (entryBreakpoint.guarantee) {
+                is Guarantee.InputDelta -> currentInput()
+                is Guarantee.GestureDistance -> gestureContext.distance
+                is Guarantee.None -> return@derivedStateOf GuaranteeState.Inactive
+            },
+            currentSegment.direction,
+        )
+    }
+
+    /**
+     * The [DiscontinuityAnimation] in effect for the current frame.
+     *
+     * This describes the starting condition of the spring animation, and is only updated if the
+     * spring animation must restarted: that is, if yet another discontinuity must be animated as a
+     * result of a segment change, or if the [currentGuaranteeState] requires the spring to be
+     * tightened.
+     *
+     * See [currentSpringState] for the continuously updated, animated spring values.
+     */
+    private val currentAnimation: DiscontinuityAnimation by derivedStateOf {
+        val currentSegment = currentSegment
+        val lastSegment = lastSegment
+        val currentSpec = spec
+        val currentInput = currentInput()
+        val lastAnimation = lastAnimation
+
+        when (segmentChangeType) {
+            SegmentChangeType.Same -> {
+                if (lastAnimation.isAtRest) {
+                    // Nothing to update if no animation is ongoing
+                    lastAnimation
+                } else if (lastGuaranteeState == currentGuaranteeState) {
+                    // Nothing to update if the spring must not be tightened.
+                    lastAnimation
+                } else {
+                    // Compute the updated spring parameters
+                    val tightenedSpringParameters =
+                        currentGuaranteeState.updatedSpringParameters(
+                            currentSegment.entryBreakpoint
+                        )
+
+                    lastAnimation.copy(
+                        springStartState = lastSpringState,
+                        springParameters = tightenedSpringParameters,
+                        springStartTimeNanos = lastFrameTimeNanos,
+                    )
+                }
+            }
+
+            SegmentChangeType.SameOppositeDirection,
+            SegmentChangeType.Direction,
+            SegmentChangeType.Spec -> {
+                // Determine the delta in the output, as produced by the old and new mapping.
+                val delta =
+                    currentSegment.mapping.map(currentInput) - lastSegment.mapping.map(currentInput)
+
+                if (delta == 0f) {
+                    // Nothing new to animate.
+                    lastAnimation
+                } else {
+                    val springParameters =
+                        if (segmentChangeType == SegmentChangeType.Direction) {
+                            currentSegment.entryBreakpoint.spring
+                        } else {
+                            currentSpec.resetSpring
+                        }
+
+                    val newTarget = delta - lastSpringState.displacement
+                    DiscontinuityAnimation(
+                        newTarget,
+                        SpringState(-newTarget, lastSpringState.velocity),
+                        springParameters,
+                        lastFrameTimeNanos,
+                    )
+                }
+            }
+
+            SegmentChangeType.Traverse -> {
+                // Process all breakpoints traversed, in order.
+                // This is involved due to the guarantees - they have to be applied, one after the
+                // other, before crossing the next breakpoint.
+                val currentDirection = currentSegment.direction
+
+                with(currentSpec[currentDirection]) {
+                    val targetIndex = findSegmentIndex(currentSegment.key)
+                    val sourceIndex = findSegmentIndex(lastSegment.key)
+                    check(targetIndex != sourceIndex)
+
+                    val directionOffset = if (targetIndex > sourceIndex) 1 else -1
+
+                    var lastBreakpoint = lastSegment.entryBreakpoint
+                    var lastAnimationTime = lastFrameTimeNanos
+                    var guaranteeState = lastGuaranteeState
+                    var springState = lastSpringState
+                    var springTarget = lastAnimation.targetValue
+                    var springParameters = lastAnimation.springParameters
+
+                    var segmentIndex = sourceIndex
+                    while (segmentIndex != targetIndex) {
+                        val nextBreakpoint =
+                            breakpoints[segmentIndex + directionOffset.coerceAtLeast(0)]
+
+                        val nextBreakpointFrameFraction =
+                            lastFrameFractionOfPosition(nextBreakpoint.position)
+
+                        val nextBreakpointCrossTime =
+                            lerp(
+                                lastFrameTimeNanos,
+                                currentAnimationTimeNanos,
+                                nextBreakpointFrameFraction,
+                            )
+                        if (
+                            guaranteeState != GuaranteeState.Inactive &&
+                                springState != SpringState.AtRest
+                        ) {
+                            val guaranteeValueAtNextBreakpoint =
+                                when (lastBreakpoint.guarantee) {
+                                    is Guarantee.InputDelta -> nextBreakpoint.position
+                                    is Guarantee.GestureDistance ->
+                                        lerp(
+                                            lastGestureDistance,
+                                            gestureContext.distance,
+                                            nextBreakpointFrameFraction,
+                                        )
+
+                                    is Guarantee.None ->
+                                        error(
+                                            "guaranteeState ($guaranteeState) is not Inactive, guarantee is missing"
+                                        )
+                                }
+
+                            guaranteeState =
+                                guaranteeState.withCurrentValue(
+                                    guaranteeValueAtNextBreakpoint,
+                                    currentDirection,
+                                )
+
+                            springParameters =
+                                guaranteeState.updatedSpringParameters(lastBreakpoint)
+                        }
+
+                        springState =
+                            springState.calculateUpdatedState(
+                                nextBreakpointCrossTime - lastAnimationTime,
+                                springParameters,
+                            )
+                        lastAnimationTime = nextBreakpointCrossTime
+
+                        val beforeBreakpoint = mappings[segmentIndex].map(nextBreakpoint.position)
+                        val afterBreakpoint =
+                            mappings[segmentIndex + directionOffset].map(nextBreakpoint.position)
+
+                        val delta = afterBreakpoint - beforeBreakpoint
+                        springTarget += delta
+                        springState = springState.addDisplacement(-delta)
+
+                        segmentIndex += directionOffset
+                        lastBreakpoint = nextBreakpoint
+                        guaranteeState =
+                            when (nextBreakpoint.guarantee) {
+                                is Guarantee.InputDelta ->
+                                    GuaranteeState.withStartValue(nextBreakpoint.position)
+
+                                is Guarantee.GestureDistance ->
+                                    GuaranteeState.withStartValue(
+                                        lerp(
+                                            lastGestureDistance,
+                                            gestureContext.distance,
+                                            nextBreakpointFrameFraction,
+                                        )
+                                    )
+
+                                is Guarantee.None -> GuaranteeState.Inactive
+                            }
+                    }
+
+                    val tightened =
+                        currentGuaranteeState.updatedSpringParameters(
+                            currentSegment.entryBreakpoint
+                        )
+
+                    DiscontinuityAnimation(springTarget, springState, tightened, lastAnimationTime)
+                }
+            }
+        }
+    }
+
+    /**
+     * Up to date and animated spring state, based on initial origin values in [currentAnimation],
+     * carried-forward to [currentAnimationTimeNanos].
+     *
+     * Is guaranteed to be [SpringState.AtRest] if the spring is not animating (the oscillation is
+     * less than [stableThreshold]).
+     */
+    private val currentSpringState: SpringState by derivedStateOf {
+        with(currentAnimation) {
+            if (isAtRest) return@derivedStateOf SpringState.AtRest
+
+            val nanosSinceAnimationStart = currentAnimationTimeNanos - springStartTimeNanos
+            val updatedSpringState =
+                springStartState.calculateUpdatedState(nanosSinceAnimationStart, springParameters)
+
+            if (updatedSpringState.isStable(springParameters, stableThreshold)) {
+                SpringState.AtRest
+            } else {
+                updatedSpringState
+            }
+        }
+    }
+
+    private val currentDirectMapped: Float
+        get() = currentSegment.mapping.map(currentInput()) - currentAnimation.targetValue
+
+    private val currentAnimatedDelta: Float
+        get() = currentAnimation.targetValue + currentSpringState.displacement
+
+    // ---- Accessor to internals, for inspection and tests ----------------------------------------
+
+    /** Whether a [keepRunning] coroutine is active currently. */
+    private var isActive = false
+        set(value) {
+            field = value
+            debugInspector?.isActive = value
+        }
+
+    /**
+     * `false` whenever the [keepRunning] coroutine is suspended while no animation is running and
+     * the input is not changing.
+     */
+    private var debugIsAnimating = false
+        set(value) {
+            field = value
+            debugInspector?.isAnimating = value
+        }
+
+    private var debugInspector: DebugInspector? = null
+    private var debugInspectorRefCount = AtomicInteger(0)
+
+    private fun onDisposeDebugInspector() {
+        if (debugInspectorRefCount.decrementAndGet() == 0) {
+            debugInspector = null
+        }
+    }
+
+    /**
+     * Provides access to internal state for debug tooling and tests.
+     *
+     * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed.
+     */
+    fun debugInspector(): DebugInspector {
+        if (debugInspectorRefCount.getAndIncrement() == 0) {
+            debugInspector =
+                DebugInspector(
+                    FrameData(
+                        lastInput,
+                        lastSegment.direction,
+                        lastGestureDistance,
+                        lastFrameTimeNanos,
+                        lastSpringState,
+                        lastSegment,
+                        lastAnimation,
+                    ),
+                    isActive,
+                    debugIsAnimating,
+                    ::onDisposeDebugInspector,
+                )
+        }
+
+        return checkNotNull(debugInspector)
+    }
+}
+
+/**
+ * Captures the start-state of a spring-animation to smooth over a discontinuity.
+ *
+ * Discontinuities are caused by segment changes, where the new and old segment produce different
+ * output values for the same input.
+ */
+internal data class DiscontinuityAnimation(
+    val targetValue: Float,
+    val springStartState: SpringState,
+    val springParameters: SpringParameters,
+    val springStartTimeNanos: Long,
+) {
+    val isAtRest: Boolean
+        get() = springStartState == SpringState.AtRest
+
+    companion object {
+        val None =
+            DiscontinuityAnimation(
+                targetValue = 0f,
+                springStartState = SpringState.AtRest,
+                springParameters = SpringParameters.Snap,
+                springStartTimeNanos = 0L,
+            )
+    }
+}
+
+/**
+ * Captures the origin of a guarantee, and the maximal distance the input has been away from the
+ * origin at most.
+ */
+@JvmInline
+internal value class GuaranteeState(val packedValue: Long) {
+    private val start: Float
+        get() = unpackFloat1(packedValue)
+
+    private val maxDelta: Float
+        get() = unpackFloat2(packedValue)
+
+    private val isInactive: Boolean
+        get() = this == Inactive
+
+    fun withCurrentValue(value: Float, direction: InputDirection): GuaranteeState {
+        if (isInactive) return Inactive
+
+        val delta = ((value - start) * direction.sign).coerceAtLeast(0f)
+        return GuaranteeState(start, max(delta, maxDelta))
+    }
+
+    fun updatedSpringParameters(breakpoint: Breakpoint): SpringParameters {
+        if (isInactive) return breakpoint.spring
+
+        val denominator =
+            when (val guarantee = breakpoint.guarantee) {
+                is Guarantee.None -> return breakpoint.spring
+                is Guarantee.InputDelta -> guarantee.delta
+                is Guarantee.GestureDistance -> guarantee.distance
+            }
+
+        val springTighteningFraction = maxDelta / denominator
+        return com.android.mechanics.spring.lerp(
+            breakpoint.spring,
+            SpringParameters.Snap,
+            springTighteningFraction,
+        )
+    }
+
+    companion object {
+        val Inactive = GuaranteeState(packFloats(Float.NaN, Float.NaN))
+
+        fun withStartValue(start: Float) = GuaranteeState(packFloats(start, 0f))
+    }
+}
+
+internal fun GuaranteeState(start: Float, maxDelta: Float) =
+    GuaranteeState(packFloats(start, maxDelta))
diff --git a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt
new file mode 100644
index 0000000..f1e6f99
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics.debug
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.android.mechanics.DiscontinuityAnimation
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import kotlinx.coroutines.DisposableHandle
+
+/** Utility to gain inspection access to internal [MotionValue] state. */
+class DebugInspector
+internal constructor(
+    initialFrameData: FrameData,
+    initialIsActive: Boolean,
+    initialIsAnimating: Boolean,
+    disposableHandle: DisposableHandle,
+) : DisposableHandle by disposableHandle {
+
+    /** The last completed frame's data. */
+    var frame: FrameData by mutableStateOf(initialFrameData)
+        internal set
+
+    /** Whether a [MotionValue.keepRunning] coroutine is active currently. */
+    var isActive: Boolean by mutableStateOf(initialIsActive)
+        internal set
+
+    /**
+     * `false` whenever the [MotionValue.keepRunning] coroutine internally is suspended while no
+     * animation is running and the input is not changing.
+     */
+    var isAnimating: Boolean by mutableStateOf(initialIsAnimating)
+        internal set
+}
+
+/** The input, output and internal state of a [MotionValue] for the frame. */
+data class FrameData
+internal constructor(
+    val input: Float,
+    val gestureDirection: InputDirection,
+    val gestureDistance: Float,
+    val frameTimeNanos: Long,
+    val springState: SpringState,
+    private val segment: SegmentData,
+    private val animation: DiscontinuityAnimation,
+) {
+    val isStable: Boolean
+        get() = springState == SpringState.AtRest
+
+    val springParameters: SpringParameters
+        get() = animation.springParameters
+
+    val segmentKey: SegmentKey
+        get() = segment.key
+
+    val output: Float
+        get() = currentDirectMapped + (animation.targetValue + springState.displacement)
+
+    val outputTarget: Float
+        get() = currentDirectMapped + animation.targetValue
+
+    private val currentDirectMapped: Float
+        get() = segment.mapping.map(input) - animation.targetValue
+}
diff --git a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt
new file mode 100644
index 0000000..1242b20
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics.debug
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEachIndexed
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.MotionSpec
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * A debug visualization of the [motionValue].
+ *
+ * Draws both the [MotionValue.spec], as well as the input and output.
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param motionValue The [MotionValue] to inspect.
+ * @param inputRange The relevant range of the input (x) axis, for which to draw the graph.
+ * @param color Color for the dots indicating the value
+ * @param historySize Number of past values to draw as a trail.
+ */
+@Composable
+fun DebugMotionValueVisualization(
+    motionValue: MotionValue,
+    inputRange: ClosedFloatingPointRange<Float>,
+    modifier: Modifier = Modifier,
+    color: Color = Color.DarkGray,
+    historySize: Int = 100,
+) {
+    val spec = motionValue.spec
+    val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) }
+
+    Spacer(
+        modifier =
+            modifier
+                .debugMotionSpecGraph(spec, inputRange, outputRange)
+                .debugMotionValueGraph(motionValue, color, inputRange, outputRange, historySize)
+    )
+}
+
+/**
+ * Draws a full-sized debug visualization of [spec].
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param inputRange The range of the input (x) axis
+ * @param outputRange The range of the output (y) axis.
+ */
+@Composable
+fun Modifier.debugMotionSpecGraph(
+    spec: MotionSpec,
+    inputRange: ClosedFloatingPointRange<Float>,
+    outputRange: ClosedFloatingPointRange<Float> =
+        remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) },
+): Modifier = drawBehind {
+    drawAxis(Color.Gray)
+    if (spec.isUnidirectional) {
+        drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Red)
+    } else {
+        drawDirectionalSpec(spec.minDirection, inputRange, outputRange, Color.Red)
+        drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Blue)
+    }
+}
+
+/**
+ * Draws a full-sized debug visualization of the [motionValue] state.
+ *
+ * This can be combined with [debugMotionSpecGraph], when [inputRange] and [outputRange] are the
+ * same.
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param color Color for the dots indicating the value
+ * @param inputRange The range of the input (x) axis
+ * @param outputRange The range of the output (y) axis.
+ * @param historySize Number of past values to draw as a trail.
+ */
+@Composable
+fun Modifier.debugMotionValueGraph(
+    motionValue: MotionValue,
+    color: Color,
+    inputRange: ClosedFloatingPointRange<Float>,
+    outputRange: ClosedFloatingPointRange<Float> =
+        remember(motionValue.spec, inputRange) {
+            motionValue.spec.computeOutputValueRange(inputRange)
+        },
+    historySize: Int = 100,
+): Modifier = composed {
+    val inspector = remember(motionValue) { motionValue.debugInspector() }
+
+    val history = remember { mutableStateListOf<FrameData>() }
+
+    LaunchedEffect(inspector, history) {
+        snapshotFlow { inspector.frame }
+            .collect {
+                history.add(it)
+                if (history.size > historySize) {
+                    history.removeFirst()
+                }
+            }
+    }
+
+    DisposableEffect(inspector) { onDispose { inspector.dispose() } }
+
+    this.drawBehind { drawInputOutputTrail(history, inputRange, outputRange, color) }
+}
+
+private val MotionSpec.isUnidirectional: Boolean
+    get() = maxDirection == minDirection
+
+private fun MotionSpec.computeOutputValueRange(
+    inputRange: ClosedFloatingPointRange<Float>
+): ClosedFloatingPointRange<Float> {
+    return if (isUnidirectional) {
+        maxDirection.computeOutputValueRange(inputRange)
+    } else {
+        val maxRange = maxDirection.computeOutputValueRange(inputRange)
+        val minRange = minDirection.computeOutputValueRange(inputRange)
+
+        val start = min(minRange.start, maxRange.start)
+        val endInclusive = max(minRange.endInclusive, maxRange.endInclusive)
+
+        start..endInclusive
+    }
+}
+
+private fun DirectionalMotionSpec.computeOutputValueRange(
+    inputRange: ClosedFloatingPointRange<Float>
+): ClosedFloatingPointRange<Float> {
+
+    val start = findBreakpointIndex(inputRange.start)
+    val end = findBreakpointIndex(inputRange.endInclusive)
+
+    val samples = buildList {
+        add(mappings[start].map(inputRange.start))
+
+        for (breakpointIndex in (start + 1)..end) {
+
+            val position = breakpoints[breakpointIndex].position
+
+            add(mappings[breakpointIndex - 1].map(position))
+            add(mappings[breakpointIndex].map(position))
+        }
+
+        add(mappings[end].map(inputRange.endInclusive))
+    }
+
+    return samples.min()..samples.max()
+}
+
+private fun DrawScope.mapPointInInputToX(
+    input: Float,
+    inputRange: ClosedFloatingPointRange<Float>,
+): Float {
+    val inputExtent = (inputRange.endInclusive - inputRange.start)
+    return ((input - inputRange.start) / (inputExtent)) * size.width
+}
+
+private fun DrawScope.mapPointInOutputToY(
+    output: Float,
+    outputRange: ClosedFloatingPointRange<Float>,
+): Float {
+    val outputExtent = (outputRange.endInclusive - outputRange.start)
+    return (1 - (output - outputRange.start) / (outputExtent)) * size.height
+}
+
+private fun DrawScope.drawDirectionalSpec(
+    spec: DirectionalMotionSpec,
+    inputRange: ClosedFloatingPointRange<Float>,
+    outputRange: ClosedFloatingPointRange<Float>,
+    color: Color,
+) {
+
+    val startSegment = spec.findBreakpointIndex(inputRange.start)
+    val endSegment = spec.findBreakpointIndex(inputRange.endInclusive)
+
+    for (segmentIndex in startSegment..endSegment) {
+        val mapping = spec.mappings[segmentIndex]
+        val startBreakpoint = spec.breakpoints[segmentIndex]
+        val segmentStart = startBreakpoint.position
+        val fromInput = segmentStart.coerceAtLeast(inputRange.start)
+        val endBreakpoint = spec.breakpoints[segmentIndex + 1]
+        val segmentEnd = endBreakpoint.position
+        val toInput = segmentEnd.coerceAtMost(inputRange.endInclusive)
+
+        // TODO add support for functions that are not linear
+        val fromY = mapPointInOutputToY(mapping.map(fromInput), outputRange)
+        val toY = mapPointInOutputToY(mapping.map(toInput), outputRange)
+
+        val start = Offset(mapPointInInputToX(fromInput, inputRange), fromY)
+        val end = Offset(mapPointInInputToX(toInput, inputRange), toY)
+        drawLine(color, start, end)
+
+        if (segmentStart == fromInput) {
+            drawCircle(color, 2.dp.toPx(), start)
+        }
+
+        if (segmentEnd == toInput) {
+            drawCircle(color, 2.dp.toPx(), end)
+        }
+
+        val guarantee = startBreakpoint.guarantee
+        if (guarantee is Guarantee.InputDelta) {
+            val guaranteePos = segmentStart + guarantee.delta
+            if (guaranteePos > inputRange.start) {
+
+                val guaranteeOffset =
+                    Offset(
+                        mapPointInInputToX(guaranteePos, inputRange),
+                        mapPointInOutputToY(mapping.map(guaranteePos), outputRange),
+                    )
+
+                val arrowSize = 4.dp.toPx()
+
+                drawLine(
+                    color,
+                    guaranteeOffset,
+                    guaranteeOffset.plus(Offset(arrowSize, -arrowSize)),
+                )
+                drawLine(color, guaranteeOffset, guaranteeOffset.plus(Offset(arrowSize, arrowSize)))
+            }
+        }
+    }
+}
+
+private fun DrawScope.drawInputOutputTrail(
+    history: List<FrameData>,
+    inputRange: ClosedFloatingPointRange<Float>,
+    outputRange: ClosedFloatingPointRange<Float>,
+    color: Color,
+) {
+    history.fastForEachIndexed { index, frame ->
+        val x = mapPointInInputToX(frame.input, inputRange)
+        val y = mapPointInOutputToY(frame.output, outputRange)
+
+        drawCircle(color, 2.dp.toPx(), Offset(x, y), alpha = index / history.size.toFloat())
+    }
+}
+
+private fun DrawScope.drawAxis(color: Color) {
+
+    drawXAxis(color)
+    drawYAxis(color)
+}
+
+private fun DrawScope.drawYAxis(color: Color, atX: Float = 0f) {
+
+    val arrowSize = 4.dp.toPx()
+
+    drawLine(color, Offset(atX, size.height), Offset(atX, 0f))
+    drawLine(color, Offset(atX, 0f), Offset(atX + arrowSize, arrowSize))
+    drawLine(color, Offset(atX, 0f), Offset(atX - arrowSize, arrowSize))
+}
+
+private fun DrawScope.drawXAxis(color: Color, atY: Float = size.height) {
+
+    val arrowSize = 4.dp.toPx()
+
+    drawLine(color, Offset(0f, atY), Offset(size.width, atY))
+    drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY + arrowSize))
+    drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY - arrowSize))
+}
diff --git a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
index 98b64e8..a031213 100644
--- a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
+++ b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
@@ -31,7 +31,7 @@
  * @see SpringParameters function to create this value.
  */
 @JvmInline
-value class SpringParameters(private val packedValue: Long) {
+value class SpringParameters(val packedValue: Long) {
     val stiffness: Float
         get() = unpackFloat1(packedValue)
 
diff --git a/mechanics/src/com/android/mechanics/spring/SpringState.kt b/mechanics/src/com/android/mechanics/spring/SpringState.kt
index 57de280..c6e1947 100644
--- a/mechanics/src/com/android/mechanics/spring/SpringState.kt
+++ b/mechanics/src/com/android/mechanics/spring/SpringState.kt
@@ -31,7 +31,7 @@
  * @see SpringState function to create this value.
  */
 @JvmInline
-value class SpringState(private val packedValue: Long) {
+value class SpringState(val packedValue: Long) {
     val displacement: Float
         get() = unpackFloat1(packedValue)
 
@@ -51,6 +51,10 @@
         return currentEnergy <= maxStableEnergy
     }
 
+    fun addDisplacement(displacementDelta: Float): SpringState {
+        return SpringState(displacement + displacementDelta, velocity)
+    }
+
     override fun toString(): String {
         return "MechanicsSpringState(displacement=$displacement, velocity=$velocity)"
     }
diff --git a/mechanics/tests/Android.bp b/mechanics/tests/Android.bp
index f892ef1..b064017 100644
--- a/mechanics/tests/Android.bp
+++ b/mechanics/tests/Android.bp
@@ -20,6 +20,7 @@
 android_test {
     name: "mechanics_tests",
     manifest: "AndroidManifest.xml",
+    defaults: ["MotionTestDefaults"],
     test_suites: ["device-tests"],
 
     srcs: [
@@ -33,12 +34,14 @@
         // ":mechanics" dependencies
         "androidx.compose.runtime_runtime",
         "androidx.compose.ui_ui-util",
+        "androidx.compose.foundation_foundation-layout",
 
         // ":mechanics_tests" dependencies
         "androidx.compose.animation_animation-core",
         "platform-test-annotations",
-        "PlatformMotionTesting",
+        "PlatformMotionTestingCompose",
         "androidx.compose.ui_ui-test-junit4",
+        "androidx.compose.ui_ui-test-manifest",
         "androidx.test.runner",
         "androidx.test.ext.junit",
         "kotlin-test",
diff --git a/mechanics/tests/AndroidManifest.xml b/mechanics/tests/AndroidManifest.xml
index edbbcbf..636ebb8 100644
--- a/mechanics/tests/AndroidManifest.xml
+++ b/mechanics/tests/AndroidManifest.xml
@@ -17,10 +17,6 @@
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
     package="com.android.mechanics.tests">
 
-    <application>
-        <uses-library android:name="android.test.runner" />
-    </application>
-
     <instrumentation
         android:name="androidx.test.runner.AndroidJUnitRunner"
         android:label="Tests for Motion Mechanics"
diff --git a/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json
new file mode 100644
index 0000000..9fd0087
--- /dev/null
+++ b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json
@@ -0,0 +1,202 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        2,
+        1.6,
+        1.2,
+        0.8000001,
+        0.40000007,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0.12146205,
+        0.3364076,
+        0.53597057,
+        0.69039464,
+        0.79985267,
+        0.8735208,
+        0.92143244,
+        0.95184386,
+        0.97079945,
+        0.9824491,
+        0.98952854,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 1
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json
new file mode 100644
index 0000000..510426b
--- /dev/null
+++ b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json
@@ -0,0 +1,82 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1.1,
+        1.1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0.05119291,
+        0.095428914
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0.55,
+        0.55
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json
new file mode 100644
index 0000000..c015899
--- /dev/null
+++ b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json
@@ -0,0 +1,182 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        2,
+        1.5,
+        1,
+        0.5,
+        0,
+        -0.5,
+        -1,
+        -1.5,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        0.9303996,
+        0.48961937,
+        0.1611222,
+        0.04164827,
+        0.008622885,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json
new file mode 100644
index 0000000..37b9396
--- /dev/null
+++ b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json
@@ -0,0 +1,262 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256,
+    272,
+    288,
+    304,
+    320,
+    336
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        2,
+        1.5,
+        1,
+        0.5,
+        0,
+        -0.5,
+        -1,
+        -1.5,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2,
+        -2
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        0.9303996,
+        0.7829481,
+        0.61738,
+        0.46381497,
+        0.3348276,
+        0.2332502,
+        0.15701783,
+        0.10203475,
+        0.06376374,
+        0.038021922,
+        0.021308899,
+        0.010875165,
+        0.0046615005,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json
new file mode 100644
index 0000000..0c034c2
--- /dev/null
+++ b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json
@@ -0,0 +1,262 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256,
+    272,
+    288,
+    304,
+    320,
+    336
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1,
+        1.5,
+        2,
+        2.5,
+        3,
+        3.5,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4,
+        4
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0.0696004,
+        0.21705192,
+        0.38261998,
+        0.536185,
+        0.6651724,
+        0.7667498,
+        0.8429822,
+        0.89796525,
+        0.93623626,
+        0.9619781,
+        0.9786911,
+        0.98912483,
+        0.9953385,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json
new file mode 100644
index 0000000..70d62ab
--- /dev/null
+++ b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json
@@ -0,0 +1,112 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        20,
+        40,
+        60,
+        80,
+        100,
+        100
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        20,
+        40,
+        60,
+        80,
+        100,
+        100
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        20,
+        40,
+        60,
+        80,
+        100,
+        100
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        true,
+        true,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_guaranteeGestureDistance_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeGestureDistance_springCompletesWithinDistance.json
new file mode 100644
index 0000000..6eb0987
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_guaranteeGestureDistance_springCompletesWithinDistance.json
@@ -0,0 +1,132 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0.13920438,
+        0.45275474,
+        0.772992,
+        0.9506903,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1600.4729,
+          "dampingRatio": 0.9166666
+        },
+        {
+          "stiffness": 3659.3052,
+          "dampingRatio": 0.9333333
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 19129.314,
+          "dampingRatio": 0.9666667
+        },
+        {
+          "stiffness": 43737.062,
+          "dampingRatio": 0.98333335
+        },
+        {
+          "stiffness": 43737.062,
+          "dampingRatio": 0.98333335
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json
new file mode 100644
index 0000000..9ca1bfa
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json
@@ -0,0 +1,142 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1,
+        1.5,
+        2,
+        2.5,
+        3,
+        3.5,
+        4,
+        4
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0.13920438,
+        0.45275474,
+        0.772992,
+        0.9506903,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1600.4729,
+          "dampingRatio": 0.9166666
+        },
+        {
+          "stiffness": 3659.3052,
+          "dampingRatio": 0.9333333
+        },
+        {
+          "stiffness": 8366.601,
+          "dampingRatio": 0.95
+        },
+        {
+          "stiffness": 19129.314,
+          "dampingRatio": 0.9666667
+        },
+        {
+          "stiffness": 43737.062,
+          "dampingRatio": 0.98333335
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json
new file mode 100644
index 0000000..fe6c211
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json
@@ -0,0 +1,222 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256,
+    272
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1,
+        1.5,
+        2,
+        2.5,
+        3,
+        3.5,
+        4,
+        4.5,
+        5,
+        5,
+        5,
+        5,
+        5,
+        5,
+        5,
+        5
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0.0696004,
+        0.21705192,
+        0.38261998,
+        0.536185,
+        0.6651724,
+        0.7667498,
+        0.8429822,
+        0.89796525,
+        0.93623626,
+        0.9619781,
+        0.9786911,
+        0.98912483,
+        0.9953385,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json
new file mode 100644
index 0000000..e78a244
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json
@@ -0,0 +1,222 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256,
+    272
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0.0696004,
+        0.21705192,
+        0.38261998,
+        0.536185,
+        0.6651724,
+        0.7667498,
+        0.8429822,
+        0.89796525,
+        0.93623626,
+        0.9619781,
+        0.9786911,
+        0.98912483,
+        0.9953385,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json
new file mode 100644
index 0000000..0ad35c3
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json
@@ -0,0 +1,212 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.5,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0.01973492,
+        0.1381998,
+        0.29998195,
+        0.4619913,
+        0.6040878,
+        0.71933174,
+        0.80780226,
+        0.8728444,
+        0.9189145,
+        0.95043683,
+        0.971274,
+        0.9845492,
+        0.9926545,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json
new file mode 100644
index 0000000..333387e
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json
@@ -0,0 +1,222 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256,
+    272
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        2,
+        1.5,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        1,
+        0.9303996,
+        0.7829481,
+        0.61738,
+        0.46381497,
+        0.3348276,
+        0.2332502,
+        0.15701783,
+        0.10203475,
+        0.06376374,
+        0.038021922,
+        0.021308899,
+        0.010875165,
+        0.0046615005,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json
new file mode 100644
index 0000000..87337cc
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json
@@ -0,0 +1,212 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        2,
+        1.5,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1,
+        1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        0.9802651,
+        0.8618002,
+        0.70001805,
+        0.5380087,
+        0.39591217,
+        0.28066826,
+        0.19219774,
+        0.1271556,
+        0.0810855,
+        0.04956317,
+        0.028725982,
+        0.015450776,
+        0.0073454976,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        1,
+        1,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json
new file mode 100644
index 0000000..04fd9bd
--- /dev/null
+++ b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json
@@ -0,0 +1,192 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        1.5,
+        1.7,
+        1.9,
+        1.6615533,
+        1.226438,
+        0.817063,
+        0.5036553,
+        0.28967857,
+        0.15513074,
+        0.07628298,
+        0.033192396,
+        0.011453152,
+        0.0016130209,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        1.5,
+        1.7,
+        1.9,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json
new file mode 100644
index 0000000..675f553
--- /dev/null
+++ b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json
@@ -0,0 +1,182 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5,
+        0.5
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        1.5,
+        1.3,
+        1.1,
+        0.9619519,
+        0.7100431,
+        0.4730364,
+        0.29158998,
+        0.16770864,
+        0.08981252,
+        0.044163823,
+        0.019216657,
+        0.0066307783,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        1.5,
+        1.3,
+        1.1,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1400,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json
new file mode 100644
index 0000000..79fd8b3
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json
@@ -0,0 +1,212 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5,
+        2.5
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        1.0034066,
+        1.0953252,
+        1.250015,
+        1.4148881,
+        1.5641418,
+        1.6876622,
+        1.783909,
+        1.855533,
+        1.9068143,
+        1.9422642,
+        1.9659443,
+        1.981205,
+        1.9906502,
+        1.996214,
+        2,
+        2
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json
new file mode 100644
index 0000000..a2765d1
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json
@@ -0,0 +1,152 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        2.5,
+        0.4,
+        0.3,
+        0.20000002,
+        0.10000002,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min",
+        "Min"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        2,
+        1.8607992,
+        1.5158144,
+        1.0649259,
+        0.62475336,
+        0.29145694,
+        0.11132395,
+        0.036348104,
+        0.009979486,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        2,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 1149.7095,
+          "dampingRatio": 0.90999997
+        },
+        {
+          "stiffness": 1888.3324,
+          "dampingRatio": 0.91999996
+        },
+        {
+          "stiffness": 3101.4778,
+          "dampingRatio": 0.93000007
+        },
+        {
+          "stiffness": 5094,
+          "dampingRatio": 0.94000006
+        },
+        {
+          "stiffness": 5094,
+          "dampingRatio": 0.94000006
+        },
+        {
+          "stiffness": 5094,
+          "dampingRatio": 0.94000006
+        },
+        {
+          "stiffness": 5094,
+          "dampingRatio": 0.94000006
+        },
+        {
+          "stiffness": 5094,
+          "dampingRatio": 0.94000006
+        },
+        {
+          "stiffness": 5094,
+          "dampingRatio": 0.94000006
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json
new file mode 100644
index 0000000..418a6de
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json
@@ -0,0 +1,182 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1,
+        2.1
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        5.000347,
+        5.12011,
+        5.3309407,
+        5.534604,
+        5.6969075,
+        5.8133464,
+        5.8910213,
+        5.93988,
+        5.969008,
+        5.9854507,
+        5.9941716,
+        6,
+        6
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6,
+        6
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        },
+        {
+          "stiffness": 1214.8745,
+          "dampingRatio": 0.91111106
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json
new file mode 100644
index 0000000..35ede9c
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json
@@ -0,0 +1,302 @@
+{
+  "frame_ids": [
+    0,
+    16,
+    32,
+    48,
+    64,
+    80,
+    96,
+    112,
+    128,
+    144,
+    160,
+    176,
+    192,
+    208,
+    224,
+    240,
+    256,
+    272,
+    288,
+    304,
+    320,
+    336,
+    352,
+    368,
+    384,
+    400
+  ],
+  "features": [
+    {
+      "name": "input",
+      "type": "float",
+      "data_points": [
+        0,
+        0.2,
+        0.4,
+        0.6,
+        0.8,
+        1,
+        1.2,
+        1.4000001,
+        1.6000001,
+        1.8000002,
+        2.0000002,
+        2.2000003,
+        2.4000003,
+        2.6000004,
+        2.8000004,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3,
+        3
+      ]
+    },
+    {
+      "name": "gestureDirection",
+      "type": "string",
+      "data_points": [
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max",
+        "Max"
+      ]
+    },
+    {
+      "name": "output",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0,
+        0,
+        0,
+        0.0696004,
+        0.21705192,
+        0.38261998,
+        0.536185,
+        0.66517246,
+        0.8363503,
+        1.0600344,
+        1.2805854,
+        1.4724215,
+        1.6271507,
+        1.745441,
+        1.8321071,
+        1.8933039,
+        1.9350511,
+        1.9625617,
+        1.9800283,
+        1.9906485,
+        1.996761,
+        2,
+        2
+      ]
+    },
+    {
+      "name": "outputTarget",
+      "type": "float",
+      "data_points": [
+        0,
+        0,
+        0,
+        0,
+        0,
+        1,
+        1,
+        1,
+        1,
+        1,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2,
+        2
+      ]
+    },
+    {
+      "name": "outputSpring",
+      "type": "springParameters",
+      "data_points": [
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 100000,
+          "dampingRatio": 1
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        },
+        {
+          "stiffness": 700,
+          "dampingRatio": 0.9
+        }
+      ]
+    },
+    {
+      "name": "isStable",
+      "type": "boolean",
+      "data_points": [
+        true,
+        true,
+        true,
+        true,
+        true,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        false,
+        true,
+        true
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt
new file mode 100644
index 0000000..c5b3e17
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt
@@ -0,0 +1,512 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.mechanics
+
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.TestMonotonicFrameClock
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.builder
+import com.android.mechanics.spec.reverseBuilder
+import com.android.mechanics.testing.DefaultSprings.matStandardDefault
+import com.android.mechanics.testing.DefaultSprings.matStandardFast
+import com.android.mechanics.testing.MotionValueToolkit
+import com.android.mechanics.testing.MotionValueToolkit.Companion.input
+import com.android.mechanics.testing.MotionValueToolkit.Companion.isStable
+import com.android.mechanics.testing.MotionValueToolkit.Companion.output
+import com.android.mechanics.testing.goldenTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.MotionTestRule
+import platform.test.motion.testing.createGoldenPathManager
+
+@RunWith(AndroidJUnit4::class)
+class MotionValueTest {
+    private val goldenPathManager =
+        createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens")
+
+    @get:Rule(order = 0) val rule = createComposeRule()
+    @get:Rule(order = 1) val motion = MotionTestRule(MotionValueToolkit(rule), goldenPathManager)
+
+    @Test
+    fun emptySpec_outputMatchesInput_withoutAnimation() =
+        motion.goldenTest(
+            spec = MotionSpec.Empty,
+            verifyTimeSeries = {
+                // Output always matches the input
+                assertThat(output).containsExactlyElementsIn(input).inOrder()
+                // There must never be an ongoing animation.
+                assertThat(isStable).doesNotContain(false)
+            },
+        ) {
+            animateValueTo(100f)
+        }
+
+    // TODO the tests should describe the expected values not only in terms of goldens, but
+    // also explicitly in verifyTimeSeries
+
+    @Test
+    fun changingInput_addsAnimationToMapping_becomesStable() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .completeWith(Mapping.Linear(factor = 0.5f))
+        ) {
+            animateValueTo(1.1f, changePerFrame = 0.5f)
+            while (underTest.isStable) {
+                updateValue(input + 0.5f)
+                awaitFrames()
+            }
+        }
+
+    @Test
+    fun segmentChange_inMaxDirection_animatedWhenReachingBreakpoint() =
+        motion.goldenTest(
+            spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
+        ) {
+            animateValueTo(1f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun segmentChange_inMinDirection_animatedWhenReachingBreakpoint() =
+        motion.goldenTest(
+            initialValue = 2f,
+            initialDirection = InputDirection.Min,
+            spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One),
+        ) {
+            animateValueTo(1f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun segmentChange_inMaxDirection_springAnimationStartedRetroactively() =
+        motion.goldenTest(
+            spec = specBuilder(Mapping.Zero).toBreakpoint(.75f).completeWith(Mapping.One)
+        ) {
+            animateValueTo(1f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun segmentChange_inMinDirection_springAnimationStartedRetroactively() =
+        motion.goldenTest(
+            initialValue = 2f,
+            initialDirection = InputDirection.Min,
+            spec = specBuilder(Mapping.Zero).toBreakpoint(1.25f).completeWith(Mapping.One),
+        ) {
+            animateValueTo(1f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun segmentChange_guaranteeNone_springAnimatesIndependentOfInput() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .completeWith(Mapping.One, guarantee = Guarantee.None)
+        ) {
+            animateValueTo(5f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun segmentChange_guaranteeInputDelta_springCompletesWithinDistance() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .completeWith(Mapping.One, guarantee = Guarantee.InputDelta(3f))
+        ) {
+            animateValueTo(4f, changePerFrame = 0.5f)
+        }
+
+    @Test
+    fun segmentChange_guaranteeGestureDistance_springCompletesWithinDistance() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .completeWith(Mapping.One, guarantee = Guarantee.GestureDistance(3f))
+        ) {
+            animateValueTo(1f, changePerFrame = 0.5f)
+            while (!underTest.isStable) {
+                gestureContext.distance += 0.5f
+                awaitFrames()
+            }
+        }
+
+    @Test
+    fun specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange() {
+        fun generateSpec(offset: Float) =
+            specBuilder(Mapping.Zero)
+                .toBreakpoint(offset, B1)
+                .jumpTo(1f)
+                .continueWithTargetValue(2f)
+                .toBreakpoint(offset + 1f, B2)
+                .completeWith(Mapping.Zero)
+
+        motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) {
+            var offset = 0f
+            repeat(4) {
+                offset -= .2f
+                underTest.spec = generateSpec(offset)
+                awaitFrames()
+            }
+            awaitStable()
+        }
+    }
+
+    @Test
+    fun specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange() {
+        fun generateSpec(offset: Float) =
+            specBuilder(Mapping.Zero)
+                .toBreakpoint(offset, B1)
+                .jumpTo(1f)
+                .continueWithTargetValue(2f)
+                .toBreakpoint(offset + 1f, B2)
+                .completeWith(Mapping.Zero)
+
+        motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) {
+            var offset = 0f
+            repeat(4) {
+                offset += .2f
+                underTest.spec = generateSpec(offset)
+                awaitFrames()
+            }
+            awaitStable()
+        }
+    }
+
+    @Test
+    fun directionChange_maxToMin_changesSegmentWithDirectionChange() =
+        motion.goldenTest(
+            spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One),
+            initialValue = 2f,
+            initialDirection = InputDirection.Max,
+            directionChangeSlop = 3f,
+        ) {
+            animateValueTo(-2f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun directionChange_minToMax_changesSegmentWithDirectionChange() =
+        motion.goldenTest(
+            spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One),
+            initialValue = 0f,
+            initialDirection = InputDirection.Min,
+            directionChangeSlop = 3f,
+        ) {
+            animateValueTo(4f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun directionChange_maxToMin_appliesGuarantee_afterDirectionChange() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .completeWith(Mapping.One, guarantee = Guarantee.InputDelta(1f)),
+            initialValue = 2f,
+            initialDirection = InputDirection.Max,
+            directionChangeSlop = 3f,
+        ) {
+            animateValueTo(-2f, changePerFrame = 0.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .continueWith(Mapping.One)
+                    .toBreakpoint(2f)
+                    .completeWith(Mapping.Two)
+        ) {
+            animateValueTo(3f, changePerFrame = 0.2f)
+            awaitStable()
+        }
+
+    @Test
+    fun traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .continueWith(Mapping.One)
+                    .toBreakpoint(2f)
+                    .completeWith(Mapping.Two)
+        ) {
+            updateValue(2.5f)
+            awaitStable()
+        }
+
+    @Test
+    fun traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .jumpBy(5f, guarantee = Guarantee.InputDelta(.9f))
+                    .continueWithConstantValue()
+                    .toBreakpoint(2f)
+                    .jumpBy(1f, guarantee = Guarantee.InputDelta(.9f))
+                    .continueWithConstantValue()
+                    .complete()
+        ) {
+            updateValue(2.1f)
+            awaitStable()
+        }
+
+    @Test
+    fun traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees() =
+        motion.goldenTest(
+            spec =
+                specBuilder(Mapping.Zero)
+                    .toBreakpoint(1f)
+                    .continueWith(Mapping.One, guarantee = Guarantee.InputDelta(1f))
+                    .toBreakpoint(2f)
+                    .completeWith(Mapping.Two),
+            initialValue = 2.5f,
+            initialDirection = InputDirection.Max,
+            directionChangeSlop = 1f,
+        ) {
+            updateValue(.5f)
+            animateValueTo(0f)
+            awaitStable()
+        }
+
+    @Test
+    fun changeDirection_flipsBetweenDirectionalSegments() {
+        val spec =
+            MotionSpec(
+                maxDirection = forwardSpecBuilder(Mapping.Zero).complete(),
+                minDirection = reverseSpecBuilder(Mapping.One).complete(),
+            )
+
+        motion.goldenTest(
+            spec = spec,
+            initialValue = 2f,
+            initialDirection = InputDirection.Max,
+            directionChangeSlop = 1f,
+        ) {
+            animateValueTo(0f)
+            awaitStable()
+        }
+    }
+
+    @Test
+    fun keepRunning_concurrentInvocationThrows() = runTestWithFrameClock { testScheduler, _ ->
+        val underTest = MotionValue({ 1f }, FakeGestureContext)
+        val realJob = launch { underTest.keepRunning() }
+        testScheduler.runCurrent()
+
+        assertThat(realJob.isActive).isTrue()
+        try {
+            underTest.keepRunning()
+            // keepRunning returns Nothing, will never get here
+        } catch (e: Throwable) {
+            assertThat(e).isInstanceOf(IllegalStateException::class.java)
+            assertThat(e).hasMessageThat().contains("keepRunning() invoked while already running")
+        }
+        assertThat(realJob.isActive).isTrue()
+        realJob.cancel()
+    }
+
+    @Test
+    fun keepRunning_suspendsWithoutAnAnimation() = runTest {
+        val input = mutableFloatStateOf(0f)
+        val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
+        val underTest = MotionValue(input::value, FakeGestureContext, spec)
+        rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }
+
+        val inspector = underTest.debugInspector()
+        var framesCount = 0
+        backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } }
+
+        rule.awaitIdle()
+        framesCount = 0
+        rule.mainClock.autoAdvance = false
+
+        assertThat(inspector.isActive).isTrue()
+        assertThat(inspector.isAnimating).isFalse()
+
+        // Update the value, but WITHOUT causing an animation
+        input.floatValue = 0.5f
+        rule.awaitIdle()
+
+        // Still on the old frame..
+        assertThat(framesCount).isEqualTo(0)
+        // ... [underTest] is now waiting for an animation frame
+        assertThat(inspector.isAnimating).isTrue()
+
+        rule.mainClock.advanceTimeByFrame()
+        rule.awaitIdle()
+
+        // Produces the frame..
+        assertThat(framesCount).isEqualTo(1)
+        // ... and is suspended again.
+        assertThat(inspector.isAnimating).isFalse()
+
+        rule.mainClock.autoAdvance = true
+        rule.awaitIdle()
+        // Ensure that no more frames are produced
+        assertThat(framesCount).isEqualTo(1)
+    }
+
+    @Test
+    fun keepRunning_remainsActiveWhileAnimating() = runTest {
+        val input = mutableFloatStateOf(0f)
+        val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
+        val underTest = MotionValue(input::value, FakeGestureContext, spec)
+        rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }
+
+        val inspector = underTest.debugInspector()
+        var framesCount = 0
+        backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } }
+
+        rule.awaitIdle()
+        framesCount = 0
+        rule.mainClock.autoAdvance = false
+
+        assertThat(inspector.isActive).isTrue()
+        assertThat(inspector.isAnimating).isFalse()
+
+        // Update the value, WITH triggering an animation
+        input.floatValue = 1.5f
+        rule.awaitIdle()
+
+        // Still on the old frame..
+        assertThat(framesCount).isEqualTo(0)
+        // ... [underTest] is now waiting for an animation frame
+        assertThat(inspector.isAnimating).isTrue()
+
+        // A couple frames should be generated without pausing
+        repeat(5) {
+            rule.mainClock.advanceTimeByFrame()
+            rule.awaitIdle()
+
+            // The spring is still settling...
+            assertThat(inspector.frame.isStable).isFalse()
+            // ... animation keeps going ...
+            assertThat(inspector.isAnimating).isTrue()
+            // ... and frames are produces...
+            assertThat(framesCount).isEqualTo(it + 1)
+        }
+
+        // But this will stop as soon as the animation is finished. Skip forward.
+        rule.mainClock.autoAdvance = true
+        rule.awaitIdle()
+
+        // At which point the spring is stable again...
+        assertThat(inspector.frame.isStable).isTrue()
+        // ... and animations are suspended again.
+        assertThat(inspector.isAnimating).isFalse()
+        // Without too many assumptions about how long it took to settle the spring, should be
+        // more than  160ms
+        assertThat(framesCount).isGreaterThan(10)
+    }
+
+    @Test
+    fun debugInspector_sameInstance_whileInUse() {
+        val underTest = MotionValue({ 1f }, FakeGestureContext)
+
+        val originalInspector = underTest.debugInspector()
+        assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector)
+    }
+
+    @Test
+    fun debugInspector_newInstance_afterUnused() {
+        val underTest = MotionValue({ 1f }, FakeGestureContext)
+
+        val originalInspector = underTest.debugInspector()
+        originalInspector.dispose()
+        assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector)
+    }
+
+    @OptIn(ExperimentalTestApi::class)
+    private fun runTestWithFrameClock(
+        testBody:
+            suspend CoroutineScope.(
+                testScheduler: TestCoroutineScheduler, backgroundScope: CoroutineScope,
+            ) -> Unit
+    ) = runTest {
+        val testScope: TestScope = this
+        withContext(TestMonotonicFrameClock(testScope, FrameDelayNanos)) {
+            testBody(testScope.testScheduler, testScope.backgroundScope)
+        }
+    }
+
+    companion object {
+        val B1 = BreakpointKey("breakpoint1")
+        val B2 = BreakpointKey("breakpoint2")
+        val FakeGestureContext =
+            object : GestureContext {
+                override val direction: InputDirection
+                    get() = InputDirection.Max
+
+                override val distance: Float
+                    get() = 0f
+            }
+        private val FrameDelayNanos: Long = 16_000_000L
+
+        fun specBuilder(firstSegment: Mapping = Mapping.Identity) =
+            MotionSpec.builder(
+                defaultSpring = matStandardDefault,
+                resetSpring = matStandardFast,
+                initialMapping = firstSegment,
+            )
+
+        fun forwardSpecBuilder(firstSegment: Mapping = Mapping.Identity) =
+            DirectionalMotionSpec.builder(
+                defaultSpring = matStandardDefault,
+                initialMapping = firstSegment,
+            )
+
+        fun reverseSpecBuilder(firstSegment: Mapping = Mapping.Identity) =
+            DirectionalMotionSpec.reverseBuilder(
+                defaultSpring = matStandardDefault,
+                initialMapping = firstSegment,
+            )
+    }
+}
diff --git a/mechanics/tests/src/com/android/mechanics/testing/DefaultSprings.kt b/mechanics/tests/src/com/android/mechanics/testing/DefaultSprings.kt
new file mode 100644
index 0000000..3d43d34
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/testing/DefaultSprings.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics.testing
+
+import com.android.mechanics.spring.SpringParameters
+
+object DefaultSprings {
+    val matStandardDefault =
+        SpringParameters(
+            stiffness = StandardMotionTokens.SpringDefaultSpatialStiffness,
+            dampingRatio = StandardMotionTokens.SpringDefaultSpatialDamping,
+        )
+    val matStandardFast =
+        SpringParameters(
+            stiffness = StandardMotionTokens.SpringFastSpatialStiffness,
+            dampingRatio = StandardMotionTokens.SpringFastSpatialDamping,
+        )
+    val matExpressiveDefault =
+        SpringParameters(
+            stiffness = ExpressiveMotionTokens.SpringDefaultSpatialStiffness,
+            dampingRatio = ExpressiveMotionTokens.SpringDefaultSpatialDamping,
+        )
+    val matExpressiveFast =
+        SpringParameters(
+            stiffness = ExpressiveMotionTokens.SpringFastSpatialStiffness,
+            dampingRatio = ExpressiveMotionTokens.SpringFastSpatialDamping,
+        )
+
+    internal object StandardMotionTokens {
+        val SpringDefaultSpatialDamping = 0.9f
+        val SpringDefaultSpatialStiffness = 700.0f
+        val SpringDefaultEffectsDamping = 1.0f
+        val SpringDefaultEffectsStiffness = 1600.0f
+        val SpringFastSpatialDamping = 0.9f
+        val SpringFastSpatialStiffness = 1400.0f
+        val SpringFastEffectsDamping = 1.0f
+        val SpringFastEffectsStiffness = 3800.0f
+        val SpringSlowSpatialDamping = 0.9f
+        val SpringSlowSpatialStiffness = 300.0f
+        val SpringSlowEffectsDamping = 1.0f
+        val SpringSlowEffectsStiffness = 800.0f
+    }
+
+    internal object ExpressiveMotionTokens {
+        val SpringDefaultSpatialDamping = 0.8f
+        val SpringDefaultSpatialStiffness = 380.0f
+        val SpringDefaultEffectsDamping = 1.0f
+        val SpringDefaultEffectsStiffness = 1600.0f
+        val SpringFastSpatialDamping = 0.6f
+        val SpringFastSpatialStiffness = 800.0f
+        val SpringFastEffectsDamping = 1.0f
+        val SpringFastEffectsStiffness = 3800.0f
+        val SpringSlowSpatialDamping = 0.8f
+        val SpringSlowSpatialStiffness = 200.0f
+        val SpringSlowEffectsDamping = 1.0f
+        val SpringSlowEffectsStiffness = 800.0f
+    }
+}
diff --git a/mechanics/tests/src/com/android/mechanics/testing/MotionValueToolkit.kt b/mechanics/tests/src/com/android/mechanics/testing/MotionValueToolkit.kt
new file mode 100644
index 0000000..edf87c2
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/testing/MotionValueToolkit.kt
@@ -0,0 +1,250 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
+
+package com.android.mechanics.testing
+
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import com.android.mechanics.DistanceGestureContext
+import com.android.mechanics.MotionValue
+import com.android.mechanics.debug.FrameData
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.MotionSpec
+import kotlin.math.abs
+import kotlin.math.floor
+import kotlin.math.sign
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import platform.test.motion.MotionTestRule
+import platform.test.motion.RecordedMotion.Companion.create
+import platform.test.motion.golden.Feature
+import platform.test.motion.golden.FrameId
+import platform.test.motion.golden.TimeSeries
+import platform.test.motion.golden.TimestampFrameId
+import platform.test.motion.golden.ValueDataPoint
+import platform.test.motion.golden.asDataPoint
+
+/** Toolkit to support [MotionValue] motion tests. */
+class MotionValueToolkit(val composeTestRule: ComposeContentTestRule) {
+    companion object {
+
+        val TimeSeries.input: List<Float>
+            get() = dataPoints("input")
+
+        val TimeSeries.output: List<Float>
+            get() = dataPoints("output")
+
+        val TimeSeries.outputTarget: List<Float>
+            get() = dataPoints("outputTarget")
+
+        val TimeSeries.isStable: List<Boolean>
+            get() = dataPoints("isStable")
+
+        internal const val TAG = "MotionValueToolkit"
+
+        private fun <T> TimeSeries.dataPoints(featureName: String): List<T> {
+            @Suppress("UNCHECKED_CAST")
+            return (features[featureName] as Feature<T>).dataPoints.map {
+                require(it is ValueDataPoint)
+                it.value
+            }
+        }
+    }
+}
+
+interface InputScope {
+    val input: Float
+    val gestureContext: DistanceGestureContext
+    val underTest: MotionValue
+
+    suspend fun awaitStable()
+
+    suspend fun awaitFrames(frames: Int = 1)
+
+    var directionChangeSlop: Float
+
+    fun updateValue(position: Float)
+
+    suspend fun animateValueTo(
+        targetValue: Float,
+        changePerFrame: Float = abs(input - targetValue) / 5f,
+    )
+
+    fun reset(position: Float, direction: InputDirection)
+}
+
+fun MotionTestRule<MotionValueToolkit>.goldenTest(
+    spec: MotionSpec,
+    initialValue: Float = 0f,
+    initialDirection: InputDirection = InputDirection.Max,
+    directionChangeSlop: Float = 5f,
+    stableThreshold: Float = 0.01f,
+    verifyTimeSeries: TimeSeries.() -> Unit = {},
+    testInput: suspend InputScope.() -> Unit,
+) = runTest {
+    with(toolkit.composeTestRule) {
+        val frameEmitter = MutableStateFlow<Long>(0)
+
+        val testHarness =
+            MotionValueTestHarness(
+                initialValue,
+                initialDirection,
+                spec,
+                stableThreshold,
+                directionChangeSlop,
+                frameEmitter.asStateFlow(),
+            )
+        val underTest = testHarness.underTest
+        val inspector = underTest.debugInspector()
+
+        setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }
+
+        val recordingJob = launch { testInput.invoke(testHarness) }
+
+        waitForIdle()
+        mainClock.autoAdvance = false
+
+        val frameIds = mutableListOf<FrameId>()
+        val frameData = mutableListOf<FrameData>()
+
+        fun recordFrame(frameId: TimestampFrameId) {
+            frameIds.add(frameId)
+            frameData.add(inspector.frame)
+        }
+
+        val startFrameTime = mainClock.currentTime
+        recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
+        while (!recordingJob.isCompleted) {
+            frameEmitter.tryEmit(mainClock.currentTime + 16)
+            runCurrent()
+            mainClock.advanceTimeByFrame()
+            recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
+        }
+
+        val timeSeries =
+            TimeSeries(
+                frameIds.toList(),
+                listOf(
+                    Feature("input", frameData.map { it.input.asDataPoint() }),
+                    Feature(
+                        "gestureDirection",
+                        frameData.map { it.gestureDirection.name.asDataPoint() },
+                    ),
+                    Feature("output", frameData.map { it.output.asDataPoint() }),
+                    Feature("outputTarget", frameData.map { it.outputTarget.asDataPoint() }),
+                    Feature("outputSpring", frameData.map { it.springParameters.asDataPoint() }),
+                    Feature("isStable", frameData.map { it.isStable.asDataPoint() }),
+                ),
+            )
+
+        inspector.dispose()
+
+        val recordedMotion = create(timeSeries, screenshots = null)
+        verifyTimeSeries.invoke(recordedMotion.timeSeries)
+        assertThat(recordedMotion).timeSeriesMatchesGolden()
+    }
+}
+
+private class MotionValueTestHarness(
+    initialInput: Float,
+    initialDirection: InputDirection,
+    spec: MotionSpec,
+    stableThreshold: Float,
+    directionChangeSlop: Float,
+    val onFrame: StateFlow<Long>,
+) : InputScope {
+
+    override var input by mutableFloatStateOf(initialInput)
+    override val gestureContext: DistanceGestureContext =
+        DistanceGestureContext(initialInput, initialDirection, directionChangeSlop)
+
+    override val underTest =
+        MotionValue(
+            { input },
+            gestureContext,
+            stableThreshold = stableThreshold,
+            initialSpec = spec,
+        )
+
+    override fun updateValue(position: Float) {
+        input = position
+        gestureContext.distance = position
+    }
+
+    override var directionChangeSlop: Float
+        get() = gestureContext.directionChangeSlop
+        set(value) {
+            gestureContext.directionChangeSlop = value
+        }
+
+    override suspend fun awaitStable() {
+        val debugInspector = underTest.debugInspector()
+        try {
+
+            onFrame
+                // Since this is a state-flow, the current frame is counted too.
+                .drop(1)
+                .takeWhile { !debugInspector.frame.isStable }
+                .collect {}
+        } finally {
+            debugInspector.dispose()
+        }
+    }
+
+    override suspend fun awaitFrames(frames: Int) {
+        onFrame
+            // Since this is a state-flow, the current frame is counted too.
+            .drop(1)
+            .take(frames)
+            .collect {}
+    }
+
+    override suspend fun animateValueTo(targetValue: Float, changePerFrame: Float) {
+        require(changePerFrame > 0f)
+        var currentValue = input
+        val delta = targetValue - currentValue
+        val step = changePerFrame * delta.sign
+
+        val stepCount = floor((abs(delta) / changePerFrame) - 1).toInt()
+        repeat(stepCount) {
+            currentValue += step
+            updateValue(currentValue)
+            awaitFrames()
+        }
+
+        updateValue(targetValue)
+        awaitFrames()
+    }
+
+    override fun reset(position: Float, direction: InputDirection) {
+        input = position
+        gestureContext.reset(position, direction)
+    }
+}
diff --git a/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt b/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt
index 7555907..81979a2 100644
--- a/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt
+++ b/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt
@@ -308,12 +308,12 @@
                         HapticComposition(
                             listOf(
                                 HapticCompositionPrimitive(
-                                    VibrationEffect.Composition.PRIMITIVE_TICK,
-                                    scale = 0.7f,
+                                    VibrationEffect.Composition.PRIMITIVE_CLICK,
+                                    scale = 0.5f,
                                     delayMillis = 0,
                                 )
                             ),
-                            VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK),
+                            VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK),
                         )
                     },
                 HapticToken.KEYPRESS_SPACEBAR to
diff --git a/tracinglib/core/src/coroutines/TraceContextElement.kt b/tracinglib/core/src/coroutines/TraceContextElement.kt
index fce8123..bed08cd 100644
--- a/tracinglib/core/src/coroutines/TraceContextElement.kt
+++ b/tracinglib/core/src/coroutines/TraceContextElement.kt
@@ -28,8 +28,11 @@
 import kotlin.contracts.ExperimentalContracts
 import kotlin.contracts.InvocationKind
 import kotlin.contracts.contract
+import kotlin.coroutines.AbstractCoroutineContextKey
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.getPolymorphicElement
+import kotlin.coroutines.minusPolymorphicKey
 import kotlinx.coroutines.CopyableThreadContextElement
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.DelicateCoroutinesApi
@@ -51,13 +54,11 @@
  */
 @PublishedApi internal val traceThreadLocal: TraceDataThreadLocal = TraceDataThreadLocal()
 
-private val alwaysEnableStackWalker: Boolean by lazy {
+private val alwaysEnableStackWalker =
     SystemProperties.getBoolean("debug.coroutine_tracing.walk_stack_override", false)
-}
 
-private val alwaysEnableContinuationCounting: Boolean by lazy {
+private val alwaysEnableContinuationCounting =
     SystemProperties.getBoolean("debug.coroutine_tracing.count_continuations_override", false)
-}
 
 /**
  * Returns a new [TraceContextElement] (or [EmptyCoroutineContext] if `coroutine_tracing` feature is
@@ -96,40 +97,39 @@
  * @param walkStackForDefaultNames whether to walk the stack and use the class name of the current
  *   suspending function if child does not have a name that was manually specified. Walking the
  *   stack is very expensive so this should not be used in production.
- * @param countContinuations whether to include an extra trace section showing the total number of
- *   times a coroutine has suspended and resumed.
+ * @param countContinuations whether to include extra info in the trace section indicating the total
+ *   number of times a coroutine has suspended and resumed (e.g. ";n=#")
+ * @param countDepth whether to include extra info in the trace section indicating the how far from
+ *   the root trace context this coroutine is (e.g. ";d=#")
  * @param testMode changes behavior is several ways: 1) parent names and sibling counts are
  *   concatenated with the name of the child. This can result in extremely long trace names, which
  *   is why it is only for testing. 2) additional strict-mode checks are added to coroutine tracing
  *   machinery. These checks are expensive and should only be used for testing. 3) omits "coroutine
- *   execution" trace slices, and omits coroutine metadata slices
+ *   execution" trace slices, and omits coroutine metadata slices. If [testMode] is enabled,
+ *   [countContinuations] and [countDepth] are ignored.
  * @param shouldIgnoreClassName lambda that takes binary class name (as returned from
  *   [StackFrame.getClassName] and returns true if it should be ignored (e.g. search for relevant
  *   class name should continue) or false otherwise.
  */
 public fun createCoroutineTracingContext(
     name: String = "UnnamedScope",
-    walkStackForDefaultNames: Boolean = false,
     countContinuations: Boolean = false,
+    countDepth: Boolean = false,
     testMode: Boolean = false,
+    walkStackForDefaultNames: Boolean = false,
     shouldIgnoreClassName: (String) -> Boolean = { false },
 ): CoroutineContext {
     return if (Flags.coroutineTracing()) {
         TraceContextElement(
             name = name,
-            // Minor perf optimization: no need to create TraceData() for root scopes since all
-            // launches require creation of child via [copyForChild] or [mergeForChild].
-            contextTraceData = null,
-            config =
-                TraceConfig(
-                    walkStackForDefaultNames = walkStackForDefaultNames || alwaysEnableStackWalker,
-                    testMode = testMode,
-                    shouldIgnoreClassName = shouldIgnoreClassName,
-                    countContinuations = countContinuations || alwaysEnableContinuationCounting,
-                ),
+            isRoot = true,
+            countContinuations =
+                !testMode && (countContinuations || alwaysEnableContinuationCounting),
+            walkStackForDefaultNames = walkStackForDefaultNames || alwaysEnableStackWalker,
+            shouldIgnoreClassName = shouldIgnoreClassName,
             parentId = null,
-            inheritedTracePrefix = "",
-            coroutineDepth = 0,
+            inheritedTracePrefix = if (testMode) "" else null,
+            coroutineDepth = if (!testMode && countDepth) 0 else -1,
         )
     } else {
         EmptyCoroutineContext
@@ -163,7 +163,8 @@
 /**
  * Common base class of [TraceContextElement] and [CoroutineTraceName]. For internal use only.
  *
- * [TraceContextElement] should be installed on the root, and [CoroutineTraceName] on the children.
+ * [TraceContextElement] should be installed on the root, and [CoroutineTraceName] should ONLY be
+ * used when launching children to give them names.
  *
  * @property name the name of the current coroutine
  */
@@ -173,15 +174,15 @@
  * @property name the name to be used for the child under construction
  * @see nameCoroutine
  */
+@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
 @PublishedApi
-internal open class CoroutineTraceName(internal val name: String) : CoroutineContext.Element {
-    internal companion object Key : CoroutineContext.Key<CoroutineTraceName>
+internal open class CoroutineTraceName(internal val name: String) :
+    CopyableThreadContextElement<TraceData?>, CoroutineContext.Element {
+    companion object Key : CoroutineContext.Key<CoroutineTraceName>
 
-    public override val key: CoroutineContext.Key<*>
+    override val key: CoroutineContext.Key<*>
         get() = Key
 
-    protected val currentId: Int = ThreadLocalRandom.current().nextInt(1, Int.MAX_VALUE)
-
     @Deprecated(
         message =
             """
@@ -194,23 +195,40 @@
         level = DeprecationLevel.ERROR,
     )
     public operator fun plus(other: CoroutineTraceName): CoroutineTraceName {
-        debug { "#plus(${other.currentId})" }
+        debug { "CTN#plus;c=$name other=${other.name}" }
         return other
     }
 
-    @OptIn(ExperimentalContracts::class)
-    protected inline fun debug(message: () -> String) {
-        contract { callsInPlace(message, InvocationKind.AT_MOST_ONCE) }
-        if (DEBUG) Log.d(TAG, "${this::class.java.simpleName}@$currentId${message()}")
-    }
-}
+    @OptIn(ExperimentalStdlibApi::class)
+    override fun <E : CoroutineContext.Element> get(key: CoroutineContext.Key<E>): E? =
+        getPolymorphicElement(key)
 
-internal class TraceConfig(
-    val walkStackForDefaultNames: Boolean,
-    val testMode: Boolean,
-    val shouldIgnoreClassName: (String) -> Boolean,
-    val countContinuations: Boolean,
-)
+    @OptIn(ExperimentalStdlibApi::class)
+    override fun minusKey(key: CoroutineContext.Key<*>): CoroutineContext = minusPolymorphicKey(key)
+
+    override fun copyForChild(): CopyableThreadContextElement<TraceData?> {
+        // Reusing this object is okay because we aren't persisting to thread local store.
+        // This object only exists for the purpose of storing a String for future use
+        // by TraceContextElement.
+        return this
+    }
+
+    override fun mergeForChild(overwritingElement: CoroutineContext.Element): CoroutineContext {
+        return if (overwritingElement is TraceContextElement) {
+            overwritingElement.createChildContext(name)
+        } else if (overwritingElement is CoroutineTraceName) {
+            overwritingElement
+        } else {
+            EmptyCoroutineContext
+        }
+    }
+
+    override fun updateThreadContext(context: CoroutineContext): TraceData? {
+        return null
+    }
+
+    override fun restoreThreadContext(context: CoroutineContext, oldState: TraceData?) {}
+}
 
 /**
  * Used for tracking parent-child relationship of coroutines and persisting [TraceData] when
@@ -230,6 +248,7 @@
  *   would be used instead: `root-scope:3^`
  * @param coroutineDepth How deep the coroutine is relative to the top-level [CoroutineScope]
  *   containing the original [TraceContextElement] from which this [TraceContextElement] was copied.
+ *   If -1, counting depth is disabled
  * @see createCoroutineTracingContext
  * @see nameCoroutine
  * @see traceCoroutine
@@ -237,31 +256,52 @@
 @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
 internal class TraceContextElement(
     name: String,
-    internal val contextTraceData: TraceData?,
-    private val config: TraceConfig,
+    private val isRoot: Boolean,
+    private val countContinuations: Boolean,
+    private val walkStackForDefaultNames: Boolean,
+    private val shouldIgnoreClassName: (String) -> Boolean,
     parentId: Int?,
-    inheritedTracePrefix: String,
+    inheritedTracePrefix: String?,
     coroutineDepth: Int,
-) : CopyableThreadContextElement<TraceData?>, CoroutineTraceName(name) {
+) : CoroutineTraceName(name) {
 
-    private val coroutineTraceName =
-        if (config.testMode) "$inheritedTracePrefix$name"
-        else "$name;d=$coroutineDepth;c=$currentId;p=${parentId ?: "none"}"
+    protected val currentId: Int = ThreadLocalRandom.current().nextInt(1, Int.MAX_VALUE)
+
+    @OptIn(ExperimentalStdlibApi::class)
+    companion object Key :
+        AbstractCoroutineContextKey<CoroutineTraceName, TraceContextElement>(
+            CoroutineTraceName,
+            { it as? TraceContextElement },
+        )
 
     init {
-        debug { "#init: name=$name" }
-        Trace.traceBegin(Trace.TRACE_TAG_APP, "TraceContextElement#init[$coroutineTraceName]")
+        val traceSection = "TCE#init name=$name"
+        debug { traceSection }
+        Trace.traceBegin(Trace.TRACE_TAG_APP, traceSection)
     }
 
-    private var continuationCount = 0
-    private val childDepth = coroutineDepth + 1
-    private var childCoroutineCount = if (config.testMode) AtomicInteger(0) else null
+    // Minor perf optimization: no need to create TraceData() for root scopes since all launches
+    // require creation of child via [copyForChild] or [mergeForChild].
+    internal val contextTraceData: TraceData? =
+        if (isRoot) null else TraceData(currentId, strictMode = inheritedTracePrefix != null)
 
-    private val copyForChildTraceMessage = "TraceContextElement#copyForChild[$coroutineTraceName]"
-    private val mergeForChildTraceMessage = "TraceContextElement#mergeForChild[$coroutineTraceName]"
+    private val coroutineTraceName =
+        if (inheritedTracePrefix == null) {
+            "coroutine execution;$name;c=$currentId;p=${parentId ?: "none"}${if (coroutineDepth == -1) "" else ";d=$coroutineDepth"}"
+        } else {
+            "$inheritedTracePrefix$name"
+        }
+
+    private var continuationCount = 0
+    private val childDepth =
+        if (inheritedTracePrefix != null || coroutineDepth == -1) -1 else coroutineDepth + 1
+    private val childCoroutineCount = if (inheritedTracePrefix != null) AtomicInteger(0) else null
+
+    private val copyForChildTraceMessage = "TCE#copy;c=$currentId]"
+    private val mergeForChildTraceMessage = "TCE#merge;c=$currentId]"
 
     init {
-        Trace.traceEnd(Trace.TRACE_TAG_APP)
+        Trace.traceEnd(Trace.TRACE_TAG_APP) // end: "TCE#init"
     }
 
     /**
@@ -282,16 +322,14 @@
     @SuppressLint("UnclosedTrace")
     public override fun updateThreadContext(context: CoroutineContext): TraceData? {
         val oldState = traceThreadLocal.get()
-        debug { "#updateThreadContext oldState=$oldState" }
+        debug { "TCE#update;c=$currentId oldState=${oldState?.currentId}" }
         if (oldState !== contextTraceData) {
-            if (!config.testMode) {
-                Trace.traceBegin(Trace.TRACE_TAG_APP, "coroutine execution")
-            }
-            Trace.traceBegin(Trace.TRACE_TAG_APP, coroutineTraceName)
-            if (config.countContinuations) {
-                Trace.traceBegin(Trace.TRACE_TAG_APP, "continuation: #${continuationCount++}")
-            }
             traceThreadLocal.set(contextTraceData)
+            Trace.traceBegin(
+                Trace.TRACE_TAG_APP,
+                if (countContinuations) "$coroutineTraceName;n=${continuationCount++}"
+                else coroutineTraceName,
+            )
             // Calls to `updateThreadContext` will not happen in parallel on the same context, and
             // they cannot happen before the prior suspension point. Additionally,
             // `restoreThreadContext` does not modify `traceData`, so it is safe to iterate over the
@@ -329,7 +367,7 @@
      * ```
      */
     public override fun restoreThreadContext(context: CoroutineContext, oldState: TraceData?) {
-        debug { "#restoreThreadContext restoring=$oldState" }
+        debug { "TCE#restore;c=$currentId restoring=${oldState?.currentId}" }
         // We not use the `TraceData` object here because it may have been modified on another
         // thread after the last suspension point. This is why we use a [TraceStateHolder]:
         // so we can end the correct number of trace sections, restoring the thread to its state
@@ -337,21 +375,15 @@
         if (oldState !== traceThreadLocal.get()) {
             contextTraceData?.endAllOnThread()
             traceThreadLocal.set(oldState)
-            if (!config.testMode) {
-                Trace.traceEnd(Trace.TRACE_TAG_APP) // end: "coroutine execution"
-            }
-            Trace.traceEnd(Trace.TRACE_TAG_APP) // end: contextMetadata
-            if (config.countContinuations) {
-                Trace.traceEnd(Trace.TRACE_TAG_APP) // end: continuation: #
-            }
+            Trace.traceEnd(Trace.TRACE_TAG_APP) // end: coroutineTraceName
         }
     }
 
     public override fun copyForChild(): CopyableThreadContextElement<TraceData?> {
-        debug { "#copyForChild" }
+        debug { copyForChildTraceMessage }
         try {
             Trace.traceBegin(Trace.TRACE_TAG_APP, copyForChildTraceMessage)
-            return createChildContext()
+            return createChildContext(null)
         } finally {
             Trace.traceEnd(Trace.TRACE_TAG_APP) // end: copyForChildTraceMessage
         }
@@ -360,16 +392,16 @@
     public override fun mergeForChild(
         overwritingElement: CoroutineContext.Element
     ): CoroutineContext {
-        debug { "#mergeForChild" }
-        if (DEBUG) {
+        debug {
             (overwritingElement as? TraceContextElement)?.let {
-                Log.e(
-                    TAG,
+                val msg =
                     "${this::class.java.simpleName}@$currentId#mergeForChild(@${it.currentId}): " +
                         "current name=\"$name\", overwritingElement name=\"${it.name}\". " +
-                        UNEXPECTED_TRACE_DATA_ERROR_MESSAGE,
-                )
+                        UNEXPECTED_TRACE_DATA_ERROR_MESSAGE
+                Trace.instant(Trace.TRACE_TAG_APP, msg)
+                Log.e(TAG, msg)
             }
+            return@debug mergeForChildTraceMessage
         }
         try {
             Trace.traceBegin(Trace.TRACE_TAG_APP, mergeForChildTraceMessage)
@@ -380,23 +412,25 @@
         }
     }
 
-    private fun createChildContext(name: String? = null): TraceContextElement {
-        val childName =
-            name
-                ?: if (config.walkStackForDefaultNames)
-                    walkStackForClassName(config.shouldIgnoreClassName)
-                else ""
-        debug { "#createChildContext: \"${this.name}\" has new child with name \"${childName}\"" }
+    internal fun createChildContext(childName: String?): TraceContextElement {
         return TraceContextElement(
-            name = childName,
-            contextTraceData = TraceData(strictMode = config.testMode),
-            config = config,
+            name =
+                childName
+                    ?: if (walkStackForDefaultNames) {
+                        walkStackForClassName(shouldIgnoreClassName)
+                    } else {
+                        ""
+                    },
+            isRoot = false,
+            countContinuations = countContinuations,
+            walkStackForDefaultNames = walkStackForDefaultNames,
+            shouldIgnoreClassName = shouldIgnoreClassName,
             parentId = currentId,
             inheritedTracePrefix =
-                if (config.testMode) {
-                    val childCount = childCoroutineCount?.incrementAndGet() ?: 0
+                if (childCoroutineCount != null) {
+                    val childCount = childCoroutineCount.incrementAndGet()
                     "$coroutineTraceName:$childCount^"
-                } else "",
+                } else null,
             coroutineDepth = childDepth,
         )
     }
@@ -439,3 +473,13 @@
 @PublishedApi internal const val TAG: String = "CoroutineTracing"
 
 @PublishedApi internal const val DEBUG: Boolean = false
+
+@OptIn(ExperimentalContracts::class)
+private inline fun debug(message: () -> String) {
+    contract { callsInPlace(message, InvocationKind.AT_MOST_ONCE) }
+    if (DEBUG) {
+        val msg = message()
+        Trace.instant(Trace.TRACE_TAG_APP, msg)
+        Log.d(TAG, msg)
+    }
+}
diff --git a/tracinglib/core/src/coroutines/TraceData.kt b/tracinglib/core/src/coroutines/TraceData.kt
index 49cea0d..d1a4da6 100644
--- a/tracinglib/core/src/coroutines/TraceData.kt
+++ b/tracinglib/core/src/coroutines/TraceData.kt
@@ -38,13 +38,14 @@
  * Used for storing trace sections so that they can be added and removed from the currently running
  * thread when the coroutine is suspended and resumed.
  *
+ * @property currentId ID of associated TraceContextElement
  * @property strictMode Whether to add additional checks to the coroutine machinery, throwing a
  *   `ConcurrentModificationException` if TraceData is modified from the wrong thread. This should
  *   only be set for testing.
  * @see traceCoroutine
  */
 @PublishedApi
-internal class TraceData(private val strictMode: Boolean) {
+internal class TraceData(internal val currentId: Int, private val strictMode: Boolean) {
 
     internal var slices: ArrayDeque<TraceSection>? = null