Skip to content

Commit 189a89b

Browse files
authored
Merge pull request #29 from div0rce/feat/pr11-horizon-aware-optimization
feat(engine): add horizon-aware planning rollouts
2 parents ec2038c + d0a4ad9 commit 189a89b

12 files changed

Lines changed: 439 additions & 2 deletions

docs/horizon-aware-planning.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
Status: Current
2+
Last updated: 2026-04-29
3+
4+
# Horizon-Aware Planning
5+
6+
Cherry has two separate decision concepts: single-step recommendation and
7+
horizon-aware planning. PR11 adds only the planning primitive.
8+
9+
## Single-Step Recommendation
10+
11+
A single-step recommendation answers:
12+
13+
> What action should be recommended now, using present-state facts?
14+
15+
This remains the default recommendation surface. PR11 does not change solver
16+
output, API routes, or UI behavior.
17+
18+
## Horizon-Aware Planning
19+
20+
A horizon-aware rollout answers:
21+
22+
> If Cherry repeatedly applied an injected policy over a short explicit horizon,
23+
> what projected state sequence would result?
24+
25+
The rollout is deterministic planning infrastructure. Its serialized label is
26+
`planning_projection`, and every rollout step is also labeled
27+
`planning_projection`.
28+
29+
Step 0 is marked with `stepRole: "selected_present_action"` because it is the
30+
present action selected by the planning policy. Later steps use
31+
`stepRole: "projected_future_action"` and describe projected policy behavior
32+
only.
33+
34+
## Non-Leakage Rule
35+
36+
Future projected events may not justify a present-time recommendation in PR11.
37+
38+
By default and by design:
39+
40+
```txt
41+
futureJustification = "forbidden"
42+
```
43+
44+
The selected present action is only the step-0 policy action. Future projected
45+
steps can affect only the projected state sequence inside the rollout.
46+
47+
Rollout step states are recorded as snapshots. Callers may inject custom
48+
snapshot behavior for non-plain state, canonical serialization, frozen fixtures,
49+
or class-like objects. The rollout loop still advances with the actual
50+
transition result.
51+
52+
## Current Scope
53+
54+
This is not full financial planning.
55+
This is not stochastic modeling.
56+
This is not obligation forecasting.
57+
This is not a UI redesign.
58+
59+
The horizon subsystem is generic. It accepts injected policy and transition
60+
functions and does not import solver internals.
61+
62+
## Related docs
63+
64+
- `docs/engine-time-semantics.md`
65+
- `docs/engine-optimality/trace.md`
66+
- `docs/simulation/objective-semantics.md`

lib/engine/horizon/config.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export const DEFAULT_HORIZON_STEPS = 3 as const;
2+
export const MAX_HORIZON_STEPS = 6 as const;
3+
4+
export type HorizonMode = 'planning';
5+
export type FutureJustification = 'forbidden';
6+
7+
export type HorizonConfig = {
8+
mode: HorizonMode;
9+
steps: number;
10+
futureJustification: FutureJustification;
11+
};
12+
13+
export type HorizonConfigInput = {
14+
mode?: HorizonMode;
15+
steps?: number;
16+
futureJustification?: FutureJustification;
17+
};
18+
19+
export function normalizeHorizonConfig(
20+
input?: HorizonConfigInput
21+
): HorizonConfig {
22+
const steps =
23+
input === undefined || input.steps === undefined
24+
? DEFAULT_HORIZON_STEPS
25+
: input.steps;
26+
27+
if (!Number.isInteger(steps) || steps < 1 || steps > MAX_HORIZON_STEPS) {
28+
throw new Error(`Invalid horizon length: ${steps}`);
29+
}
30+
31+
return {
32+
mode: 'planning',
33+
steps,
34+
futureJustification: 'forbidden',
35+
};
36+
}

lib/engine/horizon/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './config.js';
2+
export * from './types.js';
3+
export * from './policy.js';
4+
export * from './transition.js';
5+
export * from './rollout.js';

lib/engine/horizon/policy.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export type PolicyEvaluator<TState, TAction, TObjective> = (args: {
2+
state: TState;
3+
step: number;
4+
}) => {
5+
action: TAction | null;
6+
objective: TObjective | null;
7+
};
8+
9+
export function choosePlanningAction<TState, TAction, TObjective>(args: {
10+
state: TState;
11+
step: number;
12+
evaluatePolicy: PolicyEvaluator<TState, TAction, TObjective>;
13+
}): {
14+
action: TAction | null;
15+
objective: TObjective | null;
16+
} {
17+
return args.evaluatePolicy({
18+
state: args.state,
19+
step: args.step,
20+
});
21+
}

lib/engine/horizon/rollout.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { HorizonConfig } from './config.js';
2+
import type {
3+
HorizonRollout,
4+
SnapshotStateFn,
5+
HorizonStep,
6+
HorizonStepRole,
7+
HorizonTransitionReason,
8+
} from './types.js';
9+
import type { PolicyEvaluator } from './policy.js';
10+
import { choosePlanningAction } from './policy.js';
11+
import type { TransitionFn } from './transition.js';
12+
import { transitionFutureState } from './transition.js';
13+
14+
function stepRoleFor(step: number): HorizonStepRole {
15+
return step === 0 ? 'selected_present_action' : 'projected_future_action';
16+
}
17+
18+
function transitionReasonFor<TAction>(
19+
stepRole: HorizonStepRole,
20+
action: TAction | null
21+
): HorizonTransitionReason {
22+
if (action === null) {
23+
return 'projected_state_only';
24+
}
25+
26+
return stepRole === 'selected_present_action'
27+
? 'selected_present_action_projected'
28+
: 'projected_future_action';
29+
}
30+
31+
const defaultSnapshotState = <TState>(state: TState): TState =>
32+
structuredClone(state);
33+
34+
export function runHorizonRollout<TState, TAction, TObjective>(args: {
35+
initialState: TState;
36+
config: HorizonConfig;
37+
evaluatePolicy: PolicyEvaluator<TState, TAction, TObjective>;
38+
applyAction: TransitionFn<TState, TAction>;
39+
snapshotState?: SnapshotStateFn<TState>;
40+
}): HorizonRollout<TState, TAction, TObjective> {
41+
const steps: HorizonStep<TState, TAction, TObjective>[] = [];
42+
const snapshotState =
43+
args.snapshotState === undefined ? defaultSnapshotState : args.snapshotState;
44+
45+
let state = args.initialState;
46+
let selectedPresentAction: TAction | null = null;
47+
48+
for (let step = 0; step < args.config.steps; step += 1) {
49+
const stateBefore = snapshotState(state);
50+
const stepRole = stepRoleFor(step);
51+
const { action, objective } = choosePlanningAction({
52+
state,
53+
step,
54+
evaluatePolicy: args.evaluatePolicy,
55+
});
56+
57+
if (step === 0) {
58+
selectedPresentAction = action;
59+
}
60+
61+
const stateAfter =
62+
action === null
63+
? state
64+
: transitionFutureState({
65+
state,
66+
action,
67+
step,
68+
applyAction: args.applyAction,
69+
});
70+
const stateAfterSnapshot = snapshotState(stateAfter);
71+
72+
steps.push({
73+
step,
74+
label: 'planning_projection',
75+
stepRole,
76+
stateBefore,
77+
action,
78+
objective,
79+
stateAfter: stateAfterSnapshot,
80+
transitionReason: transitionReasonFor(stepRole, action),
81+
});
82+
83+
state = stateAfter;
84+
}
85+
86+
return {
87+
label: 'planning_projection',
88+
horizonSteps: args.config.steps,
89+
futureJustification: args.config.futureJustification,
90+
selectedPresentAction,
91+
steps,
92+
};
93+
}

lib/engine/horizon/transition.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type TransitionFn<TState, TAction> = (args: {
2+
state: TState;
3+
action: TAction;
4+
step: number;
5+
}) => TState;
6+
7+
export function transitionFutureState<TState, TAction>(args: {
8+
state: TState;
9+
action: TAction;
10+
step: number;
11+
applyAction: TransitionFn<TState, TAction>;
12+
}): TState {
13+
return args.applyAction({
14+
state: args.state,
15+
action: args.action,
16+
step: args.step,
17+
});
18+
}

lib/engine/horizon/types.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { FutureJustification } from './config.js';
2+
3+
export type HorizonStepIndex = number;
4+
5+
export type HorizonLabel = 'planning_projection';
6+
7+
export type HorizonStepRole =
8+
| 'selected_present_action'
9+
| 'projected_future_action';
10+
11+
export type HorizonTransitionReason =
12+
| 'selected_present_action_projected'
13+
| 'projected_future_action'
14+
| 'projected_state_only';
15+
16+
export type SnapshotStateFn<TState> = (state: TState) => TState;
17+
18+
export type HorizonStep<TState, TAction, TObjective> = {
19+
step: HorizonStepIndex;
20+
label: HorizonLabel;
21+
stepRole: HorizonStepRole;
22+
stateBefore: TState;
23+
action: TAction | null;
24+
objective: TObjective | null;
25+
stateAfter: TState;
26+
transitionReason: HorizonTransitionReason;
27+
};
28+
29+
export type HorizonRollout<TState, TAction, TObjective> = {
30+
label: HorizonLabel;
31+
horizonSteps: number;
32+
futureJustification: FutureJustification;
33+
selectedPresentAction: TAction | null;
34+
steps: readonly HorizonStep<TState, TAction, TObjective>[];
35+
};

lib/engine/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './scheduled-paydowns.js';
1313
export * from './temporal-response.js';
1414
export * from './public-types.js';
1515
export * from './public.js';
16+
export * from './horizon/index.js';

lib/engine/version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const engineBehaviorVersion = 'engine_behavior_v6' as const;
1+
export const engineBehaviorVersion = 'engine_behavior_v7' as const;
22
export const engineInputVersion = 'engine_input_v1' as const;
33
export const engineCandidateSpaceVersion = 'engine_candidate_space_v1' as const;
44
export const engineAccountingVersion = 'engine_accounting_v1' as const;

scripts/guardrails/engine-freeze.policy.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
]
77
},
88
"engineVersions": {
9-
"behavior": "engine_behavior_v6",
9+
"behavior": "engine_behavior_v7",
1010
"input": "engine_input_v1",
1111
"candidateSpace": "engine_candidate_space_v1",
1212
"accounting": "engine_accounting_v1"

0 commit comments

Comments
 (0)