libpetri 2.3.2 → 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;
@@ -1363,6 +1376,42 @@ declare class PetriNetBuilder {
1363
1376
  compose(instance: Instance<unknown>): this;
1364
1377
  compose(instance: Instance<unknown>, portMappings: ReadonlyMap<string, Place<unknown>> | Record<string, Place<unknown>>): this;
1365
1378
  compose(instance: Instance<unknown>, bind: (b: ComposeBindings) => void): this;
1379
+ compose(def: SubnetDef<unknown>): this;
1380
+ /**
1381
+ * Composes a subnet {@link SubnetDef} **directly** into this builder per
1382
+ * **MOD-025** — **without instantiation**, and without the prefix-renaming
1383
+ * of {@link SubnetDef.instantiate}.
1384
+ *
1385
+ * Every body place and transition is added under its **original**
1386
+ * (un-prefixed) name. Places merge into this builder by name: a body place
1387
+ * whose name equals an enclosing-net place *is* that place in the composed
1388
+ * flat net. This is the mode for wiring a subnet in as a single shared copy.
1389
+ *
1390
+ * Direct composition is **order-independent**: composing the same set of
1391
+ * subnets in any order yields the same flat net, because merging is by
1392
+ * place name and not by a probe of the builder's place set at call time —
1393
+ * contrast the no-interface body-inference branch of {@link composeAuto}
1394
+ * (MOD-024), which is order-sensitive.
1395
+ *
1396
+ * For multiple *independent* copies — each with isolated per-instance state
1397
+ * per [MOD-012] — use {@link SubnetDef.instantiate} + the `compose(instance)`
1398
+ * overload instead.
1399
+ *
1400
+ * Rejections: a body transition whose name already exists in this builder
1401
+ * (use `instantiate(prefix)` for independent copies); a subnet whose
1402
+ * interface declares any channel (direct composition does not bind
1403
+ * channels — use `instantiate` + the channel-binding `compose` overload).
1404
+ *
1405
+ * Token-type conflicts on a same-named place cannot be detected: TS
1406
+ * {@link Place} equality is name-only at runtime (the documented carve-out,
1407
+ * same as MOD-024).
1408
+ *
1409
+ * @throws when the subnet declares channels, or a body transition name
1410
+ * collides with a transition already in this builder.
1411
+ */
1412
+ private composeDirect;
1413
+ /** @internal MOD-026: records a node as contributed by the named subnet. */
1414
+ private recordContribution;
1366
1415
  /**
1367
1416
  * Identity-default auto-compose per **MOD-024**.
1368
1417
  *
@@ -1481,6 +1530,22 @@ declare class PetriNetBuilder {
1481
1530
  * @throws when two fusion sets share a place
1482
1531
  */
1483
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;
1484
1549
  /**
1485
1550
  * @internal Fusion-resolution pass per **MOD-061**. Split out from
1486
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-CKZYPOsL.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-CKZYPOsL.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
  }