mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
303 lines
9.2 KiB
TypeScript
303 lines
9.2 KiB
TypeScript
import canonicalize from "canonicalize";
|
|
|
|
import type { ProcessObservation } from "./processObservation.js";
|
|
import { comparableProcessObservationPayload } from "./processObservation.js";
|
|
import {
|
|
PROCESS_REACTIVE_LIMITS,
|
|
type ProcessReactiveFrontier,
|
|
type ProcessReactiveTrigger,
|
|
} from "./processReactiveScenario.js";
|
|
import type { ProcessReactiveSnapshot } from "./processReactiveRuntime.js";
|
|
|
|
/** Internal bounded trigger-match result used by the pure reducer. */
|
|
export interface ProcessReactiveTriggerMatch {
|
|
readonly matched: boolean;
|
|
readonly eventIds: readonly string[];
|
|
readonly consumeIds: readonly string[];
|
|
readonly lastOrder: number;
|
|
readonly overflow: boolean;
|
|
}
|
|
|
|
interface EvaluationBudget {
|
|
remaining: number;
|
|
}
|
|
|
|
interface TriggerMatchContext {
|
|
readonly snapshot: ProcessReactiveSnapshot;
|
|
readonly budget: EvaluationBudget;
|
|
readonly afterOrder: number;
|
|
readonly upperOrder: number | null;
|
|
}
|
|
|
|
const spendEvaluationWork = (
|
|
budget: EvaluationBudget,
|
|
units: number,
|
|
): boolean => {
|
|
if (units > budget.remaining) return false;
|
|
budget.remaining -= units;
|
|
return true;
|
|
};
|
|
|
|
const noMatch = (overflow = false): ProcessReactiveTriggerMatch => ({
|
|
matched: false,
|
|
eventIds: [],
|
|
consumeIds: [],
|
|
lastOrder: -1,
|
|
overflow,
|
|
});
|
|
|
|
const frontierOrder = (
|
|
frontier: ProcessReactiveFrontier,
|
|
snapshot: ProcessReactiveSnapshot,
|
|
): number | null => {
|
|
switch (frontier.kind) {
|
|
case "scenario_start":
|
|
return -1;
|
|
case "state_entry":
|
|
return snapshot.state_entry_capture_order - 1;
|
|
case "checkpoint":
|
|
return (
|
|
snapshot.checkpoints.findLast(({ name }) => name === frontier.name)
|
|
?.capture_order ?? null
|
|
);
|
|
case "event_id":
|
|
return (
|
|
snapshot.observations.find(
|
|
({ event_id }) => event_id === frontier.event_id,
|
|
)?.capture_order ?? null
|
|
);
|
|
}
|
|
};
|
|
|
|
const eligibleObservations = (
|
|
frontier: ProcessReactiveFrontier,
|
|
context: TriggerMatchContext,
|
|
): readonly ProcessObservation[] => {
|
|
const frontierValue = frontierOrder(frontier, context.snapshot);
|
|
if (frontierValue === null) return [];
|
|
const lowerBound = Math.max(frontierValue, context.afterOrder);
|
|
const consumed = new Set(context.snapshot.consumed_event_ids);
|
|
return context.snapshot.observations.filter(
|
|
({ capture_order, event_id }) =>
|
|
capture_order > lowerBound &&
|
|
(context.upperOrder === null || capture_order <= context.upperOrder) &&
|
|
!consumed.has(event_id),
|
|
);
|
|
};
|
|
|
|
const canonicalJson = (value: unknown): string => {
|
|
const serialized = canonicalize(value);
|
|
if (serialized === undefined)
|
|
throw new TypeError("Reactive scenario value is not canonical JSON");
|
|
return serialized;
|
|
};
|
|
|
|
const matchEvent = (
|
|
trigger: Extract<ProcessReactiveTrigger, { readonly kind: "event" }>,
|
|
context: TriggerMatchContext,
|
|
): ProcessReactiveTriggerMatch => {
|
|
const expected = canonicalJson(
|
|
comparableProcessObservationPayload(trigger.exact, trigger.ignore_fields),
|
|
);
|
|
const matches: ProcessObservation[] = [];
|
|
for (const observation of eligibleObservations(trigger.since, context)) {
|
|
if (!spendEvaluationWork(context.budget, 1)) return noMatch(true);
|
|
if (
|
|
observation.source === trigger.source &&
|
|
canonicalJson(
|
|
comparableProcessObservationPayload(
|
|
observation.payload,
|
|
trigger.ignore_fields,
|
|
),
|
|
) === expected
|
|
)
|
|
matches.push(observation);
|
|
}
|
|
if (
|
|
matches.length < trigger.cardinality.min ||
|
|
matches.length > trigger.cardinality.max
|
|
)
|
|
return noMatch();
|
|
const eventIds = matches.map(({ event_id }) => event_id);
|
|
return {
|
|
matched: true,
|
|
eventIds,
|
|
consumeIds: trigger.consume ? eventIds : [],
|
|
lastOrder: matches.at(-1)?.capture_order ?? context.afterOrder,
|
|
overflow: false,
|
|
};
|
|
};
|
|
|
|
const occurrenceRange = (
|
|
haystack: string,
|
|
needle: string,
|
|
occurrence: number,
|
|
): { readonly start: number; readonly end: number } | null => {
|
|
let offset = 0;
|
|
let start = -1;
|
|
for (let found = 0; found < occurrence; found += 1) {
|
|
start = haystack.indexOf(needle, offset);
|
|
if (start < 0) return null;
|
|
offset = start + needle.length;
|
|
}
|
|
return { start, end: offset };
|
|
};
|
|
|
|
const matchTerminalText = (
|
|
trigger: Extract<ProcessReactiveTrigger, { readonly kind: "terminal_text" }>,
|
|
context: TriggerMatchContext,
|
|
): ProcessReactiveTriggerMatch => {
|
|
const chunks: { observation: ProcessObservation; text: string }[] = [];
|
|
for (const observation of eligibleObservations(trigger.since, context)) {
|
|
if (!spendEvaluationWork(context.budget, 1)) return noMatch(true);
|
|
if (
|
|
observation.source === "terminal_raw" &&
|
|
typeof observation.payload === "object" &&
|
|
observation.payload !== null &&
|
|
!Array.isArray(observation.payload) &&
|
|
typeof observation.payload["data"] === "string"
|
|
)
|
|
chunks.push({ observation, text: observation.payload["data"] });
|
|
}
|
|
let rawBytes = 0;
|
|
for (const { text } of chunks) {
|
|
if (text.length > PROCESS_REACTIVE_LIMITS.terminalMatchBytes)
|
|
return noMatch(true);
|
|
const bytes = Buffer.byteLength(text);
|
|
rawBytes += bytes;
|
|
if (
|
|
rawBytes > PROCESS_REACTIVE_LIMITS.terminalMatchBytes ||
|
|
!spendEvaluationWork(context.budget, bytes)
|
|
)
|
|
return noMatch(true);
|
|
}
|
|
const range = occurrenceRange(
|
|
chunks.map(({ text }) => text).join(""),
|
|
trigger.literal,
|
|
trigger.occurrence,
|
|
);
|
|
if (range === null) return noMatch();
|
|
let firstChunk = 0;
|
|
let lastChunk = 0;
|
|
let characters = 0;
|
|
for (const [index, chunk] of chunks.entries()) {
|
|
characters += chunk.text.length;
|
|
if (characters <= range.start) firstChunk = index + 1;
|
|
if (characters >= range.end) {
|
|
lastChunk = index;
|
|
break;
|
|
}
|
|
}
|
|
const matched = chunks
|
|
.slice(firstChunk, lastChunk + 1)
|
|
.map(({ observation }) => observation);
|
|
const eventIds = matched.map(({ event_id }) => event_id);
|
|
return {
|
|
matched: true,
|
|
eventIds,
|
|
consumeIds: trigger.consume ? eventIds : [],
|
|
lastOrder: matched.at(-1)?.capture_order ?? context.afterOrder,
|
|
overflow: false,
|
|
};
|
|
};
|
|
|
|
const mergeMatches = (
|
|
matches: readonly ProcessReactiveTriggerMatch[],
|
|
): ProcessReactiveTriggerMatch => ({
|
|
matched: matches.every(({ matched }) => matched),
|
|
eventIds: [...new Set(matches.flatMap(({ eventIds }) => eventIds))],
|
|
consumeIds: [...new Set(matches.flatMap(({ consumeIds }) => consumeIds))],
|
|
lastOrder: Math.max(-1, ...matches.map(({ lastOrder }) => lastOrder)),
|
|
overflow: matches.some(({ overflow }) => overflow),
|
|
});
|
|
|
|
const matchEarliestPrefix = (
|
|
trigger: ProcessReactiveTrigger,
|
|
context: TriggerMatchContext,
|
|
): ProcessReactiveTriggerMatch => {
|
|
for (const observation of context.snapshot.observations) {
|
|
if (observation.capture_order <= context.afterOrder) continue;
|
|
if (
|
|
context.upperOrder !== null &&
|
|
observation.capture_order > context.upperOrder
|
|
)
|
|
break;
|
|
const matched = matchTrigger(trigger, {
|
|
...context,
|
|
upperOrder: observation.capture_order,
|
|
});
|
|
if (matched.matched || matched.overflow) return matched;
|
|
}
|
|
return noMatch();
|
|
};
|
|
|
|
const matchTrigger = (
|
|
trigger: ProcessReactiveTrigger,
|
|
context: TriggerMatchContext,
|
|
): ProcessReactiveTriggerMatch => {
|
|
if (trigger.kind === "event") return matchEvent(trigger, context);
|
|
if (trigger.kind === "terminal_text")
|
|
return matchTerminalText(trigger, context);
|
|
if (trigger.kind === "all")
|
|
return mergeMatches(
|
|
trigger.triggers.map((child) => matchTrigger(child, context)),
|
|
);
|
|
if (trigger.kind === "any") {
|
|
const evaluated = trigger.triggers.map((child) =>
|
|
matchTrigger(child, context),
|
|
);
|
|
const matches = evaluated.filter(({ matched }) => matched);
|
|
return matches.length === 0
|
|
? noMatch(evaluated.some(({ overflow }) => overflow))
|
|
: mergeMatches(matches);
|
|
}
|
|
if (trigger.kind === "sequence") {
|
|
const matches: ProcessReactiveTriggerMatch[] = [];
|
|
let cursor = context.afterOrder;
|
|
for (const child of trigger.triggers) {
|
|
const matched = matchEarliestPrefix(child, {
|
|
...context,
|
|
afterOrder: cursor,
|
|
});
|
|
if (!matched.matched) return noMatch(matched.overflow);
|
|
matches.push(matched);
|
|
cursor = matched.lastOrder;
|
|
}
|
|
return mergeMatches(matches);
|
|
}
|
|
const matches: ProcessReactiveTriggerMatch[] = [];
|
|
let cursor = context.afterOrder;
|
|
while (matches.length < trigger.max) {
|
|
const matched = matchEarliestPrefix(trigger.trigger, {
|
|
...context,
|
|
afterOrder: cursor,
|
|
});
|
|
if (!matched.matched || matched.lastOrder <= cursor) break;
|
|
matches.push(matched);
|
|
cursor = matched.lastOrder;
|
|
}
|
|
if (matches.length === trigger.max) {
|
|
const excess = matchEarliestPrefix(trigger.trigger, {
|
|
...context,
|
|
afterOrder: cursor,
|
|
});
|
|
if (excess.matched || excess.overflow) return noMatch(excess.overflow);
|
|
}
|
|
if (matches.length < trigger.min)
|
|
return noMatch(matches.some(({ overflow }) => overflow));
|
|
return mergeMatches(matches);
|
|
};
|
|
|
|
/** Match one trigger using a shared deterministic work budget. */
|
|
export const matchProcessReactiveTrigger = (
|
|
trigger: ProcessReactiveTrigger,
|
|
snapshot: ProcessReactiveSnapshot,
|
|
budget: EvaluationBudget,
|
|
): ProcessReactiveTriggerMatch =>
|
|
matchTrigger(trigger, {
|
|
snapshot,
|
|
budget,
|
|
afterOrder: -1,
|
|
upperOrder: null,
|
|
});
|