diff --git a/rune-lang/src/main/java/com/regnosys/rosetta/experimental/ExperimentalFeature.java b/rune-lang/src/main/java/com/regnosys/rosetta/experimental/ExperimentalFeature.java
index d9e7aa6cfd..15da03b65a 100644
--- a/rune-lang/src/main/java/com/regnosys/rosetta/experimental/ExperimentalFeature.java
+++ b/rune-lang/src/main/java/com/regnosys/rosetta/experimental/ExperimentalFeature.java
@@ -7,7 +7,7 @@ public enum ExperimentalFeature {
private final String featureName;
- private ExperimentalFeature(String featureName) {
+ ExperimentalFeature(String featureName) {
this.featureName = featureName;
}
diff --git a/rune-runtime/pom.xml b/rune-runtime/pom.xml
index 448f3f9cce..89fa276e8d 100644
--- a/rune-runtime/pom.xml
+++ b/rune-runtime/pom.xml
@@ -113,6 +113,33 @@
+
+ * Subclasses should override {@link #configure()} and call {@link #addOverride(Class, Class)} + * to define the class overrides for this scope. + *
+ * Example: + *
+ * public class MyScope extends AbstractFunctionScope {
+ * protected void configure() {
+ * addOverride(BaseClass.class, OverrideClass.class);
+ * }
+ * }
+ *
+ */
+public abstract class AbstractFunctionScope implements FunctionScope {
+ private final Map
+ * Subclasses must implement this method to define their overrides using {@link #addOverride(Class, Class)}.
+ */
+ protected abstract void configure();
+
+ /**
+ * Adds a class override to this scope.
+ *
+ * @param clazz the original class
+ * @param override the override class (must be a subclass of the original class)
+ * @param
+ * This provider can be injected into classes to enable scope-aware dependency resolution.
+ * Instead of receiving a fixed instance at injection time, the instance is resolved dynamically
+ * when {@link #get()} is called, taking into account any active scopes on the current thread.
+ *
+ * Example usage:
+ *
+ * Use this method to capture the current scope stack before spawning async tasks,
+ * then call {@link #setStateOfCurrentThread(FunctionContextState)} in the new thread.
+ *
+ * @return a copy of the current thread's scope state
+ */
+ FunctionContextState copyStateOfCurrentThread();
+
+ /**
+ * Sets the current thread's scope state, typically after receiving it from another thread.
+ *
+ * Use this method in a new thread to restore scope state that was captured
+ * via {@link #copyStateOfCurrentThread()} in a parent thread.
+ *
+ * @param state the scope state to set for the current thread
+ */
+ void setStateOfCurrentThread(FunctionContextState state);
+}
diff --git a/rune-runtime/src/main/java/com/rosetta/model/lib/context/FunctionContextImpl.java b/rune-runtime/src/main/java/com/rosetta/model/lib/context/FunctionContextImpl.java
new file mode 100644
index 0000000000..5f736d5aba
--- /dev/null
+++ b/rune-runtime/src/main/java/com/rosetta/model/lib/context/FunctionContextImpl.java
@@ -0,0 +1,76 @@
+package com.rosetta.model.lib.context;
+
+import com.google.inject.Injector;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.function.Supplier;
+
+/**
+ * Implementation of {@link FunctionContext} that maintains a stack of scopes with cached resolved overrides.
+ *
+ * This implementation optimizes {@link #getInstance(Class)} to O(1) time complexity (with respect to the depth of the scope stack)
+ * by maintaining a cache of resolved class overrides at each scope level. When a scope is entered, the cache is computed
+ * by applying that scope's overrides to the parent scope's cache. When a scope is exited, the parent's
+ * cache is automatically restored by popping the stack.
+ */
+@Singleton
+public class FunctionContextImpl implements FunctionContext {
+ private final ThreadLocal
+ * This class is separated from {@link FunctionContext} to enable explicit propagation of context
+ * state across thread boundaries. Instances can be copied via {@link #copy()} and transferred
+ * to other threads, allowing async tasks to inherit the parent thread's context.
+ *
+ * Maintains a scope stack with cached resolved overrides for O(1) lookup performance.
+ */
+public class FunctionContextState {
+ private static class ScopeFrame {
+ final FunctionScope scope;
+ final Map
+ * The scope stack is copied, so modifications in one thread won't affect the other.
+ *
+ * @return a copy of this state
+ */
+ public FunctionContextState copy() {
+ return new FunctionContextState(this);
+ }
+
+ @SuppressWarnings("unchecked")
+ public
+ * Implementations of this interface specify which classes should be replaced
+ * with alternative implementations within a particular execution context.
+ */
+public interface FunctionScope {
+ /**
+ * Returns the override class for the given class, or the class itself if no override exists.
+ *
+ * @param clazz the class to check for an override
+ * @param
+ * These tests automatically run with JIT disabled (-Xint) via separate Surefire execution
+ * to measure realistic first-run performance. They run automatically during {@code mvn test}
+ * or {@code mvn clean install}.
+ */
+@Tag("performance")
+public class FunctionContextStatePerformanceTest {
+ @Inject
+ private ScopeA scopeA;
+ @Inject
+ private ScopeB scopeB;
+
+ @BeforeEach
+ void setup() {
+ Injector injector = Guice.createInjector();
+ injector.injectMembers(this);
+ }
+
+ @Test
+ void testPerformanceForADeepScopeStack() {
+ // Test that deep scopes don't degrade performance significantly
+ // This proves O(1) vs O(n) by comparing shallow vs deep stack performance
+ // Tests first-run performance (realistic usage without JIT warmup)
+
+ final int ITERATIONS = 10000;
+ final int SHALLOW_DEPTH = 10;
+ final int DEEP_DEPTH = 100;
+
+ // Shallow stack
+ FunctionContextState shallowContextState = createStateWithDepth(SHALLOW_DEPTH);
+
+ // Deep stack
+ FunctionContextState deepContextState = createStateWithDepth(DEEP_DEPTH);
+
+ // Measure first-run performance (no JIT warmup - simulates real usage)
+ long shallowMs = runBenchmark(shallowContextState, ITERATIONS);
+ long deepMs = runBenchmark(deepContextState, ITERATIONS);
+
+ System.out.println("Performance test (first-run, no JIT warmup):");
+ System.out.println(" Shallow (depth " + SHALLOW_DEPTH + "): " + shallowMs + "ms for " + ITERATIONS + " lookups");
+ System.out.println(" Deep (depth " + DEEP_DEPTH + "): " + deepMs + "ms for " + ITERATIONS + " lookups");
+ double ratio = (double)deepMs / shallowMs;
+ System.out.println(" Ratio: " + String.format("%.2f", ratio) + "x");
+
+ // With O(1), ratio should be close to 1.0 even without JIT
+ // With O(n), ratio would be ~10x (DEEP_DEPTH / SHALLOW_DEPTH)
+ // Allow up to 3x difference for noise, cache effects, and first-run variance
+ assert ratio < 3.0 : "Deep context is " + String.format("%.2f", ratio)
+ + "x slower than shallow - suggests O(n) behavior";
+ }
+
+ private FunctionContextState createStateWithDepth(int depth) {
+ FunctionContextState state = FunctionContextState.empty();
+ for (int i = 0; i < depth; i++) {
+ state.pushScope(i % 2 == 0 ? scopeA : scopeB);
+ }
+ return state;
+ }
+
+ private long runBenchmark(FunctionContextState state, int iterations) {
+ long start = System.nanoTime();
+ for (int i = 0; i < iterations; i++) {
+ Class extends One> one = state.getOverride(One.class);
+ Class extends Two> two = state.getOverride(Two.class);
+ assertNotNull(one);
+ assertNotNull(two);
+ }
+ return (System.nanoTime() - start) / 1_000_000;
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/FunctionContextStateTest.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/FunctionContextStateTest.java
new file mode 100644
index 0000000000..959f24cd28
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/FunctionContextStateTest.java
@@ -0,0 +1,51 @@
+package com.rosetta.model.lib.context;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.rosetta.model.lib.context.example.One;
+import com.rosetta.model.lib.context.example.ScopeA;
+import com.rosetta.model.lib.context.example.ScopeB;
+import com.rosetta.model.lib.context.example.Two;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.inject.Inject;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class FunctionContextStateTest {
+ @Inject
+ private ScopeA scopeA;
+ @Inject
+ private ScopeB scopeB;
+
+ private FunctionContextState state;
+
+ @BeforeEach
+ void setup() {
+ Injector injector = Guice.createInjector();
+ injector.injectMembers(this);
+ state = FunctionContextState.empty();
+ }
+
+ @Test
+ void testScopeStackRestoration() {
+ // Verify that scope stack is properly unwound
+ assertEquals("One", getImplementationName(One.class));
+ assertEquals("Two", getImplementationName(Two.class));
+ state.pushScope(scopeA);
+ state.pushScope(scopeB);
+ assertEquals("OneB", getImplementationName(One.class));
+ assertEquals("TwoA", getImplementationName(Two.class));
+ state.popScope(); // pop scopeB
+ assertEquals("One", getImplementationName(One.class));
+ assertEquals("TwoA", getImplementationName(Two.class));
+ state.popScope(); // pop scopeA
+ assertEquals("One", getImplementationName(One.class));
+ assertEquals("Two", getImplementationName(Two.class));
+ }
+
+ private String getImplementationName(Class> clazz) {
+ return state.getOverride(clazz).getSimpleName();
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/FunctionContextTest.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/FunctionContextTest.java
new file mode 100644
index 0000000000..b3d099fbdf
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/FunctionContextTest.java
@@ -0,0 +1,43 @@
+package com.rosetta.model.lib.context;
+
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.rosetta.model.lib.context.example.Three;
+import com.rosetta.model.lib.context.example.ThreeInScopeA;
+import com.rosetta.model.lib.context.example.ThreeInScopeB;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import javax.inject.Inject;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class FunctionContextTest {
+ @Inject
+ private Three three;
+ @Inject
+ private ThreeInScopeA threeInScopeA;
+ @Inject
+ private ThreeInScopeB threeInScopeB;
+
+ @BeforeEach
+ void setup() {
+ Injector injector = Guice.createInjector();
+ injector.injectMembers(this);
+ }
+
+ @Test
+ void testThreeInDefaultScope() {
+ assertEquals(3, three.evaluate());
+ }
+
+ @Test
+ void testThreeInScopeA() {
+ assertEquals(5, threeInScopeA.evaluate());
+ }
+
+ @Test
+ void testThreeInScopeB() {
+ assertEquals(15, threeInScopeB.evaluate());
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/One.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/One.java
new file mode 100644
index 0000000000..10aa8fc8f9
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/One.java
@@ -0,0 +1,7 @@
+package com.rosetta.model.lib.context.example;
+
+public class One {
+ public int evaluate() {
+ return 1;
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/OneB.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/OneB.java
new file mode 100644
index 0000000000..17d9ad5cc4
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/OneB.java
@@ -0,0 +1,16 @@
+package com.rosetta.model.lib.context.example;
+
+import com.rosetta.model.lib.context.FunctionContext;
+
+import javax.inject.Inject;
+
+public class OneB extends One {
+ @Inject
+ private FunctionContext context;
+ @Inject
+ private One superFunction;
+
+ public int evaluate() {
+ return context.evaluateInScope(ScopeB.class, () -> 3 * superFunction.evaluate());
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/ScopeA.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/ScopeA.java
new file mode 100644
index 0000000000..240486a7f8
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/ScopeA.java
@@ -0,0 +1,13 @@
+package com.rosetta.model.lib.context.example;
+
+import com.rosetta.model.lib.context.AbstractFunctionScope;
+
+import javax.inject.Singleton;
+
+@Singleton
+public class ScopeA extends AbstractFunctionScope {
+ @Override
+ protected void configure() {
+ addOverride(Two.class, TwoA.class);
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/ScopeB.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/ScopeB.java
new file mode 100644
index 0000000000..8827d56777
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/ScopeB.java
@@ -0,0 +1,13 @@
+package com.rosetta.model.lib.context.example;
+
+import com.rosetta.model.lib.context.AbstractFunctionScope;
+
+import javax.inject.Singleton;
+
+@Singleton
+public class ScopeB extends AbstractFunctionScope {
+ @Override
+ protected void configure() {
+ addOverride(One.class, OneB.class);
+ }
+}
diff --git a/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/Three.java b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/Three.java
new file mode 100644
index 0000000000..6c9d046337
--- /dev/null
+++ b/rune-runtime/src/test/java/com/rosetta/model/lib/context/example/Three.java
@@ -0,0 +1,18 @@
+package com.rosetta.model.lib.context.example;
+
+import com.rosetta.model.lib.context.ContextAwareProvider;
+
+import javax.inject.Inject;
+
+public class Three {
+ @Inject
+ private ContextAwareProvider
+ * public class MyClass {
+ * {@literal @}Inject
+ * private ContextAwareProvider<MyDependency> dependencyProvider;
+ *
+ * public void doWork() {
+ * MyDependency dep = dependencyProvider.get(); // Resolved based on current scope
+ * // ... use dep
+ * }
+ * }
+ *
+ *
+ * @param