diff --git a/docs/horizon-aware-planning.md b/docs/horizon-aware-planning.md new file mode 100644 index 0000000..2f45c71 --- /dev/null +++ b/docs/horizon-aware-planning.md @@ -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` diff --git a/lib/engine/horizon/config.ts b/lib/engine/horizon/config.ts new file mode 100644 index 0000000..fde879d --- /dev/null +++ b/lib/engine/horizon/config.ts @@ -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', + }; +} diff --git a/lib/engine/horizon/index.ts b/lib/engine/horizon/index.ts new file mode 100644 index 0000000..4631ef1 --- /dev/null +++ b/lib/engine/horizon/index.ts @@ -0,0 +1,5 @@ +export * from './config.js'; +export * from './types.js'; +export * from './policy.js'; +export * from './transition.js'; +export * from './rollout.js'; diff --git a/lib/engine/horizon/policy.ts b/lib/engine/horizon/policy.ts new file mode 100644 index 0000000..05e142c --- /dev/null +++ b/lib/engine/horizon/policy.ts @@ -0,0 +1,21 @@ +export type PolicyEvaluator = (args: { + state: TState; + step: number; +}) => { + action: TAction | null; + objective: TObjective | null; +}; + +export function choosePlanningAction(args: { + state: TState; + step: number; + evaluatePolicy: PolicyEvaluator; +}): { + action: TAction | null; + objective: TObjective | null; +} { + return args.evaluatePolicy({ + state: args.state, + step: args.step, + }); +} diff --git a/lib/engine/horizon/rollout.ts b/lib/engine/horizon/rollout.ts new file mode 100644 index 0000000..dfbcf9b --- /dev/null +++ b/lib/engine/horizon/rollout.ts @@ -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( + 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 = (state: TState): TState => + structuredClone(state); + +export function runHorizonRollout(args: { + initialState: TState; + config: HorizonConfig; + evaluatePolicy: PolicyEvaluator; + applyAction: TransitionFn; + snapshotState?: SnapshotStateFn; +}): HorizonRollout { + const steps: HorizonStep[] = []; + 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, + }; +} diff --git a/lib/engine/horizon/transition.ts b/lib/engine/horizon/transition.ts new file mode 100644 index 0000000..e2d4c88 --- /dev/null +++ b/lib/engine/horizon/transition.ts @@ -0,0 +1,18 @@ +export type TransitionFn = (args: { + state: TState; + action: TAction; + step: number; +}) => TState; + +export function transitionFutureState(args: { + state: TState; + action: TAction; + step: number; + applyAction: TransitionFn; +}): TState { + return args.applyAction({ + state: args.state, + action: args.action, + step: args.step, + }); +} diff --git a/lib/engine/horizon/types.ts b/lib/engine/horizon/types.ts new file mode 100644 index 0000000..0642410 --- /dev/null +++ b/lib/engine/horizon/types.ts @@ -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 = (state: TState) => TState; + +export type HorizonStep = { + step: HorizonStepIndex; + label: HorizonLabel; + stepRole: HorizonStepRole; + stateBefore: TState; + action: TAction | null; + objective: TObjective | null; + stateAfter: TState; + transitionReason: HorizonTransitionReason; +}; + +export type HorizonRollout = { + label: HorizonLabel; + horizonSteps: number; + futureJustification: FutureJustification; + selectedPresentAction: TAction | null; + steps: readonly HorizonStep[]; +}; diff --git a/lib/engine/index.ts b/lib/engine/index.ts index 1cd9367..24f6cb4 100644 --- a/lib/engine/index.ts +++ b/lib/engine/index.ts @@ -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'; diff --git a/lib/engine/version.ts b/lib/engine/version.ts index f3d2aa5..c53fc28 100644 --- a/lib/engine/version.ts +++ b/lib/engine/version.ts @@ -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; diff --git a/scripts/guardrails/engine-freeze.policy.json b/scripts/guardrails/engine-freeze.policy.json index fc61ebe..94975bd 100644 --- a/scripts/guardrails/engine-freeze.policy.json +++ b/scripts/guardrails/engine-freeze.policy.json @@ -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" diff --git a/tests/node/engine/horizon.test.ts b/tests/node/engine/horizon.test.ts new file mode 100644 index 0000000..c4724c0 --- /dev/null +++ b/tests/node/engine/horizon.test.ts @@ -0,0 +1,151 @@ +import * as assert from 'node:assert/strict'; +import { + DEFAULT_HORIZON_STEPS, + MAX_HORIZON_STEPS, + normalizeHorizonConfig, + runHorizonRollout, +} from '../../../lib/engine.js'; + +type TestState = { + value: number; +}; + +type TestAction = { + id: string; + delta: number; +}; + +type TestObjective = { + utility: number; +}; + +function runTestRollout(steps = 3) { + return runHorizonRollout({ + initialState: { value: 0 }, + config: normalizeHorizonConfig({ steps }), + evaluatePolicy: ({ state, step }) => { + if (step === 0) { + return { + action: { id: 'present-action', delta: 1 }, + objective: { utility: state.value + 10 }, + }; + } + + return { + action: { id: `future-action-${step}`, delta: 100 + step }, + objective: { utility: state.value + 10_000 + step }, + }; + }, + applyAction: ({ state, action }) => ({ + value: state.value + action.delta, + }), + }); +} + +function testConfigDefaults(): void { + const config = normalizeHorizonConfig(); + + assert.equal(config.mode, 'planning'); + assert.equal(config.steps, DEFAULT_HORIZON_STEPS); + assert.equal(config.futureJustification, 'forbidden'); +} + +function testInvalidHorizonLength(): void { + assert.throws(() => normalizeHorizonConfig({ steps: 0 }), { + message: 'Invalid horizon length: 0', + }); + assert.throws(() => normalizeHorizonConfig({ steps: 1.5 }), { + message: 'Invalid horizon length: 1.5', + }); + assert.throws(() => normalizeHorizonConfig({ steps: MAX_HORIZON_STEPS + 1 }), { + message: `Invalid horizon length: ${MAX_HORIZON_STEPS + 1}`, + }); +} + +function testRolloutLabelsAndRoles(): void { + const rollout = runTestRollout(3); + + assert.equal(rollout.label, 'planning_projection'); + assert.equal(rollout.steps[0]?.label, 'planning_projection'); + assert.equal(rollout.steps[0]?.stepRole, 'selected_present_action'); + assert.equal(rollout.steps[1]?.label, 'planning_projection'); + assert.equal(rollout.steps[1]?.stepRole, 'projected_future_action'); + assert.equal(rollout.steps[2]?.stepRole, 'projected_future_action'); +} + +function testRolloutLengthAndFutureJustification(): void { + const rollout = runTestRollout(4); + + assert.equal(rollout.horizonSteps, 4); + assert.equal(rollout.steps.length, 4); + assert.equal(rollout.futureJustification, 'forbidden'); +} + +function testSelectedPresentActionComesOnlyFromStepZero(): void { + const rollout = runTestRollout(3); + + assert.deepEqual(rollout.selectedPresentAction, { + id: 'present-action', + delta: 1, + }); + assert.equal(rollout.steps[0]?.action?.id, 'present-action'); + assert.equal(rollout.steps[1]?.action?.id, 'future-action-1'); + assert.equal(rollout.steps[2]?.action?.id, 'future-action-2'); + assert.notEqual( + rollout.selectedPresentAction?.id, + rollout.steps[1]?.action?.id + ); +} + +function testProjectedFutureActionsOnlyAffectProjectedState(): void { + const rollout = runTestRollout(3); + + assert.deepEqual(rollout.steps[0]?.stateBefore, { value: 0 }); + assert.deepEqual(rollout.steps[0]?.stateAfter, { value: 1 }); + assert.deepEqual(rollout.steps[1]?.stateBefore, { value: 1 }); + assert.deepEqual(rollout.steps[1]?.stateAfter, { value: 102 }); + assert.deepEqual(rollout.steps[2]?.stateBefore, { value: 102 }); + assert.deepEqual(rollout.steps[2]?.stateAfter, { value: 204 }); + assert.deepEqual(rollout.selectedPresentAction, rollout.steps[0]?.action); +} + +function testMutatingTransitionsRecordStableSnapshots(): void { + const rollout = runHorizonRollout({ + initialState: { value: 0 }, + config: normalizeHorizonConfig({ steps: 3 }), + evaluatePolicy: ({ state, step }) => { + if (step === 0) { + return { + action: { id: 'present-action', delta: 1 }, + objective: { utility: state.value + 10 }, + }; + } + + return { + action: { id: `future-action-${step}`, delta: 100 + step }, + objective: { utility: state.value + 10_000 + step }, + }; + }, + applyAction: ({ state, action }) => { + state.value += action.delta; + return state; + }, + }); + + assert.deepEqual(rollout.steps[0]?.stateBefore, { value: 0 }); + assert.deepEqual(rollout.steps[0]?.stateAfter, { value: 1 }); + assert.deepEqual(rollout.steps[1]?.stateBefore, { value: 1 }); + assert.deepEqual(rollout.steps[1]?.stateAfter, { value: 102 }); + assert.deepEqual(rollout.steps[2]?.stateBefore, { value: 102 }); + assert.deepEqual(rollout.steps[2]?.stateAfter, { value: 204 }); +} + +testConfigDefaults(); +testInvalidHorizonLength(); +testRolloutLabelsAndRoles(); +testRolloutLengthAndFutureJustification(); +testSelectedPresentActionComesOnlyFromStepZero(); +testProjectedFutureActionsOnlyAffectProjectedState(); +testMutatingTransitionsRecordStableSnapshots(); + +process.stdout.write('engine horizon rollout: ok\n'); diff --git a/tests/replay/index/engine@engine_behavior_v7__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json b/tests/replay/index/engine@engine_behavior_v7__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json new file mode 100644 index 0000000..836d5f8 --- /dev/null +++ b/tests/replay/index/engine@engine_behavior_v7__engine_input_v1__engine_candidate_space_v1__engine_accounting_v1.json @@ -0,0 +1,11 @@ +{ + "hashes": [ + "8f0cf4f6b3683f107427e15adb49485cfddd761980910879e8f4b1b916c4c9b8" + ], + "versions": { + "engineAccountingVersion": "engine_accounting_v1", + "engineBehaviorVersion": "engine_behavior_v7", + "engineCandidateSpaceVersion": "engine_candidate_space_v1", + "engineInputVersion": "engine_input_v1" + } +}