Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/horizon-aware-planning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
Status: Current
Last updated: 2026-04-29

# Horizon-Aware Planning

Cherry has two separate decision concepts: single-step recommendation and
horizon-aware planning. PR11 adds only the planning primitive.

## Single-Step Recommendation

A single-step recommendation answers:

> What action should be recommended now, using present-state facts?

This remains the default recommendation surface. PR11 does not change solver
output, API routes, or UI behavior.

## Horizon-Aware Planning

A horizon-aware rollout answers:

> If Cherry repeatedly applied an injected policy over a short explicit horizon,
> what projected state sequence would result?

The rollout is deterministic planning infrastructure. Its serialized label is
`planning_projection`, and every rollout step is also labeled
`planning_projection`.

Step 0 is marked with `stepRole: "selected_present_action"` because it is the
present action selected by the planning policy. Later steps use
`stepRole: "projected_future_action"` and describe projected policy behavior
only.

## Non-Leakage Rule

Future projected events may not justify a present-time recommendation in PR11.

By default and by design:

```txt
futureJustification = "forbidden"
```

The selected present action is only the step-0 policy action. Future projected
steps can affect only the projected state sequence inside the rollout.

Rollout step states are recorded as snapshots. Callers may inject custom
snapshot behavior for non-plain state, canonical serialization, frozen fixtures,
or class-like objects. The rollout loop still advances with the actual
transition result.

## Current Scope

This is not full financial planning.
This is not stochastic modeling.
This is not obligation forecasting.
This is not a UI redesign.

The horizon subsystem is generic. It accepts injected policy and transition
functions and does not import solver internals.

## Related docs

- `docs/engine-time-semantics.md`
- `docs/engine-optimality/trace.md`
- `docs/simulation/objective-semantics.md`
36 changes: 36 additions & 0 deletions lib/engine/horizon/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const DEFAULT_HORIZON_STEPS = 3 as const;
export const MAX_HORIZON_STEPS = 6 as const;

export type HorizonMode = 'planning';
export type FutureJustification = 'forbidden';

export type HorizonConfig = {
mode: HorizonMode;
steps: number;
futureJustification: FutureJustification;
};

export type HorizonConfigInput = {
mode?: HorizonMode;
steps?: number;
futureJustification?: FutureJustification;
};

export function normalizeHorizonConfig(
input?: HorizonConfigInput
): HorizonConfig {
const steps =
input === undefined || input.steps === undefined
? DEFAULT_HORIZON_STEPS
: input.steps;

if (!Number.isInteger(steps) || steps < 1 || steps > MAX_HORIZON_STEPS) {
throw new Error(`Invalid horizon length: ${steps}`);
}

return {
mode: 'planning',
steps,
futureJustification: 'forbidden',
};
}
5 changes: 5 additions & 0 deletions lib/engine/horizon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './config.js';
export * from './types.js';
export * from './policy.js';
export * from './transition.js';
export * from './rollout.js';
21 changes: 21 additions & 0 deletions lib/engine/horizon/policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type PolicyEvaluator<TState, TAction, TObjective> = (args: {
state: TState;
step: number;
}) => {
action: TAction | null;
objective: TObjective | null;
};

export function choosePlanningAction<TState, TAction, TObjective>(args: {
state: TState;
step: number;
evaluatePolicy: PolicyEvaluator<TState, TAction, TObjective>;
}): {
action: TAction | null;
objective: TObjective | null;
} {
return args.evaluatePolicy({
state: args.state,
step: args.step,
});
}
93 changes: 93 additions & 0 deletions lib/engine/horizon/rollout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { HorizonConfig } from './config.js';
import type {
HorizonRollout,
SnapshotStateFn,
HorizonStep,
HorizonStepRole,
HorizonTransitionReason,
} from './types.js';
import type { PolicyEvaluator } from './policy.js';
import { choosePlanningAction } from './policy.js';
import type { TransitionFn } from './transition.js';
import { transitionFutureState } from './transition.js';

function stepRoleFor(step: number): HorizonStepRole {
return step === 0 ? 'selected_present_action' : 'projected_future_action';
}

function transitionReasonFor<TAction>(
stepRole: HorizonStepRole,
action: TAction | null
): HorizonTransitionReason {
if (action === null) {
return 'projected_state_only';
}

return stepRole === 'selected_present_action'
? 'selected_present_action_projected'
: 'projected_future_action';
}

const defaultSnapshotState = <TState>(state: TState): TState =>
structuredClone(state);

export function runHorizonRollout<TState, TAction, TObjective>(args: {
initialState: TState;
config: HorizonConfig;
evaluatePolicy: PolicyEvaluator<TState, TAction, TObjective>;
applyAction: TransitionFn<TState, TAction>;
snapshotState?: SnapshotStateFn<TState>;
}): HorizonRollout<TState, TAction, TObjective> {
const steps: HorizonStep<TState, TAction, TObjective>[] = [];
const snapshotState =
args.snapshotState === undefined ? defaultSnapshotState : args.snapshotState;

let state = args.initialState;
let selectedPresentAction: TAction | null = null;

for (let step = 0; step < args.config.steps; step += 1) {
const stateBefore = snapshotState(state);
const stepRole = stepRoleFor(step);
const { action, objective } = choosePlanningAction({
state,
step,
evaluatePolicy: args.evaluatePolicy,
});

if (step === 0) {
selectedPresentAction = action;
}

const stateAfter =
action === null
? state
: transitionFutureState({
state,
action,
step,
applyAction: args.applyAction,
});
const stateAfterSnapshot = snapshotState(stateAfter);

steps.push({
step,
label: 'planning_projection',
stepRole,
stateBefore,
action,
objective,
stateAfter: stateAfterSnapshot,
transitionReason: transitionReasonFor(stepRole, action),
});

state = stateAfter;
}

return {
label: 'planning_projection',
horizonSteps: args.config.steps,
futureJustification: args.config.futureJustification,
selectedPresentAction,
steps,
};
}
18 changes: 18 additions & 0 deletions lib/engine/horizon/transition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type TransitionFn<TState, TAction> = (args: {
state: TState;
action: TAction;
step: number;
}) => TState;

export function transitionFutureState<TState, TAction>(args: {
state: TState;
action: TAction;
step: number;
applyAction: TransitionFn<TState, TAction>;
}): TState {
return args.applyAction({
state: args.state,
action: args.action,
step: args.step,
});
}
35 changes: 35 additions & 0 deletions lib/engine/horizon/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { FutureJustification } from './config.js';

export type HorizonStepIndex = number;

export type HorizonLabel = 'planning_projection';

export type HorizonStepRole =
| 'selected_present_action'
| 'projected_future_action';

export type HorizonTransitionReason =
| 'selected_present_action_projected'
| 'projected_future_action'
| 'projected_state_only';

export type SnapshotStateFn<TState> = (state: TState) => TState;

export type HorizonStep<TState, TAction, TObjective> = {
step: HorizonStepIndex;
label: HorizonLabel;
stepRole: HorizonStepRole;
stateBefore: TState;
action: TAction | null;
objective: TObjective | null;
stateAfter: TState;
transitionReason: HorizonTransitionReason;
};

export type HorizonRollout<TState, TAction, TObjective> = {
label: HorizonLabel;
horizonSteps: number;
futureJustification: FutureJustification;
selectedPresentAction: TAction | null;
steps: readonly HorizonStep<TState, TAction, TObjective>[];
};
1 change: 1 addition & 0 deletions lib/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export * from './scheduled-paydowns.js';
export * from './temporal-response.js';
export * from './public-types.js';
export * from './public.js';
export * from './horizon/index.js';
2 changes: 1 addition & 1 deletion lib/engine/version.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const engineBehaviorVersion = 'engine_behavior_v6' as const;
export const engineBehaviorVersion = 'engine_behavior_v7' as const;
export const engineInputVersion = 'engine_input_v1' as const;
export const engineCandidateSpaceVersion = 'engine_candidate_space_v1' as const;
export const engineAccountingVersion = 'engine_accounting_v1' as const;
2 changes: 1 addition & 1 deletion scripts/guardrails/engine-freeze.policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
]
},
"engineVersions": {
"behavior": "engine_behavior_v6",
"behavior": "engine_behavior_v7",
"input": "engine_input_v1",
"candidateSpace": "engine_candidate_space_v1",
"accounting": "engine_accounting_v1"
Expand Down
Loading
Loading