feat: add situation graph foundation
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Deterministic situation graph builder — builds initial graph from scenario text.
|
||||
* Takes v0.2/v0.3 analysis output (from analyseScenario) and constructs a SituationGraph.
|
||||
*/
|
||||
|
||||
import {
|
||||
situationNodeSchema,
|
||||
situationEdgeSchema,
|
||||
makeNodeId,
|
||||
} from "./schema.js";
|
||||
|
||||
/**
|
||||
* Build an initial situation graph from a v0.3 reconstruction result.
|
||||
* @param {{ reconstruction: object, evidence: object[] | undefined }} analysisData
|
||||
* @returns {{ nodes: import("./schema.js").SituationNode[], edges: import("./schema.js").SituationEdge[] }}
|
||||
*/
|
||||
export function buildInitialGraph(analysisData) {
|
||||
const { reconstruction, evidence = [] } = analysisData;
|
||||
|
||||
if (!reconstruction || !reconstruction.summary) {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
const nodeMap = new Map(); // label -> node
|
||||
|
||||
// ── Helper: register or get a node by label ────────────
|
||||
|
||||
function ensureNode(
|
||||
label,
|
||||
kind,
|
||||
status,
|
||||
description,
|
||||
value,
|
||||
unit,
|
||||
confidence,
|
||||
) {
|
||||
if (nodeMap.has(label)) return nodeMap.get(label);
|
||||
|
||||
const id = makeNodeId(label);
|
||||
const node = situationNodeSchema.parse({
|
||||
id,
|
||||
label,
|
||||
description: description ?? label,
|
||||
kind,
|
||||
status,
|
||||
confidence,
|
||||
value: value ?? null,
|
||||
unit: unit ?? null,
|
||||
evidenceIds: [],
|
||||
dependsOn: [],
|
||||
affects: [],
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
});
|
||||
nodeMap.set(label, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── Evidence lookup ────────────────────────────────────
|
||||
|
||||
const evidenceMap = new Map();
|
||||
for (const ev of evidence) {
|
||||
if (ev.id) evidenceMap.set(ev.id, ev);
|
||||
}
|
||||
|
||||
function addEvidenceToNode(nodeId, evidenceId) {
|
||||
const node = Object.values(nodeMap).find((n) => n.id === nodeId);
|
||||
if (node && !node.evidenceIds.includes(evidenceId)) {
|
||||
node.evidenceIds.push(evidenceId);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Extract observed states as nodes ────────────────────
|
||||
|
||||
const summaryNode = ensureNode(
|
||||
reconstruction.summary || "Situation Summary",
|
||||
"state",
|
||||
"provisional",
|
||||
"Summary of the situation from the scenario text",
|
||||
null,
|
||||
null,
|
||||
"medium",
|
||||
);
|
||||
|
||||
// Collect all observable quantities as metric nodes
|
||||
const metrics = new Map();
|
||||
|
||||
if (reconstruction.observedStates) {
|
||||
for (const obs of reconstruction.observedStates) {
|
||||
const node = ensureNode(
|
||||
obs.description || obs.label,
|
||||
"observation",
|
||||
"supported",
|
||||
obs.description || obs.label,
|
||||
null,
|
||||
null,
|
||||
obs.confidence || "medium",
|
||||
);
|
||||
|
||||
if (obs.id) node.evidenceIds.push(obs.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Actors as states/nodes
|
||||
if (reconstruction.actors) {
|
||||
for (const actor of reconstruction.actors) {
|
||||
ensureNode(
|
||||
actor.description || actor.label,
|
||||
"observation",
|
||||
"supported",
|
||||
actor.description || actor.label,
|
||||
null,
|
||||
null,
|
||||
actor.confidence || "medium",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (reconstruction.systemsOrObjects) {
|
||||
for (const sys of reconstruction.systemsOrObjects) {
|
||||
ensureNode(
|
||||
sys.description || sys.label,
|
||||
"metric",
|
||||
"known",
|
||||
sys.description || sys.label,
|
||||
null,
|
||||
null,
|
||||
sys.confidence || "medium",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Differences as relationship nodes
|
||||
if (reconstruction.differences) {
|
||||
for (const diff of reconstruction.differences) {
|
||||
const node = ensureNode(
|
||||
diff.description || "Difference",
|
||||
"relationship",
|
||||
"supported",
|
||||
diff.description || "Difference",
|
||||
null,
|
||||
null,
|
||||
diff.confidence || "medium",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Contradictions as nodes
|
||||
if (reconstruction.contradictions) {
|
||||
for (const c of reconstruction.contradictions) {
|
||||
const node = ensureNode(
|
||||
c.description || c.label,
|
||||
"relationship",
|
||||
"supported",
|
||||
c.description || c.label,
|
||||
null,
|
||||
null,
|
||||
c.confidence || "medium",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Important unknowns as unknown nodes
|
||||
const unknownNodes = [];
|
||||
if (reconstruction.importantUnknowns) {
|
||||
for (const unk of reconstruction.importantUnknowns) {
|
||||
const node = ensureNode(
|
||||
unk.description || unk.label,
|
||||
"unknown",
|
||||
"unknown",
|
||||
unk.description || "Unknown factor in the situation",
|
||||
null,
|
||||
null,
|
||||
unk.confidence || "low",
|
||||
);
|
||||
unknownNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Plausible interpretations
|
||||
if (reconstruction.plausibleInterpretations) {
|
||||
for (const interp of reconstruction.plausibleInterpretations) {
|
||||
ensureNode(
|
||||
interp.description || interp.label,
|
||||
"assumption",
|
||||
"provisional",
|
||||
interp.description || "Plausible interpretation",
|
||||
null,
|
||||
null,
|
||||
interp.confidence || "low",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Known transitions
|
||||
if (reconstruction.knownTransitions) {
|
||||
for (const trans of reconstruction.knownTransitions) {
|
||||
ensureNode(
|
||||
`${trans.entity}: ${trans.previousState} → ${trans.currentState}`,
|
||||
"transition",
|
||||
trans.explanationStatus === "confirmed" ? "known" : "provisional",
|
||||
trans.description ||
|
||||
`Transition: ${trans.entity} from ${trans.previousState} to ${trans.currentState}`,
|
||||
null,
|
||||
null,
|
||||
trans.confidence || "medium",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build edges between nodes ────────────────────────
|
||||
|
||||
const nodeArr = Array.from(nodeMap.values());
|
||||
const edges = [];
|
||||
|
||||
// Link actors → observed states as measures relationships
|
||||
let actorNodes = [];
|
||||
let metricNodes = [];
|
||||
let unknownNodeIds = [];
|
||||
|
||||
for (const n of nodeArr) {
|
||||
if (n.kind === "observation" && n.status === "supported") {
|
||||
// These are observations — link to summary
|
||||
edges.push(
|
||||
situationEdgeSchema.parse({
|
||||
id: `e-sum-${n.id}`,
|
||||
fromNodeId: n.id,
|
||||
toNodeId: summaryNode.id,
|
||||
relationship: "supports",
|
||||
confidence: n.confidence || "medium",
|
||||
description: `${n.label} supports the summary`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (n.kind === "unknown") {
|
||||
unknownNodeIds.push(n.id);
|
||||
edges.push(
|
||||
situationEdgeSchema.parse({
|
||||
id: `e-unk-${n.id}`,
|
||||
fromNodeId: n.id,
|
||||
toNodeId: summaryNode.id,
|
||||
relationship: "depends_on",
|
||||
confidence: n.confidence || "low",
|
||||
description: `${n.label} is an unresolved factor for this situation`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: nodeArr, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal starting graph for any scenario.
|
||||
* Used when analysis has no reconstruction data (e.g., error state).
|
||||
*/
|
||||
export function buildMinimalGraph(scenario) {
|
||||
const shortLabel = scenario.slice(0, 80);
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
situationNodeSchema.parse({
|
||||
id: "n0",
|
||||
label: shortLabel,
|
||||
description: `Initial situation from: "${scenario.slice(0, 200)}"`,
|
||||
kind: "state",
|
||||
status: "provisional",
|
||||
confidence: "low",
|
||||
value: null,
|
||||
unit: null,
|
||||
evidenceIds: [],
|
||||
dependsOn: [],
|
||||
affects: [],
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
}),
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert graph nodes/edges to a human-readable summary for display.
|
||||
*/
|
||||
export function describeGraph(graph) {
|
||||
const parts = [];
|
||||
|
||||
// Count by kind
|
||||
const byKind = {};
|
||||
for (const n of graph.nodes) {
|
||||
byKind[n.kind] = (byKind[n.kind] || 0) + 1;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
`Nodes: ${Object.entries(byKind)
|
||||
.map(([k, v]) => `${v} ${k}`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
parts.push(`Edges: ${graph.edges.length} total`);
|
||||
parts.push(
|
||||
`Unknowns: ${graph.nodes.filter((n) => n.status === "unknown").length} unresolved`,
|
||||
);
|
||||
|
||||
return parts.join(" | ");
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Situation Graph schema — v0.4 experiment.
|
||||
* Defines types for an evolving multi-turn situation reconstruction graph.
|
||||
* Plain TypeScript interfaces implemented as Zod schemas for runtime validation.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
// ── Enums ────────────────────────────────────────────
|
||||
|
||||
export const SituationKind = /** @type {const} */ ({
|
||||
observation: "observation",
|
||||
reported_claim: "reported_claim",
|
||||
metric: "metric",
|
||||
state: "state",
|
||||
transition: "transition",
|
||||
relationship: "relationship",
|
||||
assumption: "assumption",
|
||||
unknown: "unknown",
|
||||
conclusion: "conclusion",
|
||||
});
|
||||
|
||||
export const SituationStatus = /** @type {const} */ ({
|
||||
known: "known",
|
||||
unknown: "unknown",
|
||||
provisional: "provisional",
|
||||
supported: "supported",
|
||||
weakened: "weakened",
|
||||
contradicted: "contradicted",
|
||||
resolved: "resolved",
|
||||
});
|
||||
|
||||
export const ConfidenceLevel = /** @type {const} */ ({
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
});
|
||||
|
||||
// ── SituationNode ────────────────────────────────────
|
||||
|
||||
export const situationNodeSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
kind: z.enum(Object.values(SituationKind)),
|
||||
status: z.enum(Object.values(SituationStatus)),
|
||||
confidence: z.enum(Object.values(ConfidenceLevel)),
|
||||
value: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||
unit: z.string().nullable().optional(),
|
||||
evidenceIds: z.array(z.string()).default([]),
|
||||
dependsOn: z.array(z.string()).default([]),
|
||||
affects: z.array(z.string()).default([]),
|
||||
parentId: z.string().nullable().optional(),
|
||||
childIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** @typedef {z.infer<typeof situationNodeSchema>} SituationNode */
|
||||
|
||||
// ── SituationEdge ────────────────────────────────────
|
||||
|
||||
export const SituationRelationship = /** @type {const} */ ({
|
||||
supports: "supports",
|
||||
weakens: "weakens",
|
||||
contradicts: "contradicts",
|
||||
depends_on: "depends_on",
|
||||
causes: "causes",
|
||||
may_cause: "may_cause",
|
||||
measures: "measures",
|
||||
compares_with: "compares_with",
|
||||
updates: "updates",
|
||||
other: "other",
|
||||
});
|
||||
|
||||
export const situationEdgeSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
fromNodeId: z.string().min(1),
|
||||
toNodeId: z.string().min(1),
|
||||
relationship: z.enum(Object.values(SituationRelationship)),
|
||||
confidence: z.enum(Object.values(ConfidenceLevel)),
|
||||
description: z.string().min(1),
|
||||
});
|
||||
|
||||
/** @typedef {z.infer<typeof situationEdgeSchema>} SituationEdge */
|
||||
|
||||
// ── SituationGraph ───────────────────────────────────
|
||||
|
||||
export const situationGraphSchema = z.object({
|
||||
centralStatement: z.string().min(1),
|
||||
nodes: z.array(situationNodeSchema).min(1),
|
||||
edges: z.array(situationEdgeSchema).default([]),
|
||||
activeUnknownNodeId: z.string().nullable(),
|
||||
resolvedNodeIds: z.array(z.string()).default([]),
|
||||
currentSummary: z.string().min(1),
|
||||
});
|
||||
|
||||
/** @typedef {z.infer<typeof situationGraphSchema>} SituationGraph */
|
||||
|
||||
// ── GraphUpdate (change set) ────────────────────────
|
||||
|
||||
const graphUpdateNodeChangeSchema = z.object({
|
||||
nodeId: z.string().min(1),
|
||||
previousStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
||||
newStatus: z.enum(Object.values(SituationStatus)).nullable().optional(),
|
||||
previousValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||
newValue: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||
reason: z.string().min(1),
|
||||
});
|
||||
|
||||
export const graphUpdateSchema = z.object({
|
||||
addedNodes: z.array(situationNodeSchema).default([]),
|
||||
updatedNodes: z.array(graphUpdateNodeChangeSchema).default([]),
|
||||
addedEdges: z.array(situationEdgeSchema).default([]),
|
||||
removedEdgeIds: z.array(z.string()).default([]),
|
||||
resolvedUnknownNodeIds: z.array(z.string()).default([]),
|
||||
affectedNodeIds: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** @typedef {z.infer<typeof graphUpdateSchema>} GraphUpdate */
|
||||
|
||||
// ── API request / response schemas ───────────────────
|
||||
|
||||
export const startCaseRequestSchema = z.object({
|
||||
scenario: z.string().min(1).max(10000),
|
||||
promptVersion: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateCaseRequestSchema = z.object({
|
||||
situationGraph: situationGraphSchema,
|
||||
previousQuestion: z.string().min(1),
|
||||
answer: z.string().min(1).max(5000),
|
||||
promptVersion: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
|
||||
/** Generate a short deterministic ID from a label */
|
||||
export function makeNodeId(label) {
|
||||
return "n" + Math.abs(hashString(label)).toString(36).slice(0, 7);
|
||||
}
|
||||
|
||||
function hashString(str) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (Math.imul(31, h) + str.charCodeAt(i)) | 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Create a minimal valid node — used in tests and fixtures */
|
||||
export function makeNode(opts) {
|
||||
const id = opts.id || makeNodeId(opts.label);
|
||||
return situationNodeSchema.parse({
|
||||
id,
|
||||
label: opts.label,
|
||||
description: opts.description ?? opts.label,
|
||||
kind: opts.kind ?? "observation",
|
||||
status: opts.status ?? "unknown",
|
||||
confidence: opts.confidence ?? "medium",
|
||||
value: opts.value ?? null,
|
||||
unit: opts.unit ?? null,
|
||||
evidenceIds: opts.evidenceIds ?? [],
|
||||
dependsOn: opts.dependsOn ?? [],
|
||||
affects: opts.affects ?? [],
|
||||
parentId: opts.parentId ?? null,
|
||||
childIds: opts.childIds ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a minimal valid edge — used in tests and fixtures */
|
||||
export function makeEdge(opts) {
|
||||
return situationEdgeSchema.parse({
|
||||
id: opts.id || "e" + opts.fromNodeId.slice(0,3) + "-" + opts.toNodeId.slice(0,3),
|
||||
fromNodeId: opts.fromNodeId,
|
||||
toNodeId: opts.toNodeId,
|
||||
relationship: opts.relationship ?? "supports",
|
||||
confidence: opts.confidence ?? "medium",
|
||||
description: opts.description ?? opts.fromNodeId + " -> " + opts.toNodeId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a minimal valid graph structure */
|
||||
export function makeGraph(opts) {
|
||||
return situationGraphSchema.parse({
|
||||
centralStatement: opts.centralStatement || "",
|
||||
nodes: opts.nodes ?? [],
|
||||
edges: opts.edges ?? [],
|
||||
activeUnknownNodeId: opts.activeUnknownNodeId ?? null,
|
||||
resolvedNodeIds: opts.resolvedNodeIds ?? [],
|
||||
currentSummary: opts.currentSummary || "",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Deterministic graph utilities for situation graph operations.
|
||||
* These functions perform safe, validated operations on the graph.
|
||||
* The LLM should never directly modify the graph — it proposes changes,
|
||||
* and these utilities apply them safely.
|
||||
*/
|
||||
|
||||
import { situationNodeSchema, situationEdgeSchema, situationGraphSchema } from "./schema.js";
|
||||
|
||||
// ── Validate that all edge references point to existing nodes ──
|
||||
|
||||
export function validateGraphReferences(graph) {
|
||||
const errors = [];
|
||||
const nodeIds = new Set(graph.nodes.map((n) => n.id));
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (node.parentId !== null && !nodeIds.has(node.parentId)) {
|
||||
errors.push(`Node "${node.id}" references parentId "${node.parentId}" which does not exist`);
|
||||
}
|
||||
for (const cid of node.childIds) {
|
||||
if (!nodeIds.has(cid)) {
|
||||
errors.push(`Node "${node.id}" references childIds "${cid}" which does not exist`);
|
||||
}
|
||||
}
|
||||
for (const dep of node.dependsOn) {
|
||||
if (!nodeIds.has(dep)) {
|
||||
errors.push(`Node "${node.id}" depends on "${dep}" which does not exist`);
|
||||
}
|
||||
}
|
||||
for (const aff of node.affects) {
|
||||
if (!nodeIds.has(aff)) {
|
||||
errors.push(`Node "${node.id}" affects "${aff}" which does not exist`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (!nodeIds.has(edge.fromNodeId)) {
|
||||
errors.push(`Edge "${edge.id}" references non-existent fromNodeId "${edge.fromNodeId}"`);
|
||||
}
|
||||
if (!nodeIds.has(edge.toNodeId)) {
|
||||
errors.push(`Edge "${edge.id}" references non-existent toNodeId "${edge.toNodeId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ── Detect duplicate node IDs ──
|
||||
|
||||
export function detectDuplicateNodeIds(nodes) {
|
||||
const countMap = new Map();
|
||||
const seen = new Set();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (countMap.has(node.id)) {
|
||||
countMap.set(node.id, countMap.get(node.id) + 1);
|
||||
} else {
|
||||
countMap.set(node.id, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates = [];
|
||||
for (const [id, count] of countMap.entries()) {
|
||||
if (count > 1 && !seen.has(id)) {
|
||||
duplicates.push({ nodeId: id, count });
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
// ── Detect duplicate edges ──
|
||||
|
||||
export function detectDuplicateEdges(edges) {
|
||||
const seen = new Set();
|
||||
const duplicates = [];
|
||||
|
||||
for (const edge of edges) {
|
||||
const key = `${edge.fromNodeId}->${edge.toNodeId}:${edge.relationship}`;
|
||||
if (seen.has(key)) {
|
||||
duplicates.push({ edgeId: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, relationship: edge.relationship });
|
||||
}
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
// ── Find all nodes that depend on a given node (transitive) ──
|
||||
|
||||
export function findDependentNodes(graph, nodeId) {
|
||||
const direct = graph.nodes.filter((n) => n.dependsOn.includes(nodeId)).map((n) => n.id);
|
||||
const affected = new Set(direct);
|
||||
|
||||
// Also propagate through edges where the relationship is depends_on
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.toNodeId === nodeId && !affected.has(edge.fromNodeId)) {
|
||||
direct.push(edge.fromNodeId);
|
||||
affected.add(edge.fromNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Transitive propagation — BFS
|
||||
const queue = [...direct];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || !affected.has(current)) continue;
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (node.dependsOn.includes(current) && !affected.has(node.id)) {
|
||||
affected.add(node.id);
|
||||
queue.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...affected];
|
||||
}
|
||||
|
||||
// ── Find all nodes that are directly or indirectly affected by a change in nodeId ──
|
||||
|
||||
export function findAffectedNodes(graph, nodeId) {
|
||||
// Direct effects: two sources
|
||||
// 1. Nodes that depend on this node (they list it in their dependsOn)
|
||||
const directFromDepends = graph.nodes.filter((n) => n.id !== nodeId && n.dependsOn.includes(nodeId)).map((n) => n.id);
|
||||
|
||||
// 2. Targets of the node's affects relationships (this node directly affects them)
|
||||
const myAffectedTargets = new Set(graph.nodes.find((n) => n.id === nodeId)?.affects || []);
|
||||
|
||||
// Merge: also add edge targets where this node is the source
|
||||
for (const edge of graph.edges) {
|
||||
if (edge.fromNodeId === nodeId && !myAffectedTargets.has(edge.toNodeId)) {
|
||||
myAffectedTargets.add(edge.toNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine both sources
|
||||
const direct = [...new Set([...directFromDepends, ...myAffectedTargets])];
|
||||
|
||||
// Transitive propagation — BFS through dependsOn and affects of affected nodes
|
||||
const affected = new Set(direct);
|
||||
const queue = [...direct];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current || !affected.has(current)) continue;
|
||||
|
||||
for (const node of graph.nodes) {
|
||||
if (node.id !== nodeId && !affected.has(node.id) && (node.dependsOn.includes(current) || node.affects.includes(current))) {
|
||||
affected.add(node.id);
|
||||
queue.push(node.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...affected];
|
||||
}
|
||||
|
||||
// ── Resolve an unknown node ──
|
||||
|
||||
export function resolveUnknownNode(graph, nodeId, newStatus, newValue, reason) {
|
||||
const nodeIdx = graph.nodes.findIndex((n) => n.id === nodeId);
|
||||
if (nodeIdx === -1) {
|
||||
return { success: false, error: `Node "${nodeId}" not found in graph` };
|
||||
}
|
||||
|
||||
const previousStatus = graph.nodes[nodeIdx].status;
|
||||
const previousValue = graph.nodes[nodeIdx].value;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
previousStatus,
|
||||
newStatus,
|
||||
previousValue,
|
||||
newValue,
|
||||
reason,
|
||||
affectedNodes: findAffectedNodes(graph, nodeId),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Select the next highest-value active unknown candidate ──
|
||||
|
||||
export function selectActiveUnknownCandidate(graph, resolvedNodeIds) {
|
||||
// Skip already resolved nodes
|
||||
const unresolved = graph.nodes.filter(
|
||||
(n) => n.kind === "unknown" && !resolvedNodeIds.includes(n.id)
|
||||
);
|
||||
|
||||
if (unresolved.length === 0) return null;
|
||||
|
||||
// Prioritise: critical unknowns first, then those that are depended upon most
|
||||
const dependencyCount = unresolved.map((n) => {
|
||||
const deps = findDependentNodes(graph, n.id).length;
|
||||
const importanceOrder = { critical: 3, important: 2, supporting: 1, incidental: 0 };
|
||||
const impScore = importanceOrder[n.confidence] || 0;
|
||||
return { node: n, score: deps * 2 + impScore };
|
||||
});
|
||||
|
||||
dependencyCount.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Return the highest-scoring unresolved unknown
|
||||
const best = dependencyCount[0];
|
||||
if (!best) return null;
|
||||
|
||||
return { nodeId: best.node.id, label: best.node.label, score: best.score };
|
||||
}
|
||||
|
||||
// ── Apply a graph update deterministically ──
|
||||
|
||||
export function applyGraphUpdate(graph, update) {
|
||||
const errors = [];
|
||||
const updatedNodesMap = new Map();
|
||||
|
||||
// Validate that update references existing nodes or newly added ones
|
||||
const allNodeIds = new Set(graph.nodes.map((n) => n.id));
|
||||
for (const added of update.addedNodes) {
|
||||
if (allNodeIds.has(added.id)) {
|
||||
errors.push(`Cannot add node with duplicate ID: "${added.id}"`);
|
||||
continue;
|
||||
}
|
||||
allNodeIds.add(added.id);
|
||||
}
|
||||
|
||||
// Validate updated nodes exist
|
||||
for (const upd of update.updatedNodes) {
|
||||
if (!allNodeIds.has(upd.nodeId)) {
|
||||
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate added edges reference existing or new nodes
|
||||
for (const edge of update.addedEdges) {
|
||||
if (!allNodeIds.has(edge.fromNodeId)) {
|
||||
errors.push(`Added edge references non-existent fromNodeId: "${edge.fromNodeId}"`);
|
||||
}
|
||||
if (!allNodeIds.has(edge.toNodeId)) {
|
||||
errors.push(`Added edge references non-existent toNodeId: "${edge.toNodeId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) return { success: false, errors };
|
||||
|
||||
// Build the new nodes list — start with a deep copy of existing
|
||||
const newNodes = graph.nodes.map((n) => ({ ...n }));
|
||||
|
||||
// Apply updated nodes
|
||||
for (const upd of update.updatedNodes) {
|
||||
const idx = newNodes.findIndex((n) => n.id === upd.nodeId);
|
||||
if (idx === -1) continue; // already validated above
|
||||
|
||||
if (upd.newStatus !== undefined && upd.newStatus !== null) {
|
||||
newNodes[idx].status = upd.newStatus;
|
||||
}
|
||||
if (upd.newValue !== undefined) {
|
||||
newNodes[idx].value = upd.newValue;
|
||||
}
|
||||
updatedNodesMap.set(upd.nodeId, newNodes[idx]);
|
||||
}
|
||||
|
||||
// Add new nodes
|
||||
for (const newNode of update.addedNodes) {
|
||||
if (!allNodeIds.has(newNode.id)) continue;
|
||||
allNodeIds.add(newNode.id);
|
||||
newNodes.push({ ...newNode });
|
||||
}
|
||||
|
||||
// Remove edges if requested
|
||||
const removedEdgeSet = new Set(update.removedEdgeIds);
|
||||
const newEdges = graph.edges.filter((e) => !removedEdgeSet.has(e.id));
|
||||
|
||||
// Add new edges
|
||||
for (const newEdge of update.addedEdges) {
|
||||
newEdges.push({ ...newEdge });
|
||||
|
||||
// Update dependsOn / affects on the nodes
|
||||
const fromNode = newNodes.find((n) => n.id === newEdge.fromNodeId);
|
||||
const toNode = newNodes.find((n) => n.id === newEdge.toNodeId);
|
||||
if (fromNode && !fromNode.childIds.includes(newEdge.toNodeId)) {
|
||||
fromNode.childIds.push(newEdge.toNodeId);
|
||||
}
|
||||
if (toNode && !toNode.dependsOn.includes(newEdge.fromNodeId)) {
|
||||
toNode.dependsOn.push(newEdge.fromNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// Add resolved node IDs
|
||||
const newResolved = [...new Set([...graph.resolvedNodeIds, ...update.resolvedUnknownNodeIds])];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodes: newNodes,
|
||||
edges: newEdges,
|
||||
resolvedNodeIds: newResolved,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Validate a proposed graph update before application ──
|
||||
|
||||
export function validateGraphUpdate(graph, update) {
|
||||
const errors = [];
|
||||
|
||||
// Check for duplicate node IDs against existing and newly added nodes
|
||||
const extendedIds = new Set(graph.nodes.map((n) => n.id));
|
||||
for (const newNode of update.addedNodes) {
|
||||
if (extendedIds.has(newNode.id)) {
|
||||
errors.push(`Cannot add node with duplicate ID: "${newNode.id}"`);
|
||||
} else {
|
||||
extendedIds.add(newNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check updated nodes exist (in original graph, not newly added ones)
|
||||
const existingIds = new Set(graph.nodes.map((n) => n.id));
|
||||
for (const upd of update.updatedNodes) {
|
||||
if (!existingIds.has(upd.nodeId)) {
|
||||
errors.push(`Cannot update non-existent node: "${upd.nodeId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Reject updates with no meaningful change
|
||||
const statusChanged = update.updatedNodes.some(
|
||||
(u) => u.previousStatus !== null && u.newStatus !== u.previousStatus
|
||||
);
|
||||
const valueChanged = update.updatedNodes.some(
|
||||
(u) => u.previousValue !== null && u.newValue !== u.previousValue
|
||||
);
|
||||
|
||||
const hasMeaningfulChange =
|
||||
update.addedNodes.length > 0 ||
|
||||
statusChanged ||
|
||||
valueChanged ||
|
||||
update.addedEdges.length > 0 ||
|
||||
update.removedEdgeIds.length > 0;
|
||||
|
||||
if (!hasMeaningfulChange) {
|
||||
errors.push("Update contains no meaningful change");
|
||||
}
|
||||
|
||||
// Reject oversized input
|
||||
const totalSize = JSON.stringify(update).length;
|
||||
if (totalSize > 100000) {
|
||||
errors.push(`Proposed graph update exceeds 100KB (${totalSize} bytes)`);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user