Files
openmaic/OpenMAIC/lib/pbl/v2/runtime/fold.ts
2026-08-16 14:58:47 +08:00

383 lines
12 KiB
TypeScript

import type { RuntimeRecord } from '@openmaic/dsl';
import type {
PBLEngagementEvent,
PBLChatMessage,
PBLProjectV2,
PBLRuntimeEvent,
} from '@/lib/pbl/v2/types';
import { MAX_ENGAGEMENT_EVENTS } from '@/lib/pbl/v2/operations/kernel/engagement';
import {
applyLearnerState,
extractLearnerState,
stripToDesignTemplate,
type PBLLearnerState,
} from './learner-state';
import {
PBL_RUNTIME_EVENT_KINDS_REQUIRING_ATTACHMENT,
type PBLEngagementEventRecordPayload,
type PBLRuntimeEventRecordPayload,
type PBLRuntimeStorePayload,
type PBLSnapshotRecordPayload,
} from './record-payloads';
import { clone } from './clone';
export interface PBLFoldGap {
recordId: string;
seq: number;
eventId?: string;
kind: string;
reason: string;
}
export interface PBLFoldDiagnostics {
gaps: PBLFoldGap[];
}
export interface FoldPBLRuntimeArgs {
designTemplate: PBLProjectV2;
records: readonly RuntimeRecord[];
}
export interface FoldPBLRuntimeResult {
learnerState: PBLLearnerState;
diagnostics: PBLFoldDiagnostics;
}
function isObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function isRuntimeEvent(value: unknown): value is PBLRuntimeEvent {
return (
isObject(value) &&
typeof value.id === 'string' &&
typeof value.kind === 'string' &&
'actorType' in value
);
}
function isEngagementEvent(value: unknown): value is PBLEngagementEvent {
return (
isObject(value) &&
typeof value.id === 'string' &&
typeof value.kind === 'string' &&
typeof value.ts === 'string' &&
!('actorType' in value)
);
}
function normalizePayload(payload: unknown): PBLRuntimeStorePayload | undefined {
if (!isObject(payload)) return undefined;
if (payload.kind === 'pbl_runtime_event' && isRuntimeEvent(payload.event)) {
return payload as unknown as PBLRuntimeStorePayload;
}
if (payload.kind === 'pbl_engagement_event' && isEngagementEvent(payload.event)) {
return payload as unknown as PBLRuntimeStorePayload;
}
if (payload.kind === 'pbl_snapshot' && isObject(payload.learnerState)) {
return payload as unknown as PBLRuntimeStorePayload;
}
if (isRuntimeEvent(payload)) {
return {
kind: 'pbl_runtime_event',
payloadVersion: 1,
event: payload,
attachment: null,
attachmentMissingReason: PBL_RUNTIME_EVENT_KINDS_REQUIRING_ATTACHMENT.has(payload.kind)
? 'legacy_raw_record_missing_attachment'
: undefined,
};
}
if (isEngagementEvent(payload)) {
return {
kind: 'pbl_engagement_event',
payloadVersion: 1,
event: payload,
};
}
return undefined;
}
function eventKey(kind: 'runtime' | 'engagement', id: string): string {
return `${kind}:${id}`;
}
function addGap(
gaps: PBLFoldGap[],
record: RuntimeRecord,
payload: { kind: string; event?: { id?: string; kind?: string } } | undefined,
reason: string,
): void {
const event = payload?.event;
gaps.push({
recordId: record.id,
seq: record.seq,
eventId: event?.id,
kind: event?.kind ?? payload?.kind ?? 'unknown',
reason,
});
}
function findMilestone(state: PBLLearnerState, milestoneId: string) {
return state.milestones.find((milestone) => milestone.id === milestoneId);
}
function findMicrotask(state: PBLLearnerState, microtaskId: string) {
for (const milestone of state.milestones) {
const microtask = milestone.microtasks.find((candidate) => candidate.id === microtaskId);
if (microtask) return { milestone, microtask };
}
return undefined;
}
function upsertThreadMessage(
state: PBLLearnerState,
threadId: string,
message: PBLChatMessage,
): void {
let thread = state.threads.find((candidate) => candidate.agentId === threadId);
if (!thread) {
thread = { agentId: threadId, messages: [] };
state.threads.push(thread);
}
if (!thread.messages.some((candidate) => candidate.id === message.id)) {
thread.messages.push(clone(message));
}
}
function applyRuntimeEvent(
state: PBLLearnerState,
payload: PBLRuntimeEventRecordPayload,
record: RuntimeRecord,
gaps: PBLFoldGap[],
): 'reset' | undefined {
const event = payload.event;
switch (event.kind) {
case 'project_reset':
return 'reset';
case 'status_changed': {
if (event.entityType === 'project') {
state.status = event.to as PBLLearnerState['status'];
return undefined;
}
if (event.entityType === 'ui_phase') {
state.uiPhase = event.to as PBLLearnerState['uiPhase'];
return undefined;
}
if (event.entityType === 'milestone') {
const milestone = findMilestone(state, event.entityId);
if (!milestone) {
addGap(gaps, record, payload, 'milestone_not_found');
return undefined;
}
milestone.status = event.to as typeof milestone.status;
if (payload.attachment?.kind === 'status') {
milestone.internalAssessment = clone(payload.attachment.milestone?.internalAssessment);
}
return undefined;
}
const found = findMicrotask(state, event.entityId);
if (!found) {
addGap(gaps, record, payload, 'microtask_not_found');
return undefined;
}
found.microtask.status = event.to as typeof found.microtask.status;
if (payload.attachment?.kind === 'status') {
found.microtask.internalAssessment = clone(
payload.attachment.microtask?.internalAssessment,
);
found.microtask.completionReason = payload.attachment.microtask?.completionReason;
found.microtask.engagement = clone(payload.attachment.microtask?.engagement);
}
return undefined;
}
case 'message_created':
if (payload.attachment?.kind !== 'message') {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'message_attachment_missing',
);
return undefined;
}
upsertThreadMessage(state, event.threadId, payload.attachment.message);
return undefined;
case 'submission_created':
if (payload.attachment?.kind !== 'submission') {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'submission_attachment_missing',
);
return undefined;
}
{
const attachment = payload.attachment;
if (!state.submissions.some((submission) => submission.id === attachment.submission.id)) {
state.submissions.push(clone(attachment.submission));
}
}
return undefined;
case 'evaluation_created':
if (payload.attachment?.kind !== 'evaluation') {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'evaluation_attachment_missing',
);
return undefined;
}
{
const attachment = payload.attachment;
if (!state.evaluations.some((evaluation) => evaluation.id === attachment.evaluation.id)) {
state.evaluations.push(clone(attachment.evaluation));
}
}
return undefined;
case 'handover_staged':
if (payload.attachment?.kind !== 'handover') {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'handover_attachment_missing',
);
return undefined;
}
state.pendingHandover = clone(payload.attachment.handover);
return undefined;
case 'handover_consumed':
if (payload.attachment?.kind === 'handover') {
state.pendingHandover = clone(payload.attachment.handover);
} else if (state.pendingHandover) {
state.pendingHandover = { ...state.pendingHandover, consumed: true };
} else {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'handover_attachment_missing',
);
}
return undefined;
case 'task_completion_staged':
if (payload.attachment?.kind !== 'pending_task_completion') {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'pending_task_completion_attachment_missing',
);
return undefined;
}
state.pendingTaskCompletion = clone(payload.attachment.pendingTaskCompletion);
return undefined;
case 'task_completion_cleared':
state.pendingTaskCompletion = undefined;
return undefined;
case 'proficiency_updated':
if (payload.attachment?.kind !== 'proficiency') {
addGap(
gaps,
record,
payload,
payload.attachmentMissingReason ?? 'proficiency_attachment_missing',
);
return undefined;
}
state.proficiencyAssessment = clone(payload.attachment.assessment);
return undefined;
case 'tool_call_started':
case 'tool_call_succeeded':
case 'tool_call_failed':
return undefined;
default: {
const _exhaustive: never = event;
void _exhaustive;
addGap(gaps, record, payload, 'unhandled_event_kind');
return undefined;
}
}
}
function applyEngagementEvent(
state: PBLLearnerState,
payload: PBLEngagementEventRecordPayload,
): void {
if (!state.engagementEvents.some((event) => event.id === payload.event.id)) {
state.engagementEvents.push(clone(payload.event));
if (state.engagementEvents.length > MAX_ENGAGEMENT_EVENTS) {
state.engagementEvents.splice(0, state.engagementEvents.length - MAX_ENGAGEMENT_EVENTS);
}
}
}
function snapshotIsUsable(snapshot: PBLSnapshotRecordPayload, currentEpoch: number): boolean {
return snapshot.epoch >= currentEpoch;
}
export function foldPBLRuntime({
designTemplate,
records,
}: FoldPBLRuntimeArgs): FoldPBLRuntimeResult {
const baselineProject = stripToDesignTemplate(designTemplate);
const baseline = extractLearnerState(baselineProject);
let state = clone(baseline);
let epoch = state.runtimeResetEpoch ?? 0;
const gaps: PBLFoldGap[] = [];
const seen = new Set<string>();
for (const record of [...records].sort((a, b) => a.seq - b.seq)) {
const payload = normalizePayload(record.payload);
if (!payload) {
addGap(gaps, record, undefined, 'payload_malformed');
continue;
}
if (payload.kind === 'pbl_snapshot') {
if (!snapshotIsUsable(payload, epoch)) continue;
epoch = payload.epoch;
state = clone(payload.learnerState);
state.runtimeResetEpoch = payload.epoch === 0 ? state.runtimeResetEpoch : payload.epoch;
gaps.length = 0;
if (payload.anchor.lastRuntimeEventId) {
seen.add(eventKey('runtime', payload.anchor.lastRuntimeEventId));
}
if (payload.anchor.lastEngagementEventId) {
seen.add(eventKey('engagement', payload.anchor.lastEngagementEventId));
}
continue;
}
if (payload.kind === 'pbl_engagement_event') {
const key = eventKey('engagement', payload.event.id);
if (seen.has(key)) continue;
seen.add(key);
applyEngagementEvent(state, payload);
continue;
}
const key = eventKey('runtime', payload.event.id);
if (seen.has(key)) continue;
seen.add(key);
const result = applyRuntimeEvent(state, payload, record, gaps);
if (result === 'reset') {
const proficiencyAssessment = clone(state.proficiencyAssessment);
epoch += 1;
state = clone(baseline);
state.proficiencyAssessment = proficiencyAssessment;
state.runtimeResetEpoch = epoch;
gaps.length = 0;
}
}
// Normalise through the public apply/extract boundary so the result uses the
// same defaulting rules as hydration.
const normalized = extractLearnerState(applyLearnerState(baselineProject, state));
return { learnerState: normalized, diagnostics: { gaps } };
}