357 lines
9.3 KiB
JavaScript
357 lines
9.3 KiB
JavaScript
/**
|
|
* 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
|
|
const sourceIdToNodeId = new Map();
|
|
|
|
function mapSourceId(sourceId, node) {
|
|
if (sourceId) sourceIdToNodeId.set(sourceId, node.id);
|
|
return 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);
|
|
mapSourceId(obs.id, node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Actors as states/nodes
|
|
if (reconstruction.actors) {
|
|
for (const actor of reconstruction.actors) {
|
|
mapSourceId(actor.id, 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) {
|
|
mapSourceId(sys.id, 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",
|
|
);
|
|
mapSourceId(diff.id, node);
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
);
|
|
mapSourceId(c.id, node);
|
|
}
|
|
}
|
|
|
|
// 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",
|
|
);
|
|
mapSourceId(unk.id, node);
|
|
unknownNodes.push(node);
|
|
}
|
|
}
|
|
|
|
// Plausible interpretations
|
|
if (reconstruction.plausibleInterpretations) {
|
|
for (const interp of reconstruction.plausibleInterpretations) {
|
|
mapSourceId(interp.id, ensureNode(
|
|
interp.description || interp.label,
|
|
"assumption",
|
|
"provisional",
|
|
interp.description || "Plausible interpretation",
|
|
null,
|
|
null,
|
|
interp.confidence || "low",
|
|
));
|
|
}
|
|
}
|
|
|
|
// Unexplained transitions remain unresolved transition nodes
|
|
if (reconstruction.unexplainedTransitions) {
|
|
for (const trans of reconstruction.unexplainedTransitions) {
|
|
const label =
|
|
trans.description ||
|
|
`${trans.entity ?? "Unexplained transition"}: ${trans.previousState ?? "unknown"} → ${trans.currentState ?? "unknown"}`;
|
|
mapSourceId(trans.id, ensureNode(
|
|
label,
|
|
"transition",
|
|
"unknown",
|
|
trans.description || label,
|
|
null,
|
|
null,
|
|
trans.confidence || "low",
|
|
));
|
|
}
|
|
}
|
|
|
|
// Known transitions
|
|
if (reconstruction.knownTransitions) {
|
|
for (const trans of reconstruction.knownTransitions) {
|
|
mapSourceId(trans.id, 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`,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
const edgeIds = new Set(edges.map((edge) => edge.id));
|
|
for (const relationship of reconstruction.relationships ?? []) {
|
|
const fromNodeId = sourceIdToNodeId.get(relationship.fromId);
|
|
const toNodeId = sourceIdToNodeId.get(relationship.toId);
|
|
const id = `e-rel-${relationship.id}`;
|
|
|
|
if (!fromNodeId || !toNodeId || edgeIds.has(id)) continue;
|
|
|
|
edges.push(
|
|
situationEdgeSchema.parse({
|
|
id,
|
|
fromNodeId,
|
|
toNodeId,
|
|
relationship: relationship.relationship,
|
|
confidence: relationship.confidence,
|
|
description: relationship.description,
|
|
}),
|
|
);
|
|
edgeIds.add(id);
|
|
}
|
|
|
|
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(" | ");
|
|
}
|