Directorial Preference Injection vs. Hardcoded Planning Rules¶
Summary¶
The current Directorial Preference Memory system is correctly harvesting and rendering user style preferences, but those preferences are being injected too softly. They are appended to the user prompt of each planning call while the planning templates still contain rigid system prompt rules that govern beat count, filler authorization, budget sizing, and timing.
This means the preferences are visible to the model, but they do not reliably change the plan.
The fix is to compile the preference profile into a runtime planning policy and inject that policy into the system prompt of each stage as a preference-aware calibration layer.
The policy should not override script truth, user instructions, continuity, or JSON/schema requirements. It should override generic planning defaults such as:
- conservative filler posture;
- fixed density-to-count mappings;
- default timing ranges;
- default beat granularity assumptions;
- default camera/shot-language tendencies.
Current Failure Mode¶
The planning cascade is:
recommendKeyBeats
→ recommendFillerStrategy
→ budgetBeatPackages
→ generateBeatPackage
The preference block is appended to each call, but each downstream stage is constrained by upstream outputs.
| Stage | Current hard behavior | Why learned preferences lose |
|---|---|---|
recommendKeyBeats |
Strongly polices overcounting and local peaks. | A learned preference for more granular action/chase anchors has to fight the system-level compression rules. |
recommendFillerStrategy |
Starts from “zero filler is the default.” | A learned preference for targeted inserts, POVs, obstacles, and micro-reactions gets treated as decorative unless the direct-cut test strictly requires it. |
budgetBeatPackages |
Converts confirmed filler strategy into counts and forbids unapproved filler. | If the filler strategy stayed conservative, budget cannot meaningfully expand. |
generateBeatPackage |
Must obey the package budget and uses fixed timing ranges. | It can style shots, but it cannot increase shot count once budget is locked. |
So even when the preference block says:
Avg shots/key-beat: 6.4
Avg shot duration: 2.2s
Action/chase scenes: expand 1.3x–2x with targeted inserts
…the budget may still produce:
Beat 1: 1 key + 1 filler = 2 shots
Beat 2: 1 key + 2 fillers = 3 shots
Total: 5 shots
The model saw the preference, but the planning rules made it hard to act on it.
Root Cause¶
The preference memory is currently being used as context, not as planning policy.
Current pattern:
static system prompt rules
+ normal user prompt
+ appended learned-preference block
Recommended pattern:
base system prompt
+ preference-aware system prompt calibration
+ normal user prompt
+ optional human-readable preference summary
The human-readable preference block is still useful for debugging and model context, but it is not enough to change quantitative planning decisions.
Proposed Intermediate Artifact¶
Introduce a structured runtime object:
interface RuntimePlanningPreferencePolicy {
applies: boolean;
confidence: 'low' | 'medium' | 'high';
reason: string;
shotDensity?: {
targetShotsPerKeyBeat?: {
min: number;
ideal: number;
max: number;
};
targetTotalShots?: {
min: number;
ideal: number;
max: number;
};
expansionMode?: 'none' | 'moderate' | 'kinetic_granular';
};
beatPlanning?: {
targetNarrativeAnchors?: {
min: number;
ideal: number;
max: number;
};
countMicroCausalActionStepsAsBeatCandidates?: boolean;
countPOVOrObstacleChangesAsBeatCandidates?: boolean;
};
fillerStrategy?: {
defaultPosture?: 'zero_first' | 'support_envelope';
allowPreferenceDrivenSupport?: boolean;
densityBias?: 'none' | 'upward';
preferredSupportTypes?: Array<
| 'geography'
| 'physical_detail'
| 'reaction_cascade'
| 'transition'
| 'pacing_breath'
| 'pov'
>;
};
budget?: {
allowScalingWithinConfirmedSections?: boolean;
allowPreferenceAuthorizedSupport?: boolean;
densityMapping?: {
sparse: { min: number; ideal: number; max: number };
moderate: { min: number; ideal: number; max: number };
dense: { min: number; ideal: number; max: number };
};
};
generation?: {
targetAvgShotDurationSeconds?: number;
timingRanges?: {
key: { min: number; ideal: number; max: number };
filler: { min: number; ideal: number; max: number };
};
preferredShotSizes?: string[];
preferredAngles?: string[];
preferredMovements?: string[];
preferredShotJobs?: string[];
};
}
Example compiled policy for the Connor chase scene:
const policy: RuntimePlanningPreferencePolicy = {
applies: true,
confidence: 'high',
reason: 'Current scene is a chase/action-threshold scene matching prior user preferences.',
shotDensity: {
targetShotsPerKeyBeat: { min: 4, ideal: 6, max: 8 },
targetTotalShots: { min: 8, ideal: 10, max: 13 },
expansionMode: 'kinetic_granular',
},
beatPlanning: {
targetNarrativeAnchors: { min: 3, ideal: 4, max: 6 },
countMicroCausalActionStepsAsBeatCandidates: true,
countPOVOrObstacleChangesAsBeatCandidates: true,
},
fillerStrategy: {
defaultPosture: 'support_envelope',
allowPreferenceDrivenSupport: true,
densityBias: 'upward',
preferredSupportTypes: [
'geography',
'physical_detail',
'reaction_cascade',
'pov',
],
},
budget: {
allowScalingWithinConfirmedSections: true,
allowPreferenceAuthorizedSupport: true,
densityMapping: {
sparse: { min: 1, ideal: 2, max: 2 },
moderate: { min: 3, ideal: 4, max: 5 },
dense: { min: 5, ideal: 6, max: 8 },
},
},
generation: {
targetAvgShotDurationSeconds: 2.2,
timingRanges: {
key: { min: 1.8, ideal: 2.4, max: 3.2 },
filler: { min: 1.0, ideal: 1.8, max: 2.6 },
},
preferredShotSizes: ['WIDE', 'MEDIUM', 'CLOSE_UP', 'EXTREME_CLOSE_UP'],
preferredAngles: ['EYE_LEVEL', 'POV', 'LOW_ANGLE', 'DUTCH_ANGLE'],
preferredMovements: ['STATIC', 'TRACK_RIGHT', 'HANDHELD', 'STEADICAM'],
preferredShotJobs: [
'cinematic_anchor',
'action_progression',
'reaction_emotion',
'orientation_context',
],
},
};
Preference Override Policy¶
The policy must be stronger than generic defaults but weaker than user instructions and scene truth.
Add this as a fixed system prompt preamble wherever a runtime policy is injected:
=== LEARNED DIRECTORIAL PREFERENCE POLICY ===
A learned preference policy may be provided for this scene.
Use it to parameterize generic planning defaults such as beat granularity, filler posture,
density-to-shot-count mapping, timing ranges, and camera/shot-language tendencies.
The policy does NOT override:
1. explicit current user instructions;
2. script facts, continuity, character/location truth, or feasibility constraints;
3. required JSON schema and canonical camera values;
4. confirmed user-approved planning artifacts, unless the current tool is explicitly responsible for revising them.
The policy DOES override generic defaults when it applies, including:
- conservative zero-filler posture;
- default density-to-count mappings;
- default timing ranges;
- generic camera/shot-language defaults;
- generic assumptions about how granular action/chase beats should be.
If the policy applies but the scene cannot support its target range, keep the scene-appropriate plan and explain why.
This is more precise than:
Override: prioritize scene context over these preferences when they clearly conflict.
That sentence is too weak because it gives the model an easy escape hatch to ignore the preferences entirely.
Stage-by-Stage Changes¶
1. recommendKeyBeats¶
Current issue¶
The template correctly guards against overcounting, but the guard is strong enough that user preference for granular kinetic anchors may be ignored.
Desired behavior¶
If a learned action/chase preference applies, the model should actively inspect micro-causal steps, POV changes, obstacle encounters, geography shifts, and threshold crossings as possible beat candidates.
System prompt addendum¶
=== LEARNED BEAT GRANULARITY CALIBRATION ===
If a learned preference policy applies to this scene, use it as calibration for how granularly to identify scene anchors.
This does not mean inventing fake beats. It means:
- inspect smaller causal action steps as possible beat candidates;
- treat POV shifts, obstacle encounters, irreversible physical choices, and threshold crossings as possible structural anchors in kinetic scenes;
- if the policy indicates an expanded action/chase style, do not collapse multiple irreversible physical or spatial changes into one broad beat merely because they occur in one short action passage.
If your recommended beat count is below the policy target range, run an undercount audit:
- Did I collapse a physical cause and its consequence into one beat?
- Did I miss a POV/obstacle/geography shift that changes how the audience tracks the chase?
- Did I demote an irreversible commitment or threshold crossing into filler?
If the scene truly cannot support the target range, keep the smaller beat count and explain why.
What this changes¶
The model still respects the structural-shift rule, but it searches harder for valid micro-anchors in kinetic scenes.
2. recommendFillerStrategy¶
Current issue¶
The current prompt starts from a conservative default:
ZERO FILLER IS THE DEFAULT FOR SIMPLE SCENES
Start from the assumption that the correct answer is zero filler sections.
That suppresses a learned style that values granular inserts, POVs, obstacles, and micro-reactions.
Desired behavior¶
For matching action/chase scenes, the default should become a targeted support-envelope posture, not zero filler.
System prompt replacement¶
Replace the zero-filler section with:
=== DEFAULT FILLER POSTURE ===
For simple scenes, zero filler is the default.
However, if a learned preference policy applies and marks this as a kinetic/action/chase/high-orientation scene, do NOT start from zero filler. Start from a targeted support-envelope posture:
- every key beat should be checked for orientation, physical-detail, POV, obstacle, micro-reaction, or consequence support;
- support is valid when it preserves clarity, momentum, psychological pressure, or causal coherence;
- do not add decorative atmosphere-only filler.
The direct-cut test still applies, but learned user style can change what counts as a real gap.
For this user, missing POV, tactile physical detail, obstacle tracking, or micro-emotional reaction may be a real editorial gap in kinetic scenes.
Add a density calibration section:
=== LEARNED DENSITY CALIBRATION ===
If the learned policy provides target shots per key beat or an expansion ratio:
- choose filler sections and densities that make the later shot budget capable of reaching that target;
- prefer fewer, denser, well-motivated support envelopes over many vague filler sections;
- if the target would be inappropriate for this scene, explain why in reasoning.
What this changes¶
The filler strategy becomes capable of authorizing the support that budget needs later.
3. budgetBeatPackages¶
Current issue¶
This stage is where shot count becomes real, but the current prompt has fixed mappings and a hard self-check:
No filler was invented that the confirmed strategy didn't authorize.
That blocks expansion when the confirmed filler strategy is too lean.
Desired behavior¶
The budget stage should still avoid decorative filler, but it should be allowed to scale counts within confirmed sections and, in limited cases, add preference-authorized support attached to confirmed beats.
Replace the self-check rule¶
Replace:
No filler was invented that the confirmed strategy didn't authorize.
with:
No unmotivated filler was invented.
If a learned preference policy applies, you may scale filler counts within confirmed filler sections to match the policy's shot-density target, as long as every added filler still serves the confirmed section's role and intent.
Only add a new preference-authorized support allocation outside confirmed sections when:
- the policy explicitly calls for expanded support;
- the current budget would fall materially below the policy target;
- the added support attaches to a confirmed key beat;
- and the filler has a concrete job: geography, physical_detail, reaction_cascade, transition, pacing_breath, or POV.
Do not add atmosphere-only filler.
Make density mapping policy-aware¶
=== DENSITY TO SHOT COUNT MAPPING ===
If no learned preference policy applies:
- sparse → 1 shot
- moderate → 2-3 shots
- dense → 4+ shots
If an expanded learned preference policy applies:
- sparse → 1-2 shots
- moderate → 3-5 shots
- dense → 5-8 shots
Use the policy's target total shot range as a calibration check.
If the budget falls below the minimum target, increase counts within authorized support sections or explain why the scene cannot support the target.
Add a preference alignment check¶
=== PREFERENCE ALIGNMENT CHECK ===
If learned policy provides targetTotalShots or targetShotsPerKeyBeat:
1. Calculate the planned total.
2. Compare against the target range.
3. If below target and the preference applies, expand support counts where concrete support jobs exist.
4. If still below target, state the reason in reasoning.
What this changes¶
The budget tool can now convert preferences into actual shot counts.
4. generateBeatPackage¶
Current issue¶
The generation stage must obey package budget, which is correct, but its timing ranges are hardcoded:
key shots: 2-5s
filler: 1-3s
This conflicts with learned timing preferences such as:
target avg duration: 2.2s
kinetic/chase range: 1.5-2.8s
Desired behavior¶
Keep the package budget hard, but make timing and shot language policy-aware.
System prompt replacement¶
Replace fixed timing with:
timing: seconds.
If no learned timing policy applies:
- key shots: 2-5s
- filler shots: 1-3s
If a learned timing policy applies:
- use the learned target average and ranges;
- for kinetic/action/chase scenes, keep most shots near the learned average unless a specific key image needs a longer hold;
- do not exceed the range without a clear story reason.
Learned timing policy for this package:
{formattedTimingPolicy}
Add shot-language policy:
=== LEARNED SHOT LANGUAGE CALIBRATION ===
If a learned generation policy applies:
- prefer the learned shot sizes, angles, movements, and shot jobs when they fit the beat;
- use POV, Dutch angle, low angle, handheld, or tracking only when they clarify subjectivity, pressure, threat, or spatial motion;
- maintain variation, but do not avoid preferred camera language merely for variety;
- the learned style is a calibration layer, not a reason to violate canonical camera values or asset constraints.
What this changes¶
Generation can now reflect the learned average duration and camera tendencies once the package budget is large enough.
Important Schema Fix: Density Labels¶
There is a density label mismatch.
recommendFillerStrategy outputs labels like:
sparse
moderate
dense
But printed prompts show confirmed filler strategy sections with:
light
medium
Meanwhile budgetBeatPackages expects:
sparse
moderate
dense
This should be standardized immediately.
Recommended enum:
type FillerDensity = 'sparse' | 'moderate' | 'dense';
If you keep light | medium | heavy from key-beat support need, map it explicitly:
function normalizeSupportNeedToDensity(
supportNeed: 'none' | 'light' | 'medium' | 'heavy'
): 'sparse' | 'moderate' | 'dense' | 'none' {
switch (supportNeed) {
case 'none': return 'none';
case 'light': return 'sparse';
case 'medium': return 'moderate';
case 'heavy': return 'dense';
}
}
Do not rely on the LLM to infer light → sparse or medium → moderate.
Implementation Plan¶
1. Stop relying only on renderedBlock¶
Current behavior fetches a rendered prose block:
private cachedPreferenceBlock: string | null | undefined = undefined;
private async getPreferenceBlock(): Promise<string | null> {
if (this.cachedPreferenceBlock !== undefined) return this.cachedPreferenceBlock;
try {
const profile = await DirectorialPreferenceProfilesModel.findByUserAndStory(
this.userId,
this.storyData.storyId
);
this.cachedPreferenceBlock = profile?.renderedBlock || null;
} catch {
this.cachedPreferenceBlock = null;
}
return this.cachedPreferenceBlock;
}
Add a structured policy fetch:
private cachedPreferencePolicy: RuntimePlanningPreferencePolicy | null | undefined = undefined;
private async getPreferencePolicy(): Promise<RuntimePlanningPreferencePolicy | null> {
if (this.cachedPreferencePolicy !== undefined) {
return this.cachedPreferencePolicy;
}
try {
this.cachedPreferencePolicy =
await DirectorialPreferenceService.buildRuntimePolicyForScene({
preferenceOwnerUserId: this.userId,
storyData: this.storyData,
sceneData: this.sceneData,
});
} catch {
this.cachedPreferencePolicy = null;
}
return this.cachedPreferencePolicy;
}
Keep renderedBlock for debugging and optional user prompt context, but use the structured policy for control.
2. Pass policy slices into all four templates¶
| Template | Policy slice |
|---|---|
RecommendKeyBeatsTemplate |
policy.beatPlanning + high-level style summary |
RecommendFillerStrategyTemplate |
policy.fillerStrategy + policy.shotDensity |
BudgetBeatPackagesTemplate |
policy.budget + policy.shotDensity |
GenerateBeatPackageTemplate |
policy.generation + beat-specific preferences |
Example:
const policy = await this.getPreferencePolicy();
const [userPrompt, systemPrompt] = RecommendFillerStrategyTemplate.getPrompt(
this.storyData,
this.sceneData,
this.sceneData.confirmedKeyBeats.beats,
params.steering,
policy?.fillerStrategy,
policy?.shotDensity
);
3. Inject policy as system prompt addenda¶
Each template should build:
const systemPrompt = `
${baseSystemPrompt}
${PreferencePolicyPromptFormatter.forStage(policy, 'budgetBeatPackages')}
`;
The stage-specific formatter should be deterministic.
Example:
class PreferencePolicyPromptFormatter {
static forStage(
policy: RuntimePlanningPreferencePolicy | null | undefined,
stage:
| 'recommendKeyBeats'
| 'recommendFillerStrategy'
| 'budgetBeatPackages'
| 'generateBeatPackage'
): string {
if (!policy?.applies) return '';
switch (stage) {
case 'budgetBeatPackages':
return formatBudgetPolicy(policy);
case 'generateBeatPackage':
return formatGenerationPolicy(policy);
case 'recommendFillerStrategy':
return formatFillerPolicy(policy);
case 'recommendKeyBeats':
return formatBeatPolicy(policy);
}
}
}
Add a Deterministic Budget QA Gate¶
Prompts are not enough. Add a validator after budgetBeatPackages and before generateBeatPackage.
const budgetData = await SharedHelpers.parseJsonTextResponse<BudgetBeatPackagesResponse>(
budgetResponse.outputText
);
const budgetCheck = validateBudgetAgainstPreferencePolicy({
budget: budgetData,
preferencePolicy,
confirmedBeats,
confirmedFillerSections,
sceneData: this.sceneData,
});
if (!budgetCheck.ok) {
const repairedBudget = await rerunBudgetWithFeedback({
originalBudget: budgetData,
budgetCheck,
preferencePolicy,
});
return repairedBudget;
}
Validator example:
function validateBudgetAgainstPreferencePolicy(params: {
budget: BudgetBeatPackagesResponse;
preferencePolicy: RuntimePlanningPreferencePolicy | null;
confirmedBeats: SceneKeyBeat[];
}): {
ok: boolean;
reason?: string;
minTarget?: number;
actual?: number;
} {
const { budget, preferencePolicy } = params;
if (!preferencePolicy?.applies) {
return { ok: true };
}
const minTarget = preferencePolicy.shotDensity?.targetTotalShots?.min;
if (!minTarget) {
return { ok: true };
}
if (budget.totalShots < minTarget) {
return {
ok: false,
minTarget,
actual: budget.totalShots,
reason: `Budget total ${budget.totalShots} is below learned style minimum ${minTarget}.`,
};
}
return { ok: true };
}
Repair prompt:
The budget is too lean for the learned style policy.
Current total: {actual}.
Target range: {min}-{max}.
Expand only where concrete support jobs exist.
Prefer scaling existing confirmed filler sections before adding new support.
Do not add decorative atmosphere-only filler.
Connor Scene Example¶
Current generated plan:
| Beat | Package |
|---|---|
| Park edge decision | 1 key + 1 filler = 2 shots |
| Drop bag / vault wall | 1 key + 2 fillers = 3 shots |
| Total | 5 shots |
Preference-aware target:
targetTotalShots: { min: 8, ideal: 10, max: 13 }
A better budget could be:
| Beat | Preference-aware package |
|---|---|
| Park edge reveal | 1 key + 2 lead-in geography/POV fillers + 1 reaction/recognition trailing filler = 4 shots |
| Bag drop / wall vault | 1 key + 4 physical-detail / obstacle / tactile fillers + 1 plunge/consequence trailing filler = 6 shots |
| Total | 10 shots |
This is not blindly forcing 13 shots. It is applying the learned kinetic style to a scene that actually matches the preference conditions.
Priority Work Items¶
| Work item | Priority | Why |
|---|---|---|
Add RuntimePlanningPreferencePolicy |
P0 | Need operational knobs, not only prose. |
| Pass policy into all four templates | P0 | Preferences must affect the whole cascade. |
| Inject policy into system prompts | P0 | User-prompt append is too weak against static system rules. |
| Fix density enum mismatch | P0 | light/medium vs. sparse/moderate/dense weakens control. |
| Make budget density mapping preference-aware | P0 | Budget is where shot count becomes real. |
| Replace “no invented filler” with “no unmotivated filler” | P0 | Allows preference-authorized expansion without decorative filler. |
| Add budget QA gate | P1 | Prevents silent regression to conservative budgets. |
| Make generation timing policy-aware | P1 | Lets 2.2s average appear in actual shots. |
| Add Connor regression test | P1 | Ensures the known failure case stays fixed. |
Regression Test Proposal¶
Use the Connor scene prompt as a fixture.
Expected behavior with high-confidence kinetic preference policy:
expect(budget.totalShots).toBeGreaterThanOrEqual(8);
expect(budget.totalShots).toBeLessThanOrEqual(13);
For budget packages:
expect(packages[0].leadInFillers + packages[0].trailingFillers).toBeGreaterThanOrEqual(2);
expect(packages[1].leadInFillers + packages[1].trailingFillers).toBeGreaterThanOrEqual(4);
For generated shots:
const avgTiming = average(shots.map((shot) => Number(shot.timing)));
expect(avgTiming).toBeGreaterThanOrEqual(1.5);
expect(avgTiming).toBeLessThanOrEqual(2.8);
For camera language:
expect(shots.some((shot) => shot.cameraAngle === 'POV')).toBe(true);
expect(shots.some((shot) => shot.shotSize === 'EXTREME_CLOSE_UP')).toBe(true);
expect(shots.some((shot) => shot.shotJob === 'physical_detail')).toBe(true);
Final Principle¶
The learned profile should not be injected as:
Here are preferences. Consider them.
It should be compiled into:
For this scene, these generic defaults are changed:
- beat granularity;
- filler posture;
- density-to-count mapping;
- budget target;
- timing ranges;
- camera/shot-job tendencies.
That is the difference between a preference memory that is merely visible and a preference memory that actually changes the generated plan.