437 lines
13 KiB
JavaScript
437 lines
13 KiB
JavaScript
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 normaliseText(value) {
|
|
return String(value || "")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function buildResolvedUnknownUpdate(node) {
|
|
return {
|
|
nodeId: node.id,
|
|
previousStatus: node.status ?? null,
|
|
newStatus: "resolved",
|
|
previousValue: node.value ?? null,
|
|
newValue: node.value ?? null,
|
|
reason:
|
|
"Resolved because the proposal explicitly marked this unknown as resolved.",
|
|
};
|
|
}
|
|
|
|
function reconcileResolutionSemantics(graph, proposal) {
|
|
const nextProposal = cloneJsonSafe(proposal);
|
|
const errors = [];
|
|
const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
|
|
const updatedNodeById = new Map(
|
|
nextProposal.updatedNodes.map((nodeUpdate) => [
|
|
nodeUpdate.nodeId,
|
|
nodeUpdate,
|
|
]),
|
|
);
|
|
|
|
for (const resolvedUnknownNodeId of nextProposal.resolvedUnknownNodeIds) {
|
|
const existingNode = graphNodeById.get(resolvedUnknownNodeId);
|
|
|
|
if (!existingNode) {
|
|
errors.push(
|
|
`Resolved unknown must reference an existing node: "${resolvedUnknownNodeId}"`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (existingNode.kind !== "unknown") {
|
|
errors.push(
|
|
`Resolved unknown must reference an existing unknown node: "${resolvedUnknownNodeId}"`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const existingUpdate = updatedNodeById.get(resolvedUnknownNodeId);
|
|
if (!existingUpdate) {
|
|
const syntheticUpdate = buildResolvedUnknownUpdate(existingNode);
|
|
nextProposal.updatedNodes.push(syntheticUpdate);
|
|
updatedNodeById.set(resolvedUnknownNodeId, syntheticUpdate);
|
|
continue;
|
|
}
|
|
|
|
if (existingUpdate.newStatus !== "resolved") {
|
|
existingUpdate.newStatus = "resolved";
|
|
if (existingUpdate.previousStatus == null) {
|
|
existingUpdate.previousStatus = existingNode.status ?? null;
|
|
}
|
|
if (existingUpdate.previousValue === undefined) {
|
|
existingUpdate.previousValue = existingNode.value ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const update of nextProposal.updatedNodes) {
|
|
const existingNode = graphNodeById.get(update.nodeId);
|
|
if (
|
|
existingNode?.kind === "unknown" &&
|
|
update.newStatus === "resolved" &&
|
|
!nextProposal.resolvedUnknownNodeIds.includes(update.nodeId)
|
|
) {
|
|
errors.push(
|
|
`Unknown node updated to resolved must also appear in resolvedUnknownNodeIds: "${update.nodeId}"`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
proposal: nextProposal,
|
|
errors,
|
|
};
|
|
}
|
|
|
|
function validateSemanticDuplicateUnknowns(graph, proposal) {
|
|
const errors = [];
|
|
const unresolvedUnknowns = graph.nodes.filter(
|
|
(node) =>
|
|
node.kind === "unknown" &&
|
|
!proposal.resolvedUnknownNodeIds.includes(node.id),
|
|
);
|
|
|
|
for (const addedNode of proposal.addedNodes) {
|
|
const addedTexts = [
|
|
normaliseText(addedNode.label),
|
|
normaliseText(addedNode.description),
|
|
].filter(Boolean);
|
|
|
|
for (const unresolvedUnknown of unresolvedUnknowns) {
|
|
const unresolvedTexts = [
|
|
normaliseText(unresolvedUnknown.label),
|
|
normaliseText(unresolvedUnknown.description),
|
|
].filter(Boolean);
|
|
|
|
const duplicatesMeaning = addedTexts.some((text) =>
|
|
unresolvedTexts.includes(text),
|
|
);
|
|
|
|
if (!duplicatesMeaning) continue;
|
|
|
|
const linkedToUnknown = proposal.addedEdges.some(
|
|
(edge) =>
|
|
(edge.fromNodeId === addedNode.id &&
|
|
edge.toNodeId === unresolvedUnknown.id) ||
|
|
(edge.toNodeId === addedNode.id &&
|
|
edge.fromNodeId === unresolvedUnknown.id),
|
|
);
|
|
|
|
const updatedUnknown = proposal.updatedNodes.some(
|
|
(update) => update.nodeId === unresolvedUnknown.id,
|
|
);
|
|
|
|
if (!linkedToUnknown && !updatedUnknown) {
|
|
errors.push(
|
|
`Proposal adds a node duplicating unresolved unknown meaning without linking or resolving it: "${unresolvedUnknown.id}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
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 reconciledProposal = reconcileResolutionSemantics(
|
|
situationGraph,
|
|
proposalValidation.data,
|
|
);
|
|
const validatedProposal = reconciledProposal.proposal;
|
|
const proposalCompatibilityErrors = [];
|
|
proposalCompatibilityErrors.push(...reconciledProposal.errors);
|
|
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)`,
|
|
),
|
|
);
|
|
|
|
proposalCompatibilityErrors.push(
|
|
...validateSemanticDuplicateUnknowns(situationGraph, validatedProposal),
|
|
);
|
|
|
|
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),
|
|
graphReferenceValidation: resultReferenceValidation,
|
|
};
|
|
}
|