libpetri 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1297,8 +1297,20 @@ declare class PetriNet {
1297
1297
  readonly name: string;
1298
1298
  readonly places: ReadonlySet<Place<any>>;
1299
1299
  readonly transitions: ReadonlySet<Transition>;
1300
+ /**
1301
+ * Subnet-membership metadata per **MOD-026**: maps each node name (place or
1302
+ * transition) contributed by exactly one directly-composed subnet to that
1303
+ * subnet's name. Shared places contributed by two or more subnets, and every
1304
+ * node of a net not built via {@link PetriNetBuilder.compose}`(SubnetDef)`,
1305
+ * are absent. Never null — an empty map when there is no metadata. The DOT
1306
+ * exporter renders `subgraph cluster_*` blocks from this map.
1307
+ *
1308
+ * V8 `Map` is insertion-ordered, preserving compose order so cluster
1309
+ * subgraphs render deterministically (cross-language byte-parity).
1310
+ */
1311
+ readonly subnetMembership: ReadonlyMap<string, string>;
1300
1312
  /** @internal Use {@link PetriNet.builder} to create instances. */
1301
- constructor(key: symbol, name: string, places: ReadonlySet<Place<any>>, transitions: ReadonlySet<Transition>);
1313
+ constructor(key: symbol, name: string, places: ReadonlySet<Place<any>>, transitions: ReadonlySet<Transition>, subnetMembership?: ReadonlyMap<string, string>);
1302
1314
  /**
1303
1315
  * Creates a new PetriNet with actions bound to transitions by name.
1304
1316
  * Unbound transitions keep passthrough action.
@@ -1315,6 +1327,7 @@ declare class PetriNetBuilder {
1315
1327
  private readonly _places;
1316
1328
  private readonly _transitions;
1317
1329
  private readonly _fusionSets;
1330
+ private readonly _subnetContributions;
1318
1331
  constructor(name: string);
1319
1332
  /** Add an explicit place. */
1320
1333
  place(place: Place<any>): this;
@@ -1397,6 +1410,8 @@ declare class PetriNetBuilder {
1397
1410
  * collides with a transition already in this builder.
1398
1411
  */
1399
1412
  private composeDirect;
1413
+ /** @internal MOD-026: records a node as contributed by the named subnet. */
1414
+ private recordContribution;
1400
1415
  /**
1401
1416
  * Identity-default auto-compose per **MOD-024**.
1402
1417
  *
@@ -1515,6 +1530,22 @@ declare class PetriNetBuilder {
1515
1530
  * @throws when two fusion sets share a place
1516
1531
  */
1517
1532
  build(): PetriNet;
1533
+ /**
1534
+ * @internal Resolves the per-compose contributions recorded by
1535
+ * `composeDirect` into the final node-name → subnet-name membership map per
1536
+ * **MOD-026**. A node contributed by exactly one subnet maps to that subnet;
1537
+ * a place contributed by two or more subnets is a shared rendezvous place
1538
+ * and is omitted (it renders top-level, outside any cluster). Returns an
1539
+ * empty map — the common case — when no subnet was composed directly.
1540
+ */
1541
+ private resolveSubnetMembership;
1542
+ /**
1543
+ * @internal Drops membership entries for places removed by fusion: a
1544
+ * non-canonical fused member no longer exists in the net, so its
1545
+ * `node-name → subnet` entry would dangle. The surviving canonical place
1546
+ * keeps its own entry. Per **MOD-026**.
1547
+ */
1548
+ private static filterFusedMembership;
1518
1549
  /**
1519
1550
  * @internal Fusion-resolution pass per **MOD-061**. Split out from
1520
1551
  * {@link build} so the no-fusion fast path stays trivial.
@@ -1,5 +1,5 @@
1
- import { b as Transition, a as Place, P as PetriNet, E as EnvironmentPlace, aG as MarkingState, aH as PInvariant, aI as MarkingStateBuilder, aJ as SmtProperty, aK as SmtVerificationResult } from '../petri-net-RpZxG0Io.js';
2
- export { aL as DeadlockFree, aM as MutualExclusion, aN as PlaceBound, aO as Proven, aP as SmtStatistics, aQ as TokenSupplier, aR as Unknown, aS as Unreachable, aT as Verdict, X as VerificationHarness, Y as VerificationResult, aU as Violated, aV as deadlockFree, aW as isProven, aX as isViolated, aY as mutualExclusion, aZ as pInvariant, a_ as pInvariantToString, a$ as placeBound, b0 as propertyDescription, b1 as unreachable } from '../petri-net-RpZxG0Io.js';
1
+ import { b as Transition, a as Place, P as PetriNet, E as EnvironmentPlace, aG as MarkingState, aH as PInvariant, aI as MarkingStateBuilder, aJ as SmtProperty, aK as SmtVerificationResult } from '../petri-net-DXdpd0r5.js';
2
+ export { aL as DeadlockFree, aM as MutualExclusion, aN as PlaceBound, aO as Proven, aP as SmtStatistics, aQ as TokenSupplier, aR as Unknown, aS as Unreachable, aT as Verdict, X as VerificationHarness, Y as VerificationResult, aU as Violated, aV as deadlockFree, aW as isProven, aX as isViolated, aY as mutualExclusion, aZ as pInvariant, a_ as pInvariantToString, a$ as placeBound, b0 as propertyDescription, b1 as unreachable } from '../petri-net-DXdpd0r5.js';
3
3
  import { Expr, init, Bool, FuncDecl } from 'z3-solver';
4
4
 
5
5
  /**
@@ -63,10 +63,16 @@ interface ClusterDescriptor {
63
63
  *
64
64
  * Graphviz emits cluster nodes as SIBLINGS of the cluster `<g>`, not as
65
65
  * children — `<g class="cluster">` only carries title, polygon border, and
66
- * the cluster's text label. To recover cluster membership we match each
67
- * node's `<title>` (the original graph id) against libpetri's prefixing
68
- * convention: interior places are emitted as `p_<prefix>_<name>`,
69
- * transitions as `t_<prefix>_<name>`, and junctions as `j_<prefix>_<name>`.
66
+ * the cluster's text label. Cluster membership is recovered two ways:
67
+ *
68
+ * - **Name-based** instance composition prefixes interior names
69
+ * (`p_<prefix>_…`, `t_<prefix>_…`, `j_<prefix>_…`) or uses slash-separated
70
+ * names (`<prefix>/…`).
71
+ * - **Geometric** — direct composition (spec MOD-026) keeps each node's
72
+ * original, un-prefixed name, so the only structural signal left in the
73
+ * rendered SVG is the node's drawn position: a node whose centre falls
74
+ * inside the cluster's border box belongs to it. This is what Graphviz
75
+ * itself draws the box around.
70
76
  *
71
77
  * Does not split nested-cluster prefixes (`outer_inner` could mean either
72
78
  * `outer/inner` or a flat `outer_inner` prefix — Graphviz sanitisation is
@@ -30,13 +30,14 @@ function discoverClusters(svg) {
30
30
  if (!raw.startsWith("cluster_")) continue;
31
31
  const prefix = raw.slice("cluster_".length);
32
32
  if (out.has(prefix)) continue;
33
+ const rect = clusterRect(g);
33
34
  let nodeCount = 0;
34
35
  for (const node of allNodes) {
35
36
  if (node.getAttribute("data-instance")) continue;
36
37
  const nodeTitle = directChild(node, "title");
37
38
  if (!nodeTitle) continue;
38
39
  const id = (nodeTitle.textContent ?? "").trim();
39
- if (nodeBelongsToPrefix(id, prefix)) {
40
+ if (nodeBelongsToCluster(id, node, prefix, rect)) {
40
41
  node.setAttribute("data-instance", prefix);
41
42
  nodeCount++;
42
43
  }
@@ -50,9 +51,77 @@ function discoverClusters(svg) {
50
51
  }
51
52
  return out;
52
53
  }
54
+ function nodeBelongsToCluster(nodeId, node, prefix, rect) {
55
+ if (nodeBelongsToPrefix(nodeId, prefix)) return true;
56
+ if (rect) {
57
+ const c = nodeCenter(node);
58
+ if (c && c.x >= rect.minX && c.x <= rect.maxX && c.y >= rect.minY && c.y <= rect.maxY) {
59
+ return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
53
64
  function nodeBelongsToPrefix(nodeId, prefix) {
54
65
  return nodeId === prefix || nodeId.startsWith(`${prefix}/`) || nodeId.startsWith(`p_${prefix}_`) || nodeId.startsWith(`t_${prefix}_`) || nodeId.startsWith(`j_${prefix}_`);
55
66
  }
67
+ function clusterRect(group) {
68
+ const border = directChild(group, "polygon") ?? directChild(group, "path");
69
+ if (!border) return null;
70
+ const raw = border.getAttribute("points") ?? border.getAttribute("d") ?? "";
71
+ return boundsOf(raw);
72
+ }
73
+ function nodeCenter(node) {
74
+ const ellipse = directChild(node, "ellipse");
75
+ if (ellipse) {
76
+ const x = parseFloat(ellipse.getAttribute("cx") ?? "");
77
+ const y = parseFloat(ellipse.getAttribute("cy") ?? "");
78
+ if (!Number.isNaN(x) && !Number.isNaN(y)) return { x, y };
79
+ }
80
+ const shape = directChild(node, "polygon") ?? directChild(node, "path");
81
+ if (shape) {
82
+ const bounds = boundsOf(
83
+ shape.getAttribute("points") ?? shape.getAttribute("d") ?? ""
84
+ );
85
+ if (bounds) {
86
+ return {
87
+ x: (bounds.minX + bounds.maxX) / 2,
88
+ y: (bounds.minY + bounds.maxY) / 2
89
+ };
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+ function boundsOf(coords) {
95
+ const nums = coords.match(/-?\d+(?:\.\d+)?/g);
96
+ if (!nums || nums.length < 2) return null;
97
+ let minX = Infinity;
98
+ let minY = Infinity;
99
+ let maxX = -Infinity;
100
+ let maxY = -Infinity;
101
+ for (let i = 0; i + 1 < nums.length; i += 2) {
102
+ const x = parseFloat(nums[i]);
103
+ const y = parseFloat(nums[i + 1]);
104
+ if (x < minX) minX = x;
105
+ if (x > maxX) maxX = x;
106
+ if (y < minY) minY = y;
107
+ if (y > maxY) maxY = y;
108
+ }
109
+ if (minX === Infinity) return null;
110
+ return { minX, minY, maxX, maxY };
111
+ }
112
+ function nodeClusterMap(svg) {
113
+ const map = /* @__PURE__ */ new Map();
114
+ svg.querySelectorAll("g.node[data-instance]").forEach((node) => {
115
+ const di = node.getAttribute("data-instance");
116
+ const title = directChild(node, "title");
117
+ const id = (title?.textContent ?? "").trim();
118
+ if (di && id) map.set(id, di);
119
+ });
120
+ return map;
121
+ }
122
+ function endpointInCluster(id, prefix, clusterMap) {
123
+ return nodeBelongsToPrefix(id, prefix) || clusterMap.get(id) === prefix;
124
+ }
56
125
  function colorForPrefix(prefix) {
57
126
  let h = 2166136261;
58
127
  for (let i = 0; i < prefix.length; i++) {
@@ -84,6 +153,7 @@ function setClusterCollapsed(cluster, collapsed) {
84
153
  node.classList.add("petri-collapsed-inside");
85
154
  hidden.push(node);
86
155
  });
156
+ const clusterMap = nodeClusterMap(root);
87
157
  root.querySelectorAll("g.edge").forEach((edge) => {
88
158
  const titleEl = directChild(edge, "title");
89
159
  if (!titleEl) return;
@@ -92,7 +162,7 @@ function setClusterCollapsed(cluster, collapsed) {
92
162
  if (arrow < 0) return;
93
163
  const from = t.slice(0, arrow);
94
164
  const to = t.slice(arrow + 2);
95
- if (nodeBelongsToPrefix(from, cluster.prefix) && nodeBelongsToPrefix(to, cluster.prefix)) {
165
+ if (endpointInCluster(from, cluster.prefix, clusterMap) && endpointInCluster(to, cluster.prefix, clusterMap)) {
96
166
  edge.classList.add("petri-collapsed-inside");
97
167
  hidden.push(edge);
98
168
  }
@@ -135,6 +205,7 @@ function applyFilter(svg, prefix) {
135
205
  }
136
206
  svg.setAttribute("data-active-filter", prefix);
137
207
  svg.classList.add("has-active-filter");
208
+ const clusterMap = nodeClusterMap(svg);
138
209
  svg.querySelectorAll("g.node").forEach((node) => {
139
210
  const di = node.getAttribute("data-instance");
140
211
  const match = !!di && (di === prefix || di.indexOf(prefix + "_") === 0 || di.indexOf(prefix + "/") === 0);
@@ -152,7 +223,7 @@ function applyFilter(svg, prefix) {
152
223
  }
153
224
  const from = titleText.slice(0, arrow);
154
225
  const to = titleText.slice(arrow + 2);
155
- const matches = (name) => name.indexOf(prefix + "/") >= 0 || name.indexOf(prefix + "_") >= 0 || name === prefix;
226
+ const matches = (name) => name.indexOf(prefix + "/") >= 0 || name.indexOf(prefix + "_") >= 0 || name === prefix || clusterMap.get(name) === prefix;
156
227
  if (matches(from) || matches(to)) edge.classList.remove("petri-dimmed");
157
228
  else edge.classList.add("petri-dimmed");
158
229
  });
@@ -160,9 +231,10 @@ function applyFilter(svg, prefix) {
160
231
  function isInsideCollapsedCluster(svg, clusters, collapsedPrefixes, graphId) {
161
232
  if (collapsedPrefixes.size === 0) return false;
162
233
  if (!svg) return false;
234
+ const clusterMap = nodeClusterMap(svg);
163
235
  for (const prefix of collapsedPrefixes) {
164
236
  if (!clusters.has(prefix)) continue;
165
- if (nodeBelongsToPrefix(graphId, prefix)) return true;
237
+ if (endpointInCluster(graphId, prefix, clusterMap)) return true;
166
238
  }
167
239
  return false;
168
240
  }