Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion libs/@hashintel/petrinaut/src/ui/views/Notebook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ role: Notebook view β€” the net as expandable cells with editable code, dependen
---

The notebook renders the net as a flat list of cells, one per entity, so a
model reads like a program: declarations and the flow that uses them. It replaces the canvas and its
model reads like a program: declarations, the flow that uses them, and the
analyses that fall out of the structure. It replaces the canvas and its
panels wholesale, which is what lets its Monaco editors reuse the LSP
document URIs β€” a model is never mounted twice.

Expand All @@ -21,3 +22,9 @@ from the editor, expansion and search live here). The graph explorer draws
the whole net from the arc structure alone, ignoring canvas positions, so
the diagram answers "what feeds what" rather than "where did the author
drag things".

Every analysis is structural: it reads places, transitions and arcs, never
markings or scenario state. The reasoning behind each algorithm β€” why
cycles are SCCs, why "needs seeding" means a minimal siphon, how the
layout and its animation stay cheap β€” is in the deep-dive:
[Notebook graph analyses](doc:notebook/graph-analyses).
103 changes: 103 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";

import { buildCycleMembership, findCycleGroups } from "./net-cycles";

import type { NetGraph, NetGraphNode } from "./notebook-model";

const place = (id: string): NetGraphNode => ({ id, name: id, kind: "place" });
const transition = (id: string): NetGraphNode => ({
id,
name: id,
kind: "transition",
});

describe("findCycleGroups", () => {
it("finds no cycles in a chain", () => {
const graph: NetGraph = {
nodes: [place("Source"), transition("Move"), place("Sink")],
edges: [
{ from: "Source", to: "Move" },
{ from: "Move", to: "Sink" },
],
};

expect(findCycleGroups(graph)).toEqual([]);
});

it("groups the members of a two-node loop", () => {
const graph: NetGraph = {
nodes: [place("Pool"), transition("Churn")],
edges: [
{ from: "Pool", to: "Churn" },
{ from: "Churn", to: "Pool" },
],
};

const groups = findCycleGroups(graph);

expect(groups).toHaveLength(1);
expect(groups[0]!.memberIds).toEqual(["Pool", "Churn"]);
expect(groups[0]!.label).toBe(1);
});

it("keeps separate loops in separate groups, numbered in document order", () => {
const graph: NetGraph = {
nodes: [
place("A1"),
transition("A2"),
place("B1"),
transition("B2"),
place("Free"),
],
edges: [
{ from: "A1", to: "A2" },
{ from: "A2", to: "A1" },
{ from: "B1", to: "B2" },
{ from: "B2", to: "B1" },
{ from: "A1", to: "Free" },
],
};

const groups = findCycleGroups(graph);

expect(groups.map(({ memberIds }) => memberIds)).toEqual([
["A1", "A2"],
["B1", "B2"],
]);
expect(groups.map(({ label }) => label)).toEqual([1, 2]);
});

it("treats a longer loop as one group", () => {
const graph: NetGraph = {
nodes: [place("P1"), transition("T1"), place("P2"), transition("T2")],
edges: [
{ from: "P1", to: "T1" },
{ from: "T1", to: "P2" },
{ from: "P2", to: "T2" },
{ from: "T2", to: "P1" },
],
};

const groups = findCycleGroups(graph);

expect(groups).toHaveLength(1);
expect(groups[0]!.memberIds).toEqual(["P1", "T1", "P2", "T2"]);
});

it("maps every member to its group", () => {
const graph: NetGraph = {
nodes: [place("Pool"), transition("Churn"), place("Outside")],
edges: [
{ from: "Pool", to: "Churn" },
{ from: "Churn", to: "Pool" },
{ from: "Churn", to: "Outside" },
],
};

const membership = buildCycleMembership(findCycleGroups(graph));

expect(membership.get("Pool")?.label).toBe(1);
expect(membership.get("Churn")?.label).toBe(1);
expect(membership.has("Outside")).toBe(false);
});
});
150 changes: 150 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/views/Notebook/net-cycles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* Cycle detection for the net graph: Tarjan's strongly-connected components,
* run iteratively so deep nets can't blow the call stack.
*
* Only places and transitions can take part in a cycle β€” types, parameters
* and differential equations are pure declarations β€” so this works on the
* {@link NetGraph} rather than the full dependency graph.
*/

import type { NetGraph } from "./notebook-model";

/** A set of nodes that are all reachable from each other. */
export type CycleGroup = {
/** Stable key derived from the members, safe to use as a React key. */
key: string;
/** 1-based number shown to the user, in document order of the first member. */
label: number;
memberIds: string[];
};

/**
* Every cycle in the net, ordered by where its earliest member appears in the
* document. Nodes not in any cycle are absent.
*/
export function findCycleGroups(graph: NetGraph): CycleGroup[] {
const targetsByNode = new Map<string, string[]>();
for (const edge of graph.edges) {
const existing = targetsByNode.get(edge.from);
if (existing === undefined) {
targetsByNode.set(edge.from, [edge.to]);
} else {
existing.push(edge.to);
}
}

const documentOrder = new Map(
graph.nodes.map((node, position) => [node.id, position]),
);

const depthIndex = new Map<string, number>();
const lowLink = new Map<string, number>();
const onStack = new Set<string>();
const componentStack: string[] = [];
const components: string[][] = [];
let nextIndex = 0;

const open = (id: string) => {
depthIndex.set(id, nextIndex);
lowLink.set(id, nextIndex);
nextIndex += 1;
componentStack.push(id);
onStack.add(id);
};

for (const root of graph.nodes) {
if (depthIndex.has(root.id)) {
continue;
}
open(root.id);
const callStack: { id: string; nextTarget: number }[] = [
{ id: root.id, nextTarget: 0 },
];

while (callStack.length > 0) {
const frame = callStack[callStack.length - 1]!;
const targets = targetsByNode.get(frame.id) ?? [];

if (frame.nextTarget < targets.length) {
const target = targets[frame.nextTarget]!;
frame.nextTarget += 1;

if (!depthIndex.has(target)) {
open(target);
callStack.push({ id: target, nextTarget: 0 });
} else if (onStack.has(target)) {
lowLink.set(
frame.id,
Math.min(lowLink.get(frame.id)!, depthIndex.get(target)!),
);
}
continue;
}

callStack.pop();
const parent = callStack[callStack.length - 1];
if (parent !== undefined) {
lowLink.set(
parent.id,
Math.min(lowLink.get(parent.id)!, lowLink.get(frame.id)!),
);
}

if (lowLink.get(frame.id) === depthIndex.get(frame.id)) {
const members: string[] = [];
let member: string;
do {
member = componentStack.pop()!;
onStack.delete(member);
members.push(member);
} while (member !== frame.id);

if (members.length > 1) {
components.push(
members.sort(
(left, right) =>
(documentOrder.get(left) ?? 0) -
(documentOrder.get(right) ?? 0),
),
);
}
}
}
}

return components
.sort(
(left, right) =>
(documentOrder.get(left[0]!) ?? 0) -
(documentOrder.get(right[0]!) ?? 0),
)
.map((memberIds, position) => ({
key: memberIds.join("+"),
label: position + 1,
memberIds,
}));
}

/** Lookup from node id to the cycle it belongs to, for rows and diagram nodes. */
export function buildCycleMembership(
groups: CycleGroup[],
): Map<string, CycleGroup> {
const membership = new Map<string, CycleGroup>();
for (const group of groups) {
for (const id of group.memberIds) {
membership.set(id, group);
}
}
return membership;
}

/**
* Distinct tints for cycle badges and rings, cycled through by group number.
* Deliberately avoids the blue/orange/purple used for selection roles.
*/
export const CYCLE_TINTS = ["pink", "green", "yellow"] as const;

export type CycleTint = (typeof CYCLE_TINTS)[number];

export const cycleTint = (group: CycleGroup): CycleTint =>
CYCLE_TINTS[(group.label - 1) % CYCLE_TINTS.length]!;
Loading
Loading