feat: apply validated graph update proposals
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { describeGraph } from "./builder.js";
|
||||
import { graphUpdateSchema, situationGraphSchema } from "./schema.js";
|
||||
import {
|
||||
applyGraphUpdate,
|
||||
detectDuplicateNodeIds,
|
||||
findAffectedNodes,
|
||||
selectActiveUnknownCandidate,
|
||||
validateGraphReferences,
|
||||
validateGraphUpdate,
|
||||
} from "./utils.js";
|
||||
|
||||
function cloneJsonSafe(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function zodIssuesToErrors(error) {
|
||||
return (
|
||||
error?.issues?.map((issue) => {
|
||||
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
|
||||
return `${path}${issue.message}`;
|
||||
}) ?? ["Validation failed"]
|
||||
);
|
||||
}
|
||||
|
||||
function collectDuplicateEdgeIds(edges) {
|
||||
const counts = new Map();
|
||||
|
||||
for (const edge of edges) {
|
||||
counts.set(edge.id, (counts.get(edge.id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([edgeId, count]) => ({ edgeId, count }));
|
||||
}
|
||||
|
||||
function buildAffectedNodeIds(graph, proposal) {
|
||||
const affected = new Set(proposal.affectedNodeIds ?? []);
|
||||
|
||||
for (const update of proposal.updatedNodes ?? []) {
|
||||
affected.add(update.nodeId);
|
||||
for (const nodeId of findAffectedNodes(graph, update.nodeId)) {
|
||||
affected.add(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of proposal.resolvedUnknownNodeIds ?? []) {
|
||||
affected.add(nodeId);
|
||||
for (const affectedNodeId of findAffectedNodes(graph, nodeId)) {
|
||||
affected.add(affectedNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
return [...affected];
|
||||
}
|
||||
|
||||
function buildChangesApplied(proposal, affectedNodeIds) {
|
||||
return {
|
||||
addedNodeCount: proposal.addedNodes.length,
|
||||
updatedNodeCount: proposal.updatedNodes.length,
|
||||
addedEdgeCount: proposal.addedEdges.length,
|
||||
removedEdgeCount: proposal.removedEdgeIds.length,
|
||||
resolvedUnknownCount: proposal.resolvedUnknownNodeIds.length,
|
||||
affectedNodeCount: affectedNodeIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyValidatedProposal({ situationGraph, proposal }) {
|
||||
const graphValidation = situationGraphSchema.safeParse(situationGraph);
|
||||
const proposalValidation = graphUpdateSchema.safeParse(proposal);
|
||||
|
||||
const existingGraphReferenceValidation = graphValidation.success
|
||||
? validateGraphReferences(situationGraph)
|
||||
: null;
|
||||
|
||||
const existingDuplicateNodeIds = graphValidation.success
|
||||
? detectDuplicateNodeIds(situationGraph.nodes)
|
||||
: [];
|
||||
const existingDuplicateEdgeIds = graphValidation.success
|
||||
? collectDuplicateEdgeIds(situationGraph.edges)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!graphValidation.success ||
|
||||
!existingGraphReferenceValidation?.valid ||
|
||||
existingDuplicateNodeIds.length > 0 ||
|
||||
existingDuplicateEdgeIds.length > 0
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
stage: "graph_validation",
|
||||
errors: [
|
||||
...(!graphValidation.success
|
||||
? zodIssuesToErrors(graphValidation.error)
|
||||
: []),
|
||||
...(!existingGraphReferenceValidation?.valid
|
||||
? existingGraphReferenceValidation.errors
|
||||
: []),
|
||||
...existingDuplicateNodeIds.map(
|
||||
({ nodeId, count }) =>
|
||||
`Graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
||||
),
|
||||
...existingDuplicateEdgeIds.map(
|
||||
({ edgeId, count }) =>
|
||||
`Graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!proposalValidation.success) {
|
||||
return {
|
||||
success: false,
|
||||
stage: "proposal_compatibility",
|
||||
errors: zodIssuesToErrors(proposalValidation.error),
|
||||
};
|
||||
}
|
||||
|
||||
const validatedProposal = proposalValidation.data;
|
||||
const proposalCompatibilityErrors = [];
|
||||
const proposalGraphValidation = validateGraphUpdate(
|
||||
situationGraph,
|
||||
validatedProposal,
|
||||
);
|
||||
|
||||
if (!proposalGraphValidation.valid) {
|
||||
proposalCompatibilityErrors.push(...proposalGraphValidation.errors);
|
||||
}
|
||||
|
||||
const existingEdgeIds = new Set(situationGraph.edges.map((edge) => edge.id));
|
||||
const reachableNodeIds = new Set([
|
||||
...situationGraph.nodes.map((node) => node.id),
|
||||
...validatedProposal.addedNodes.map((node) => node.id),
|
||||
]);
|
||||
const addedEdgeDuplicateIds = collectDuplicateEdgeIds(
|
||||
validatedProposal.addedEdges,
|
||||
);
|
||||
proposalCompatibilityErrors.push(
|
||||
...addedEdgeDuplicateIds.map(
|
||||
({ edgeId, count }) =>
|
||||
`Proposal contains duplicate added edge ID: "${edgeId}" (${count} occurrences)`,
|
||||
),
|
||||
);
|
||||
|
||||
for (const edge of validatedProposal.addedEdges) {
|
||||
if (existingEdgeIds.has(edge.id)) {
|
||||
proposalCompatibilityErrors.push(
|
||||
`Cannot add edge with duplicate ID: "${edge.id}"`,
|
||||
);
|
||||
}
|
||||
if (!reachableNodeIds.has(edge.fromNodeId)) {
|
||||
proposalCompatibilityErrors.push(
|
||||
`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`,
|
||||
);
|
||||
}
|
||||
if (!reachableNodeIds.has(edge.toNodeId)) {
|
||||
proposalCompatibilityErrors.push(
|
||||
`Added edge references non-existent toNodeId: "${edge.toNodeId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const removedEdgeIds = new Set(validatedProposal.removedEdgeIds);
|
||||
for (const edgeId of removedEdgeIds) {
|
||||
if (!existingEdgeIds.has(edgeId)) {
|
||||
proposalCompatibilityErrors.push(
|
||||
`Cannot remove non-existent edge: "${edgeId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const combinedNodeDuplicates = detectDuplicateNodeIds([
|
||||
...situationGraph.nodes,
|
||||
...validatedProposal.addedNodes,
|
||||
]);
|
||||
proposalCompatibilityErrors.push(
|
||||
...combinedNodeDuplicates.map(
|
||||
({ nodeId, count }) =>
|
||||
`Proposal would produce duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
||||
),
|
||||
);
|
||||
|
||||
if (proposalCompatibilityErrors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
stage: "proposal_compatibility",
|
||||
errors: proposalCompatibilityErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const graphSnapshot = cloneJsonSafe(situationGraph);
|
||||
const proposalSnapshot = cloneJsonSafe(validatedProposal);
|
||||
const previousActiveUnknownNodeId = graphSnapshot.activeUnknownNodeId ?? null;
|
||||
const affectedNodeIds = buildAffectedNodeIds(graphSnapshot, proposalSnapshot);
|
||||
|
||||
const applied = applyGraphUpdate(graphSnapshot, proposalSnapshot);
|
||||
if (!applied.success) {
|
||||
return {
|
||||
success: false,
|
||||
stage: "application",
|
||||
errors: applied.errors,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedSituationGraph = {
|
||||
...graphSnapshot,
|
||||
nodes: applied.nodes,
|
||||
edges: applied.edges,
|
||||
resolvedNodeIds: applied.resolvedNodeIds,
|
||||
};
|
||||
|
||||
const activeUnknownWasResolved =
|
||||
previousActiveUnknownNodeId != null &&
|
||||
updatedSituationGraph.resolvedNodeIds.includes(previousActiveUnknownNodeId);
|
||||
|
||||
let newActiveUnknownNodeId = previousActiveUnknownNodeId;
|
||||
if (activeUnknownWasResolved) {
|
||||
newActiveUnknownNodeId = null;
|
||||
}
|
||||
|
||||
const remainingUnknownExists =
|
||||
newActiveUnknownNodeId != null &&
|
||||
updatedSituationGraph.nodes.some(
|
||||
(node) =>
|
||||
node.id === newActiveUnknownNodeId &&
|
||||
node.kind === "unknown" &&
|
||||
!updatedSituationGraph.resolvedNodeIds.includes(node.id),
|
||||
);
|
||||
|
||||
if (!remainingUnknownExists) {
|
||||
newActiveUnknownNodeId =
|
||||
selectActiveUnknownCandidate(
|
||||
updatedSituationGraph,
|
||||
updatedSituationGraph.resolvedNodeIds,
|
||||
)?.nodeId ?? null;
|
||||
}
|
||||
|
||||
updatedSituationGraph.activeUnknownNodeId = newActiveUnknownNodeId;
|
||||
updatedSituationGraph.currentSummary = describeGraph(updatedSituationGraph);
|
||||
|
||||
const resultGraphValidation = situationGraphSchema.safeParse(
|
||||
updatedSituationGraph,
|
||||
);
|
||||
const resultReferenceValidation = resultGraphValidation.success
|
||||
? validateGraphReferences(updatedSituationGraph)
|
||||
: null;
|
||||
const resultDuplicateNodeIds = resultGraphValidation.success
|
||||
? detectDuplicateNodeIds(updatedSituationGraph.nodes)
|
||||
: [];
|
||||
const resultDuplicateEdgeIds = resultGraphValidation.success
|
||||
? collectDuplicateEdgeIds(updatedSituationGraph.edges)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!resultGraphValidation.success ||
|
||||
!resultReferenceValidation?.valid ||
|
||||
resultDuplicateNodeIds.length > 0 ||
|
||||
resultDuplicateEdgeIds.length > 0
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
stage: "result_validation",
|
||||
errors: [
|
||||
...(!resultGraphValidation.success
|
||||
? zodIssuesToErrors(resultGraphValidation.error)
|
||||
: []),
|
||||
...(!resultReferenceValidation?.valid
|
||||
? resultReferenceValidation.errors
|
||||
: []),
|
||||
...resultDuplicateNodeIds.map(
|
||||
({ nodeId, count }) =>
|
||||
`Updated graph contains duplicate node ID: "${nodeId}" (${count} occurrences)`,
|
||||
),
|
||||
...resultDuplicateEdgeIds.map(
|
||||
({ edgeId, count }) =>
|
||||
`Updated graph contains duplicate edge ID: "${edgeId}" (${count} occurrences)`,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedSituationGraph,
|
||||
graphUpdate: validatedProposal,
|
||||
affectedNodeIds,
|
||||
resolvedUnknownNodeIds: validatedProposal.resolvedUnknownNodeIds,
|
||||
previousActiveUnknownNodeId,
|
||||
newActiveUnknownNodeId,
|
||||
changesApplied: buildChangesApplied(validatedProposal, affectedNodeIds),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user