refactor(context): define pure functional ContextPatch and ProcessArgs

This commit is contained in:
Your Name
2026-04-07 23:25:20 +00:00
parent cd14eb40ce
commit b1d62a0b9d
2 changed files with 49 additions and 226 deletions
@@ -1,211 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Episode, IrNode } from './types.js';
export interface MutationRecord {
episodeId: string;
type: 'modified' | 'inserted' | 'replaced' | 'deleted';
action: string;
originalIds?: string[]; // If replaced
episode?: Episode; // For new or modified
}
export class EpisodeEditor {
private originalMap: Map<string, Episode>;
private workingOrder: string[];
private workingMap: Map<string, Episode>;
private mutations: MutationRecord[] = [];
private targetNodes?: Set<string>;
constructor(episodes: Episode[], targetNodes?: Set<string>) {
this.originalMap = new Map(episodes.map((e) => [e.id, e]));
this.workingOrder = episodes.map((e) => e.id);
this.workingMap = new Map(episodes.map((e) => [e.id, e]));
this.targetNodes = targetNodes;
}
/**
* Provides a readonly view of the specific targets this processor is allowed to touch.
* If no targets were specified (e.g. fallback pipeline), it returns the entire history.
*/
get targets(): Array<{ episode: Episode; node: IrNode | Episode }> {
const results: Array<{ episode: Episode; node: IrNode | Episode }> = [];
for (const epId of this.workingOrder) {
const ep = this.workingMap.get(epId)!;
// If we don't have restricted targets, everything is a target
if (!this.targetNodes) {
results.push({ episode: ep, node: ep });
continue;
}
// Check episode itself
if (this.targetNodes.has(ep.id)) {
results.push({ episode: ep, node: ep });
}
// Check trigger
if (this.targetNodes.has(ep.trigger.id)) {
results.push({ episode: ep, node: ep.trigger });
}
// Check steps
for (const step of ep.steps) {
if (this.targetNodes.has(step.id)) {
results.push({ episode: ep, node: step });
}
}
// Check yield
if (ep.yield && this.targetNodes.has(ep.yield.id)) {
results.push({ episode: ep, node: ep.yield });
}
}
return results;
}
/**
* Returns the full history for READ-ONLY context purposes.
* Processors should not iterate over this array to decide what to mutate.
* They should iterate over `editor.targets`.
*/
getFullHistory(): readonly Episode[] {
return this.workingOrder.map((id) => this.workingMap.get(id)!);
}
private isTargeted(episodeId: string): boolean {
if (!this.targetNodes) return true;
if (this.targetNodes.has(episodeId)) return true;
const ep = this.workingMap.get(episodeId);
if (!ep) return false;
if (this.targetNodes.has(ep.trigger.id)) return true;
if (ep.yield && this.targetNodes.has(ep.yield.id)) return true;
for (const step of ep.steps) {
if (this.targetNodes.has(step.id)) return true;
}
return false;
}
/**
* Safely edits an existing episode.
* The framework will handle deeply cloning the episode before passing it to the mutator.
* Throws an error if the processor attempts to edit a non-targeted node.
*/
editEpisode(id: string, action: string, mutator: (draft: Episode) => void) {
if (!this.isTargeted(id)) {
throw new Error(`EpisodeEditor: Processor attempted to edit Episode ${id} which is outside its allowed target scope.`);
}
const ep = this.workingMap.get(id);
if (!ep) return;
// Lazy deep clone only if it's the original reference
if (ep === this.originalMap.get(id)) {
const clone = structuredClone(ep);
this.workingMap.set(id, clone);
}
const draft = this.workingMap.get(id)!;
mutator(draft);
// Log mutation if not already tracked as modified/inserted/replaced
if (!this.mutations.find((m) => m.episodeId === id)) {
this.mutations.push({
episodeId: id,
type: 'modified',
action,
episode: draft,
});
}
}
/**
* Inserts a brand new episode into the graph at the specified index.
*/
insertEpisode(index: number, newEpisode: Episode, action: string) {
this.workingMap.set(newEpisode.id, newEpisode);
this.workingOrder.splice(index, 0, newEpisode.id);
this.mutations.push({
episodeId: newEpisode.id,
type: 'inserted',
action,
episode: newEpisode,
});
}
/**
* Replaces a set of older episodes with a single new episode (e.g., a Summary or Snapshot).
* It inserts the new episode at the lowest index of the removed episodes.
*/
replaceEpisodes(oldIds: string[], newEpisode: Episode, action: string) {
for (const id of oldIds) {
if (!this.isTargeted(id)) {
throw new Error(`EpisodeEditor: Processor attempted to replace Episode ${id} which is outside its allowed target scope.`);
}
}
const indices = oldIds
.map((id) => this.workingOrder.indexOf(id))
.filter((i) => i !== -1);
if (indices.length === 0) return;
const insertIndex = Math.min(...indices);
// Remove old
this.workingOrder = this.workingOrder.filter((id) => !oldIds.includes(id));
for (const id of oldIds) {
this.workingMap.delete(id);
}
// Insert new
this.workingOrder.splice(insertIndex, 0, newEpisode.id);
this.workingMap.set(newEpisode.id, newEpisode);
this.mutations.push({
episodeId: newEpisode.id,
type: 'replaced',
action,
originalIds: oldIds,
episode: newEpisode,
});
}
/**
* Removes episodes from the graph completely (e.g., emergency truncation).
*/
removeEpisodes(oldIds: string[], action: string) {
for (const id of oldIds) {
if (!this.isTargeted(id)) {
throw new Error(`EpisodeEditor: Processor attempted to remove Episode ${id} which is outside its allowed target scope.`);
}
}
this.workingOrder = this.workingOrder.filter((id) => !oldIds.includes(id));
for (const id of oldIds) {
this.workingMap.delete(id);
this.mutations.push({ episodeId: id, type: 'deleted', action });
}
}
/**
* Retrieves the final, finalized array of episodes.
* Called by the Orchestrator.
*/
getFinalEpisodes(): Episode[] {
return this.workingOrder.map((id) => this.workingMap.get(id)!);
}
/**
* Retrieves a log of all structural and property mutations performed by this editor.
* Called by the Orchestrator to emit VariantReady events.
*/
getMutations(): MutationRecord[] {
return this.mutations;
}
}
+49 -15
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { EpisodeEditor } from './ir/episodeEditor.js';
import type { ConcreteNode, IrMetadata } from './ir/types.js';
/**
* State object passed through the processing pipeline.
@@ -19,36 +19,70 @@ export interface ContextAccountingState {
readonly deficitTokens: number;
/**
* Set of Episode IDs that the orchestrator has deemed highly protected.
* Processors should generally skip mutating these episodes unless doing proactive/required transforms.
* Set of Logical Node IDs (like Tasks or Episodes) that the orchestrator has deemed highly protected.
* Processors should generally skip mutating Concrete Nodes that belong to these parents.
*/
readonly protectedEpisodeIds: Set<string>;
readonly protectedLogicalIds: ReadonlySet<string>;
/**
* True if currentTokens <= retainedTokens.
*/
readonly isBudgetSatisfied: boolean;
}
/**
* If this pipeline was triggered by a specific event (e.g., a new turn),
* this contains the specific Node IDs (Episodes, Steps, or Triggers) that should be evaluated.
* If undefined, the processor may evaluate the entire graph.
/**
* A declarative instruction from a processor on how to modify the Ship.
* Applied sequentially by the Orchestrator (Reducer).
*/
export interface ContextPatch {
/** The IDs of the Concrete Nodes to remove from the Ship. */
readonly removedIds: ReadonlyArray<string>;
/**
* The new synthetic Concrete Nodes (e.g., MaskedTool, Snapshot) to insert.
* If omitted or empty, this patch acts as a pure deletion.
*/
readonly targetNodeIds?: Set<string>;
readonly insertedNodes?: ReadonlyArray<ConcreteNode>;
/** The index at which to insert the new nodes. If omitted, they replace the first removedId. */
readonly insertionIndex?: number;
/** Audit metadata explaining who made this patch, when, and why. */
readonly metadata: IrMetadata;
}
export interface ProcessArgs {
/** The flat, sequential array of current renderable nodes (The Ship). */
readonly ship: ReadonlyArray<ConcreteNode>;
/**
* The specific subset of Concrete Node IDs that triggered this execution.
* For 'new_message', these are the new nodes. For 'retained_exceeded', the aged-out nodes.
*/
readonly triggerTargets: ReadonlySet<string>;
/** The token budget and accounting state. */
readonly state: ContextAccountingState;
/**
* An escape hatch allowing the processor to query the original, uncompressed
* state of a node from the Pristine Graph.
*/
readonly getPristineNode: (id: string) => ConcreteNode | undefined;
}
/**
* Interface for all context degradation strategies.
* Processors are pure functions that return ContextPatches.
*/
export interface ContextProcessor {
/** Unique ID for registry mapping. */
readonly id: string;
/** Unique name for telemetry and logging. */
readonly name: string;
/**
* Processes the episodic history payload via the provided EpisodeEditor, based on the current accounting state.
* Processors should safely mutate or replace episodes using the editor's API.
*/
process(editor: EpisodeEditor, state: ContextAccountingState): Promise<void>;
/** Returns an array of declarative patches to apply to the Ship. */
process(args: ProcessArgs): Promise<ContextPatch[]>;
}
/**
@@ -64,4 +98,4 @@ export interface BackstopTargetOptions {
target?: 'incremental' | 'freeNTokens' | 'max';
/** If target is 'freeNTokens', this is the amount of tokens to clear. */
freeTokensTarget?: number;
}
}