From d442c50e4e4b726af916a81ea63bdd67ef196ddb Mon Sep 17 00:00:00 2001 From: David Legg Date: Wed, 30 Oct 2024 00:35:49 -0700 Subject: [PATCH] WIP - Incon behaviors Adds the InconBehavior interface and some of the surrounding machinery to connect it. The thrust of this idea is to wire together incon reading, fincon writing, and cell creation, such that the easiest way for a modeler to create a new cell is to correctly wire up the incons. This means that wiring up those incons should be relatively painless, it should minimize duplicating necessary information, it should eliminate specifying any unnecessary information, and side-stepping the incons process should require rebuilding a large portion of the resource stack, as a strong discouragement to doing so. This commit does not compile. The status of various classes is: 1. I'm mostly happy with InconBehavior itself. It's a neatly packaged bit of behavior, it composes well (see InconBehavior.map), and I think it's legible as-is. 2. I may want to remove InconBehaviors static constructors "constant" and "serialized". I think those belong in MutableResource, as part of the "notSaving" and "serializing" helper methods instead. 3. I'm not happy with MutableResource yet, as we're now duplicating information for the incon behavior (name and ValueMapper) that we'll give later if/when we register it. I'm wondering if we should roll registration into this method as well, perhaps with a flag or alternate constructor to not register a resource... 4. Everywhere we build MutableResources needs to be updated, pending what we decide to do for the step above. Additionally, we'll need to document how this changes the MutableResource interface and include that in the migration notes should this be adopted by Aerie. --- .../contrib/streamline/core/CellRefV2.java | 73 ++++++++------- .../streamline/core/InconBehavior.java | 62 +++++++++++++ .../core/InitialConditionManager.java | 76 +++++++++++++++ .../streamline/core/MutableResource.java | 93 ++++++++++++++++--- .../streamline/utils/InvertibleFunction.java | 39 ++++++++ 5 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InconBehavior.java create mode 100644 contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InitialConditionManager.java create mode 100644 contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/utils/InvertibleFunction.java diff --git a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2.java b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2.java index 2324dcf00b..8dad366293 100644 --- a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2.java +++ b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2.java @@ -1,6 +1,7 @@ package gov.nasa.jpl.aerie.contrib.streamline.core; import gov.nasa.jpl.aerie.contrib.streamline.core.monads.ErrorCatchingMonad; +import gov.nasa.jpl.aerie.contrib.streamline.utils.InvertibleFunction; import gov.nasa.jpl.aerie.merlin.framework.CellRef; import gov.nasa.jpl.aerie.merlin.protocol.model.CellType; import gov.nasa.jpl.aerie.merlin.protocol.model.EffectTrait; @@ -23,37 +24,47 @@ private CellRefV2() {} /** * Allocate a new resource with an explicitly given effect type and effect trait. */ - public static , E extends DynamicsEffect> CellRef> allocate(ErrorCatching> initialDynamics, EffectTrait effectTrait) { - return CellRef.allocate(new Cell<>(initialDynamics), new CellType<>() { - @Override - public EffectTrait getEffectType() { - return effectTrait; - } - - @Override - public Cell duplicate(Cell cell) { - return new Cell<>(cell.initialDynamics, cell.dynamics, cell.elapsedTime); - } - - @Override - public void apply(Cell cell, E effect) { - cell.initialDynamics = effect.apply(cell.dynamics).match( - ErrorCatching::success, - error -> failure(new RuntimeException( - "Applying effect '%s' failed.".formatted(getName(effect, null)), error))); - cell.dynamics = cell.initialDynamics; - cell.elapsedTime = ZERO; - } - - @Override - public void step(Cell cell, Duration duration) { - // Avoid accumulated round-off error in imperfect stepping - // by always stepping up from the initial dynamics - cell.elapsedTime = cell.elapsedTime.plus(duration); - cell.dynamics = ErrorCatchingMonad.map(cell.initialDynamics, d -> - expiring(d.data().step(cell.elapsedTime), d.expiry().minus(cell.elapsedTime))); - } - }); + public static , E extends DynamicsEffect> CellRef> allocate( + InconBehavior>> inconBehavior, + EffectTrait effectTrait) { + // Building the invertible function below ties together two key functions as de-facto inverses: + // 1. Reading the incon and using it to initialize the cell. + // 2. Reading the cell and using it to write the fincon. + // Registering that mapped incon behavior here then ensures that we always register the incon behavior, + // without the modeler needing to manage incons as a separate step, which they may forget / mismanage. + return InitialConditionManager.register(inconBehavior.map(InvertibleFunction.of( + initialDynamics -> CellRef.allocate(new Cell<>(initialDynamics), new CellType<>() { + @Override + public EffectTrait getEffectType() { + return effectTrait; + } + + @Override + public Cell duplicate(Cell cell) { + return new Cell<>(cell.initialDynamics, cell.dynamics, cell.elapsedTime); + } + + @Override + public void apply(Cell cell, E effect) { + cell.initialDynamics = effect.apply(cell.dynamics).match( + ErrorCatching::success, + error -> failure(new RuntimeException( + "Applying effect '%s' failed.".formatted(getName(effect, null)), error))); + cell.dynamics = cell.initialDynamics; + cell.elapsedTime = ZERO; + } + + @Override + public void step(Cell cell, Duration duration) { + // Avoid accumulated round-off error in imperfect stepping + // by always stepping up from the initial dynamics + cell.elapsedTime = cell.elapsedTime.plus(duration); + cell.dynamics = ErrorCatchingMonad.map(cell.initialDynamics, d -> + expiring(d.data().step(cell.elapsedTime), d.expiry().minus(cell.elapsedTime))); + } + }), + cellRef -> cellRef.get().dynamics + ))); } public static > EffectTrait> noncommutingEffects() { diff --git a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InconBehavior.java b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InconBehavior.java new file mode 100644 index 0000000000..f5ed082b07 --- /dev/null +++ b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InconBehavior.java @@ -0,0 +1,62 @@ +package gov.nasa.jpl.aerie.contrib.streamline.core; + +import gov.nasa.jpl.aerie.contrib.streamline.utils.InvertibleFunction; +import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; + +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Combines the operations of getting initial state and saving final state, + * since these operations are intentionally closely coupled. + */ +public interface InconBehavior { + T getIncon(InitialConditionManager.InitialConditions initialConditions); + + void writeFincon(T state, InitialConditionManager.FinalConditions finalConditions); + + static InconBehavior of(Function getIncon, BiConsumer writeFincon) { + return new InconBehavior() { + @Override + public T getIncon(InitialConditionManager.InitialConditions initialConditions) { + return getIncon.apply(initialConditions); + } + + @Override + public void writeFincon(T state, InitialConditionManager.FinalConditions finalConditions) { + writeFincon.accept(state, finalConditions); + } + }; + } + + /** + * The null case of incon behavior, when in fact the state does not get saved out. + */ + static InconBehavior constant(T value) { + return InconBehavior.of($ -> value, (s, f) -> {}); + } + + /** + * The standard case of incon behavior, where the state is serialized out under a single key. + */ + static InconBehavior serialized(String key, Function, T> constructor, Function serializer) { + return InconBehavior.of( + incons -> constructor.apply(incons.get(key)), + (state, fincons) -> fincons.put(key, serializer.apply(state))); + } + + /** + * Extend this {@link InconBehavior} with an invertible function. + *

+ * In particular, this can be used to apply {@link Resource} wrappers around the initial state. + *

+ */ + default InconBehavior map(InvertibleFunction f) { + return InconBehavior.of( + f.compose(this::getIncon), + (state, fincons) -> this.writeFincon(f.inverse().apply(state), fincons)); + } +} diff --git a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InitialConditionManager.java b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InitialConditionManager.java new file mode 100644 index 0000000000..8ee10a23a3 --- /dev/null +++ b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/InitialConditionManager.java @@ -0,0 +1,76 @@ +package gov.nasa.jpl.aerie.contrib.streamline.core; + +import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; + +import java.util.*; +import java.util.function.Consumer; + +public final class InitialConditionManager { + private InitialConditionManager() {} + + private static boolean initialized = false; + private static InitialConditions initialConditions; + private static Consumer> finconHandler; + private static List> finconHooks; + + public static void init(InitialConditions initialConditions, Consumer> finconHandler) { + if (initialized) { + throw new IllegalStateException("InitialConditionManager has already been initialized"); + } + + InitialConditionManager.initialConditions = initialConditions; + InitialConditionManager.finconHandler = finconHandler; + InitialConditionManager.finconHooks = new ArrayList<>(); + initialized = true; + } + + // The "correct" way to get an initial value also registers a way to write the final value. + // This is intended to remind the modeler that these operations are closely coupled. + public static T register(InconBehavior behavior) { + if (!initialized) { + throw new IllegalStateException("InitialConditionManager has not been initialized"); + } + + var result = behavior.getIncon(initialConditions); + // This looks admittedly strange, but the intent is for result to be something like a resource, + // which is a stable handle for a state that changes over the course of the simulation. + // This closure captures that stable handle, with the intent of querying it later to capture the final state. + finconHooks.add($ -> behavior.writeFincon(result, $)); + return result; + } + + public static void writeFincon() { + var transparentFincons = new HashMap(); + // opaqueFincons is a write-only view of transparentFincons + var opaqueFincons = FinalConditions.of(transparentFincons); + // Each fincon hook appends its portion of the fincons + for (var finconHook : finconHooks) { + finconHook.accept(opaqueFincons); + } + // Finally we write the full object out + finconHandler.accept(transparentFincons); + } + + + public interface InitialConditions { + Optional get(String key); + + static InitialConditions of(Map map) { + return key -> Optional.ofNullable(map.get(key)); + } + } + + public interface FinalConditions { + void put(String key, SerializedValue value); + + static FinalConditions of(Map map) { + return (key, value) -> { + if (map.putIfAbsent(key, value) != null) { + throw new IllegalStateException(String.format( + "Final condition has already been written for %s", key)); + } + }; + } + } + +} diff --git a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/MutableResource.java b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/MutableResource.java index 7692a5ee2e..a7a0c4f91b 100644 --- a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/MutableResource.java +++ b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/core/MutableResource.java @@ -6,10 +6,19 @@ import gov.nasa.jpl.aerie.contrib.streamline.debugging.Profiling; import gov.nasa.jpl.aerie.merlin.framework.CellRef; import gov.nasa.jpl.aerie.contrib.streamline.core.CellRefV2.Cell; +import gov.nasa.jpl.aerie.merlin.framework.Result; +import gov.nasa.jpl.aerie.merlin.framework.ValueMapper; import gov.nasa.jpl.aerie.merlin.protocol.model.EffectTrait; +import gov.nasa.jpl.aerie.merlin.protocol.types.SerializedValue; +import gov.nasa.jpl.aerie.merlin.protocol.types.ValueSchema; +import java.util.Map; +import java.util.Optional; + +import static gov.nasa.jpl.aerie.contrib.serialization.rulesets.BasicValueMappers.duration; import static gov.nasa.jpl.aerie.contrib.streamline.core.CellRefV2.allocate; import static gov.nasa.jpl.aerie.contrib.streamline.core.CellRefV2.autoEffects; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Expiry.expiry; import static gov.nasa.jpl.aerie.contrib.streamline.core.monads.DynamicsMonad.pure; import static gov.nasa.jpl.aerie.contrib.streamline.debugging.Naming.*; import static gov.nasa.jpl.aerie.contrib.streamline.debugging.Profiling.profile; @@ -25,24 +34,18 @@ default void emit(String effectName, DynamicsEffect effect) { emit(name(effect, effectName)); } - static > MutableResource resource(D initial) { - return resource(pure(initial)); - } - - static > MutableResource resource(D initial, EffectTrait> effectTrait) { - return resource(pure(initial), effectTrait); - } - - static > MutableResource resource(ErrorCatching> initial) { + static > MutableResource resource(InconBehavior>> inconBehavior) { // Use autoEffects for a generic CellResource, on the theory that most resources // have relatively few effects, and even fewer concurrent effects, so this is performant enough. // If that doesn't hold, a more specialized solution can be constructed directly. - return resource(initial, autoEffects()); + return resource(inconBehavior, autoEffects()); } - static > MutableResource resource(ErrorCatching> initial, EffectTrait> effectTrait) { + static > MutableResource resource( + InconBehavior>> inconBehavior, + EffectTrait> effectTrait) { MutableResource result = new MutableResource<>() { - private final CellRef, Cell> cell = allocate(initial, effectTrait); + private final CellRef, Cell> cell = allocate(inconBehavior, effectTrait); @Override public void emit(final DynamicsEffect effect) { @@ -68,6 +71,72 @@ public ErrorCatching> getDynamics() { return result; } + static InconBehavior>> notSaving(D initialValue) { + return notSaving(pure(initialValue)); + } + + static InconBehavior>> notSaving(ErrorCatching> initialValue) { + return InconBehavior.constant(initialValue); + } + + // TODO - It would be nice if the name we set here could somehow auto-populate the name of the resource, + // and also be the name we register the resource as. Same for the value mapper, it would be nice to just use that for registration too. + // Alternatively, we could demand a name for every MutableResource, and combine that with the other info here later...? + static InconBehavior>> serializing(String key, D defaultValue, ValueMapper mapper) { + return serializing(key, pure(defaultValue), standardDynamicsMapper(mapper)); + } + + private static ValueMapper>> standardDynamicsMapper(ValueMapper baseMapper) { + return new ValueMapper<>() { + @Override + public ValueSchema getValueSchema() { + // Note: Both errorMessage and expiry are nullable. + return ValueSchema.ofStruct(Map.of( + "error", ValueSchema.STRING, + "expiry", ValueSchema.DURATION, + "dynamics", baseMapper.getValueSchema())); + } + + @Override + public SerializedValue serializeValue(ErrorCatching> value) { + return value.match( + success -> SerializedValue.of(Map.of( + "expiry", success.expiry().value().map(duration()::serializeValue).orElse(SerializedValue.NULL), + "dynamics", baseMapper.serializeValue(success.data()) + )), + error -> SerializedValue.of(Map.of( + "error", SerializedValue.of(error.getMessage()) + )) + ); + } + + @Override + public Result>, String> deserializeValue(SerializedValue serializedValue) { + try { + var map = serializedValue.asMap().orElseThrow(); + if (map.containsKey("error")) { + return Result.success(ErrorCatching.failure(new Exception(map.get("error").asString().orElseThrow()))); + } else { + var expiry = expiry(Optional.ofNullable(map.get("expiry")) + .map($ -> duration().deserializeValue($).getSuccessOrThrow())); + var dynamics = baseMapper.deserializeValue(map.get("dynamics")).getSuccessOrThrow(); + return Result.success(ErrorCatching.success(Expiring.expiring(dynamics, expiry))); + } + } catch (Throwable e) { + // TODO - we need *way* better error reporting here, but I just can't be bothered tonight. + return Result.failure("Failed to deserialize value as a standard wrapped dynamics object."); + } + } + }; + } + + static InconBehavior>> serializing(String key, ErrorCatching> defaultValue, ValueMapper>> mapper) { + return InconBehavior.serialized( + key, + serializedValue -> serializedValue.map($ -> mapper.deserializeValue($).getSuccessOrThrow()).orElse(defaultValue), + mapper::serializeValue); + } + static > void set(MutableResource resource, D newDynamics) { resource.emit(name(DynamicsMonad.effect(x -> newDynamics), "Set %s", newDynamics)); } diff --git a/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/utils/InvertibleFunction.java b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/utils/InvertibleFunction.java new file mode 100644 index 0000000000..c481d137de --- /dev/null +++ b/contrib/src/main/java/gov/nasa/jpl/aerie/contrib/streamline/utils/InvertibleFunction.java @@ -0,0 +1,39 @@ +package gov.nasa.jpl.aerie.contrib.streamline.utils; + +import java.util.function.Function; + +public interface InvertibleFunction extends Function { + InvertibleFunction inverse(); + + default InvertibleFunction compose(InvertibleFunction before) { + return new InvertibleFunction<>() { + @Override + public InvertibleFunction inverse() { + return before.inverse().compose(InvertibleFunction.this.inverse()); + } + + @Override + public B apply(C c) { + return InvertibleFunction.this.apply(before.apply(c)); + } + }; + } + + default InvertibleFunction andThen(InvertibleFunction after) { + return after.compose(this); + } + + static InvertibleFunction of(Function map, Function inverse) { + return new InvertibleFunction<>() { + @Override + public B apply(A a) { + return map.apply(a); + } + + @Override + public InvertibleFunction inverse() { + return InvertibleFunction.of(inverse, map); + } + }; + } +}