process-bigraph-lang 0.1.0__py3-none-win_amd64.whl

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.
@@ -0,0 +1,236 @@
1
+ """
2
+ Interface declarations as data: a minimal stage-A `describe` and the registry the normalizer resolves addresses in.
3
+
4
+ `describe_module` imports a Python module once (the publish-time step; authoring never imports) and turns every
5
+ step/process class it exposes into an `InterfaceDecl`: the address in portable `local:!module.Class` form, the
6
+ `config_schema`, and the ports obtained by calling `inputs()`/`outputs()` with the class standing in for `self`
7
+ (ports that depend on an instance are marked `dynamic_ports`). Types stay in bigraph-schema's own form. The
8
+ `InterfaceRegistry` holds declarations and reads and writes the JSON manifest (`pblang-manifest/0`), which the
9
+ TypeScript checker, printer and binder consume; `render_interfaces` has the CLI print the `.pblang` header file.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import inspect
16
+ import json
17
+ import tempfile
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from process_bigraph_lang.v2.types import MISSING, Named, parse_type
23
+
24
+ MANIFEST_VERSION = "pblang-manifest/0"
25
+
26
+
27
+ @dataclass
28
+ class ConfigField:
29
+ """One entry of a `config_schema`: the type in bigraph-schema form and its default, when it has one."""
30
+
31
+ type: Any = None
32
+ default: Any = MISSING
33
+
34
+
35
+ @dataclass
36
+ class InterfaceDecl:
37
+ """A process/step interface as a manifest entry: address, config fields, ports (bigraph-schema forms)."""
38
+
39
+ name: str
40
+ kind: str # process | step | adapter
41
+ address: str | None = None
42
+ module: str | None = None
43
+ config: dict[str, ConfigField] = field(default_factory=dict)
44
+ inputs: dict[str, Any] | None = None
45
+ outputs: dict[str, Any] | None = None
46
+ doc: str | None = None
47
+ dynamic_ports: bool = False # inputs()/outputs() could not be evaluated without an instance
48
+
49
+
50
+ def short_name(address: str) -> str:
51
+ """`Name` of `local:!pkg.mod.Name`, `local:Name` or `Name`."""
52
+ data = address.split(":", 1)[1] if ":" in address else address
53
+ return data.lstrip("!").rsplit(".", 1)[-1]
54
+
55
+
56
+ def describe_module(module_name: str) -> list[InterfaceDecl]:
57
+ """Declarations for the step/process classes a module defines or re-exports (classes of its own package only)."""
58
+ module = importlib.import_module(module_name)
59
+ is_package = hasattr(module, "__path__")
60
+ decls: list[InterfaceDecl] = []
61
+ for name, cls in inspect.getmembers(module, inspect.isclass):
62
+ if name.startswith("_") or not _is_edge_class(cls) or not _belongs_to(cls, module_name, is_package):
63
+ continue
64
+ decls.append(describe_class(cls, module_name, name))
65
+ return decls
66
+
67
+
68
+ def describe_class(cls: type, module_name: str | None = None, name: str | None = None) -> InterfaceDecl:
69
+ """The declaration of one step/process class."""
70
+ module_name = module_name or cls.__module__
71
+ name = name or cls.__name__
72
+ inputs, outputs, dynamic = _ports(cls)
73
+ return InterfaceDecl(
74
+ name=name,
75
+ kind=_kind(cls),
76
+ address=f"local:!{module_name}.{name}",
77
+ module=module_name,
78
+ config=_config_fields(getattr(cls, "config_schema", None) or {}),
79
+ inputs=inputs,
80
+ outputs=outputs,
81
+ doc=_doc(cls),
82
+ dynamic_ports=dynamic,
83
+ )
84
+
85
+
86
+ @dataclass
87
+ class InterfaceRegistry:
88
+ """Declarations by address: the manifest the CLI reads, and what the normalizer resolves edges in."""
89
+
90
+ decls: list[InterfaceDecl] = field(default_factory=list)
91
+
92
+ @classmethod
93
+ def from_modules(cls, *module_names: str) -> InterfaceRegistry:
94
+ registry = cls()
95
+ for module_name in module_names:
96
+ registry.decls.extend(describe_module(module_name))
97
+ return registry
98
+
99
+ @classmethod
100
+ def from_manifest(cls, manifest: dict[str, Any]) -> InterfaceRegistry:
101
+ return cls([_decl_from_manifest(entry) for entry in manifest.get("components", [])])
102
+
103
+ def to_manifest(self) -> dict[str, Any]:
104
+ return {"schema_version": MANIFEST_VERSION, "components": [_decl_to_manifest(d) for d in self.decls]}
105
+
106
+ def lookup(self, address: str) -> InterfaceDecl | None:
107
+ """By exact address; else by unique short name (`local:Name`, or a class re-exported from another module)."""
108
+ wanted = address if ":" in address else f"local:{address}"
109
+ if not wanted.startswith("local:"):
110
+ return None
111
+ exact = [d for d in self.decls if d.address == wanted]
112
+ if exact:
113
+ return exact[0]
114
+ by_name = [d for d in self.decls if d.name == short_name(wanted)]
115
+ return by_name[0] if len(by_name) == 1 else None
116
+
117
+ def write_manifest(self, path: Path) -> Path:
118
+ path.write_text(json.dumps(self.to_manifest(), indent=2, default=str) + "\n", encoding="utf-8")
119
+ return path
120
+
121
+
122
+ def render_interfaces(decls: list[InterfaceDecl], module_name: str) -> str:
123
+ """The `.pblang` header file of a module's declarations, as the CLI prints it."""
124
+ from process_bigraph_lang.v2.langium import run_cli
125
+
126
+ with tempfile.TemporaryDirectory() as tmp:
127
+ manifest = Path(tmp) / "manifest.json"
128
+ entries = [_decl_to_manifest(d) for d in decls]
129
+ for entry in entries:
130
+ entry["module"] = module_name
131
+ manifest.write_text(json.dumps({"schema_version": MANIFEST_VERSION, "components": entries}, default=str))
132
+ return run_cli("headers", str(manifest)).stdout
133
+
134
+
135
+ # --- describe ----------------------------------------------------------------------------------------------------
136
+
137
+
138
+ def _is_edge_class(cls: type) -> bool:
139
+ return any(base.__name__ == "Edge" for base in cls.__mro__)
140
+
141
+
142
+ def _belongs_to(cls: type, module_name: str, is_package: bool) -> bool:
143
+ """Defined in the module itself, or (for a package) re-exported from one of its submodules."""
144
+ return cls.__module__ == module_name or (is_package and cls.__module__.startswith(module_name + "."))
145
+
146
+
147
+ def _kind(cls: type) -> str:
148
+ names = [base.__name__ for base in cls.__mro__]
149
+ if "Process" in names:
150
+ return "process"
151
+ return "step"
152
+
153
+
154
+ def _ports(cls: type) -> tuple[dict[str, Any] | None, dict[str, Any] | None, bool]:
155
+ faces: list[dict[str, Any] | None] = []
156
+ dynamic = False
157
+ for method_name in ("inputs", "outputs"):
158
+ method = getattr(cls, method_name, None)
159
+ if method is None:
160
+ faces.append(None)
161
+ continue
162
+ try:
163
+ ports = method(cls) # the class stands in for `self`: works for literal port dicts
164
+ except Exception: # noqa: BLE001 - ports depend on an instance (self.config, children, ...)
165
+ faces.append(None)
166
+ dynamic = True
167
+ continue
168
+ faces.append({str(k): v for k, v in ports.items()} if isinstance(ports, dict) else None)
169
+ return faces[0], faces[1], dynamic
170
+
171
+
172
+ def _config_fields(schema: Any) -> dict[str, ConfigField]:
173
+ if not isinstance(schema, dict):
174
+ return {}
175
+ fields: dict[str, ConfigField] = {}
176
+ for key, spec in schema.items():
177
+ default: Any = MISSING
178
+ try:
179
+ expr = parse_type(spec)
180
+ if isinstance(expr, Named):
181
+ default = expr.default
182
+ except (TypeError, ValueError):
183
+ pass
184
+ fields[str(key)] = ConfigField(spec if isinstance(spec, (str, dict)) else None, default)
185
+ return fields
186
+
187
+
188
+ def _doc(cls: type) -> str | None:
189
+ text = cls.__dict__.get("__doc__")
190
+ if not text:
191
+ return None
192
+ first = inspect.cleandoc(text).split("\n\n", 1)[0].strip()
193
+ return first or None
194
+
195
+
196
+ # --- manifest ----------------------------------------------------------------------------------------------------
197
+
198
+
199
+ def _decl_to_manifest(decl: InterfaceDecl) -> dict[str, Any]:
200
+ entry: dict[str, Any] = {
201
+ "name": decl.name,
202
+ "kind": decl.kind,
203
+ "address": decl.address,
204
+ "module": decl.module,
205
+ "config": {
206
+ k: {"type": f.type, **({"default": f.default} if f.default is not MISSING else {})}
207
+ for k, f in decl.config.items()
208
+ },
209
+ "inputs": dict(decl.inputs) if decl.inputs is not None else None,
210
+ "outputs": dict(decl.outputs) if decl.outputs is not None else None,
211
+ "dynamic_ports": decl.dynamic_ports,
212
+ }
213
+ if decl.doc:
214
+ entry["doc"] = decl.doc
215
+ return entry
216
+
217
+
218
+ def _decl_from_manifest(entry: dict[str, Any]) -> InterfaceDecl:
219
+ config = {k: ConfigField(f.get("type"), f.get("default", MISSING)) for k, f in (entry.get("config") or {}).items()}
220
+ return InterfaceDecl(
221
+ name=entry["name"],
222
+ kind=entry.get("kind", "process"),
223
+ address=entry["address"] if entry.get("address") else None,
224
+ module=entry.get("module"),
225
+ config=config,
226
+ inputs=_ports_from_manifest(entry.get("inputs")),
227
+ outputs=_ports_from_manifest(entry.get("outputs")),
228
+ doc=entry.get("doc"),
229
+ dynamic_ports=bool(entry.get("dynamic_ports", False)),
230
+ )
231
+
232
+
233
+ def _ports_from_manifest(ports: Any) -> dict[str, Any] | None:
234
+ if ports is None:
235
+ return None
236
+ return {str(k): v for k, v in ports.items()}
@@ -0,0 +1,38 @@
1
+ """
2
+ The DSL CLI from Python: `cli-native` (the same binary v1 uses, built once by Deno at wheel-build time) or
3
+ `node dsl/bin/cli.js` in a development checkout. Everything the v2 language does, parsing, checking, compiling,
4
+ decompiling and rendering headers, lives in TypeScript (`dsl/src/language/v2/`); Python only calls it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ _ROOT = Path(__file__).resolve().parent.parent
13
+
14
+
15
+ def langium_cli() -> list[str] | None:
16
+ """The command that runs the DSL CLI, or None when neither the native binary nor a built checkout exists."""
17
+ for candidate in (_ROOT / "bin").glob("cli-native*"):
18
+ return [str(candidate)]
19
+ script = _ROOT.parent / "dsl" / "bin" / "cli.js"
20
+ if script.exists() and (_ROOT.parent / "dsl" / "out").exists():
21
+ return ["node", str(script)]
22
+ return None
23
+
24
+
25
+ def run_cli(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
26
+ """Run a CLI command; raise with its stderr when it fails (unless `check=False`)."""
27
+ command = langium_cli()
28
+ if command is None:
29
+ raise RuntimeError("no Langium CLI: build dsl/ (npm run build) or install the cli-native binary")
30
+ result = subprocess.run([*command, *args], capture_output=True, text=True, encoding="utf-8", check=False)
31
+ if check and result.returncode != 0:
32
+ raise RuntimeError(
33
+ f"Langium CLI failed ({' '.join(args[:2])}): {result.stderr.strip() or result.stdout.strip()}"
34
+ )
35
+ return result
36
+
37
+
38
+ __all__ = ["langium_cli", "run_cli"]
@@ -0,0 +1,384 @@
1
+ """
2
+ Equivalence of composite documents modulo representation.
3
+
4
+ Two documents are equivalent when they realize the same composite. `normalize_document` rewrites a document
5
+ into one canonical shape: types are pushed through bigraph-schema (`core.access` + `core.render(defaults=True)`),
6
+ addresses become `protocol:data` strings, wires become lists, tuples and arrays become lists, runtime keys
7
+ (`instance`, `_contract`, the root `global_time`) are dropped, and the edge entries of the schema half are folded
8
+ into the state half. `equivalent` compares two normalized documents; `diff_documents` explains a difference.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import difflib
14
+ import json
15
+ import math
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+
20
+ from process_bigraph_lang.v2.interfaces import InterfaceRegistry
21
+ from process_bigraph_lang.v2.types import MISSING, Group, Named, Struct, TypeExpr, UnionType, parse_type
22
+
23
+ EDGE_KINDS = ("process", "step", "shared_process", "shared_process_ref")
24
+ RUNTIME_EDGE_KEYS = ("instance", "_contract")
25
+ COMPOSITE_OPTION_KEYS = (
26
+ "global_time_precision",
27
+ "run_steps_on_init",
28
+ "parallel_steps",
29
+ "parallel_processes",
30
+ "parallel_workers",
31
+ "skip_process_state",
32
+ "contract_strict",
33
+ )
34
+
35
+
36
+ def is_site(value: Any) -> bool:
37
+ """A template site: `{"_type": "site", "_sort": ..., "_default"?: ...}`."""
38
+ return isinstance(value, dict) and value.get("_type") == "site"
39
+
40
+
41
+ def is_edge_node(value: Any) -> bool:
42
+ """A step/process node in the state half (its `_type` says so, or it carries an `address`)."""
43
+ return isinstance(value, dict) and (value.get("_type") in EDGE_KINDS or "address" in value)
44
+
45
+
46
+ _EdgeTable = dict[tuple[str, ...], dict[str, Any]]
47
+
48
+
49
+ def normalize_document(doc: dict[str, Any], core: Any, registry: InterfaceRegistry | None = None) -> dict[str, Any]:
50
+ """
51
+ The canonical shape of a document (see the module docstring). `core` is a bigraph-schema core. With a
52
+ `registry`, an edge's `_inputs`/`_outputs` that merely repeat its interface's face are dropped (process-bigraph
53
+ merges them with `interface()`, so they carry no information).
54
+ """
55
+ table: _EdgeTable = {}
56
+ raw_schema = doc.get("schema") or {}
57
+ schema = _norm_schema(raw_schema, core, (), table)
58
+ state = _norm_state(doc.get("state") or {}, raw_schema, core, (), table, registry)
59
+ options = {k: _canon(doc[k], core) for k in COMPOSITE_OPTION_KEYS if k in doc}
60
+ return {"schema": schema if isinstance(schema, dict) else {}, "state": state, "options": options}
61
+
62
+
63
+ def equivalent(a: dict[str, Any], b: dict[str, Any], core: Any, registry: InterfaceRegistry | None = None) -> bool:
64
+ return deep_equal(normalize_document(a, core, registry), normalize_document(b, core, registry))
65
+
66
+
67
+ def diff_documents(
68
+ a: dict[str, Any], b: dict[str, Any], core: Any, limit: int = 200, registry: InterfaceRegistry | None = None
69
+ ) -> str:
70
+ """A readable account of how two documents differ after normalization (empty when equivalent)."""
71
+ left, right = normalize_document(a, core, registry), normalize_document(b, core, registry)
72
+ flat_left, flat_right = _flatten(left), _flatten(right)
73
+ lines: list[str] = []
74
+ for key in sorted(set(flat_left) | set(flat_right)):
75
+ if key not in flat_right:
76
+ lines.append(f"- {key}: {_show(flat_left[key])}")
77
+ elif key not in flat_left:
78
+ lines.append(f"+ {key}: {_show(flat_right[key])}")
79
+ elif not deep_equal(flat_left[key], flat_right[key]):
80
+ lines.append(f"~ {key}: {_show(flat_left[key])} -> {_show(flat_right[key])}")
81
+ if len(lines) > limit:
82
+ lines = [*lines[:limit], f"... {len(lines) - limit} more"]
83
+ if not lines and not deep_equal(left, right):
84
+ dump_left = json.dumps(left, indent=2, sort_keys=True, default=str).splitlines()
85
+ dump_right = json.dumps(right, indent=2, sort_keys=True, default=str).splitlines()
86
+ lines = list(difflib.unified_diff(dump_left, dump_right, "a", "b", lineterm=""))
87
+ return "\n".join(lines)
88
+
89
+
90
+ def deep_equal(a: Any, b: Any, *, strict: bool = False, rel_tol: float = 1e-9) -> bool:
91
+ """
92
+ Structural equality that tolerates numpy arrays and float noise.
93
+
94
+ With `strict=False` (the normalizer's mode) tuples equal lists and ints equal floats; with `strict=True`
95
+ container types and numeric types must match exactly (the v1 parity tests use this).
96
+ """
97
+ if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
98
+ if strict and not (isinstance(a, np.ndarray) and isinstance(b, np.ndarray)):
99
+ return False
100
+ return deep_equal(_to_list(a), _to_list(b), strict=False, rel_tol=rel_tol)
101
+ if isinstance(a, dict) and isinstance(b, dict):
102
+ return set(a) == set(b) and all(deep_equal(a[k], b[k], strict=strict, rel_tol=rel_tol) for k in a)
103
+ if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
104
+ if strict and type(a) is not type(b):
105
+ return False
106
+ return len(a) == len(b) and all(deep_equal(x, y, strict=strict, rel_tol=rel_tol) for x, y in zip(a, b))
107
+ if isinstance(a, bool) or isinstance(b, bool):
108
+ return a is b or (isinstance(a, bool) and isinstance(b, bool) and a == b)
109
+ if _is_number(a) and _is_number(b):
110
+ if strict and (isinstance(a, float) != isinstance(b, float)):
111
+ return False
112
+ return math.isclose(float(a), float(b), rel_tol=rel_tol, abs_tol=0.0)
113
+ return bool(a == b)
114
+
115
+
116
+ # --- schema half -------------------------------------------------------------------------------------------------
117
+
118
+
119
+ def _norm_schema(schema: Any, core: Any, path: tuple[str, ...], table: _EdgeTable) -> Any:
120
+ if isinstance(schema, dict) and schema.get("_type") in EDGE_KINDS:
121
+ table[path] = {k: schema[k] for k in ("_type", "_inputs", "_outputs") if k in schema}
122
+ return None
123
+ if isinstance(schema, dict) and "_type" not in schema:
124
+ out: dict[str, Any] = {}
125
+ for key in sorted(schema, key=str):
126
+ if not path and key == "global_time":
127
+ continue
128
+ value = _norm_schema(schema[key], core, (*path, str(key)), table)
129
+ if value is not None:
130
+ out[str(key)] = value
131
+ return out
132
+ return _canon_type(schema, core)
133
+
134
+
135
+ def _canon_type(spec: Any, core: Any) -> Any:
136
+ """
137
+ One canonical shape for every spelling of a type: `float{3.5}` and `{"_type": "float", "_default": 3.5}`,
138
+ `map[string,float]` and `map[float]`, `a:float|b:float` and `{"a": "float", "b": "float"}`,
139
+ `array[(2|3),T]` and `{"_type": "array", "_shape": [2, 3], "_data": T}`. The canonical form is built from
140
+ the parsed type tree, so unknown (custom) type names survive.
141
+ """
142
+ try:
143
+ expr = parse_type(spec)
144
+ except (TypeError, ValueError, KeyError, IndexError):
145
+ return _canon(spec, core)
146
+ return _canon_expr(expr, core)
147
+
148
+
149
+ def _canon_expr(expr: TypeExpr, core: Any) -> Any:
150
+ if isinstance(expr, Named):
151
+ return _canon_named(expr, core)
152
+ if isinstance(expr, Struct):
153
+ return {k: _canon_expr(v, core) for k, v in sorted(expr.fields.items())}
154
+ if isinstance(expr, UnionType):
155
+ return {"_type": "union", "_options": [_canon_expr(o, core) for o in expr.options]}
156
+ if isinstance(expr, Group):
157
+ return [int(i) if str(i).lstrip("-").isdigit() else i for i in expr.items]
158
+ out: dict[str, Any] = {}
159
+ for key, value in sorted(expr.attrs.items(), key=lambda kv: str(kv[0])):
160
+ if key in ("_inputs", "_outputs"):
161
+ out[key] = _canon_ports(value, core)
162
+ elif key in ("_value", "_element", "_data", "_sort", "_key", "_leaf") and isinstance(value, (str, dict)):
163
+ out[key] = _canon_type(value, core)
164
+ else:
165
+ out[key] = _canon(value, core)
166
+ return out
167
+
168
+
169
+ def _canon_named(expr: Named, core: Any) -> Any:
170
+ name = _TYPE_ALIASES.get(expr.name, expr.name)
171
+ groups = [a for a in expr.args if isinstance(a, Group)]
172
+ others = [a for a in expr.args if not isinstance(a, Group)]
173
+ if name == "map" and len(others) == 2 and isinstance(others[0], Named) and others[0].name == "string":
174
+ others = others[1:]
175
+ out: Any
176
+ numeric = [a for a in others if isinstance(a, Named) and a.name.lstrip("-").isdigit()]
177
+ if name == "array" or groups or expr.units is not None:
178
+ dims = _canon_expr(groups[0], core) if groups else [int(a.name) for a in numeric]
179
+ data = [a for a in others if a not in numeric]
180
+ out = {"_type": name, "_shape": dims or [1], "_data": _canon_expr(data[-1], core) if data else "float"}
181
+ if expr.units is not None:
182
+ out["_units"] = expr.units
183
+ elif others:
184
+ out = {"_type": name, "_args": [_canon_expr(a, core) for a in others]}
185
+ else:
186
+ out = name
187
+ if expr.default is not MISSING:
188
+ out = {"_type": out} if isinstance(out, str) else out
189
+ out["_default"] = _canon(expr.default, core)
190
+ return out
191
+
192
+
193
+ def _canon_ports(ports: Any, core: Any) -> Any:
194
+ if isinstance(ports, str):
195
+ try:
196
+ parsed = parse_type(ports)
197
+ except (TypeError, ValueError, KeyError, IndexError):
198
+ return ports
199
+ ports = parsed.fields if isinstance(parsed, Struct) else {}
200
+ return {str(k): _canon_expr(v, core) for k, v in sorted(ports.items())}
201
+ if isinstance(ports, dict):
202
+ return {str(k): _canon_type(v, core) for k, v in sorted(ports.items(), key=lambda kv: str(kv[0]))}
203
+ return _canon(ports, core)
204
+
205
+
206
+ _TYPE_ALIASES = {"int": "integer", "bool": "boolean"}
207
+
208
+
209
+ # --- state half --------------------------------------------------------------------------------------------------
210
+
211
+
212
+ def _norm_state(
213
+ state: Any,
214
+ schema: Any,
215
+ core: Any,
216
+ path: tuple[str, ...],
217
+ table: _EdgeTable,
218
+ registry: InterfaceRegistry | None = None,
219
+ ) -> Any:
220
+ if is_site(state):
221
+ return _canon_site(state, core)
222
+ if isinstance(state, dict) and (is_edge_node(state) or path in table):
223
+ return _canon_edge(state, core, path, table, registry)
224
+ if isinstance(state, dict) and not (isinstance(schema, dict) and "_type" in schema):
225
+ out: dict[str, Any] = {}
226
+ sub_schema = schema if isinstance(schema, dict) else {}
227
+ keys = set(state) | {k for k in sub_schema if (*path, str(k)) not in table}
228
+ for key in sorted(keys, key=str):
229
+ if not path and key == "global_time":
230
+ continue
231
+ if key in state:
232
+ out[str(key)] = _norm_state(state[key], sub_schema.get(key), core, (*path, str(key)), table, registry)
233
+ else:
234
+ default = _schema_default(sub_schema[key], core)
235
+ if default is not None:
236
+ out[str(key)] = _norm_state(default, sub_schema[key], core, (*path, str(key)), table, registry)
237
+ return out
238
+ return _canon(state, core)
239
+
240
+
241
+ def _schema_default(schema: Any, core: Any) -> Any:
242
+ """What process-bigraph materializes for a schema entry that has no state (a saved document contains it)."""
243
+ if isinstance(schema, dict) and schema.get("_type") in EDGE_KINDS:
244
+ return None
245
+ try:
246
+ default = core.default(core.access(schema))
247
+ except Exception: # noqa: BLE001
248
+ return None
249
+ return default[1] if isinstance(default, tuple) and len(default) == 2 else default
250
+
251
+
252
+ def _canon_edge(
253
+ node: dict[str, Any],
254
+ core: Any,
255
+ path: tuple[str, ...],
256
+ table: _EdgeTable,
257
+ registry: InterfaceRegistry | None = None,
258
+ ) -> dict[str, Any]:
259
+ info = table.get(path, {})
260
+ out: dict[str, Any] = {"_type": node.get("_type") or info.get("_type")}
261
+ address = node.get("address")
262
+ if address is not None:
263
+ out["address"] = _canon_address(address, core)
264
+ decl = None
265
+ if registry is not None and address is not None and not is_site(address):
266
+ decl = registry.lookup(_canon_address(address, core))
267
+ if decl is not None and decl.address is not None:
268
+ out["address"] = decl.address # `local:Name` and `local:!module.Name` name the same component
269
+ for key, attr in (("_inputs", "inputs"), ("_outputs", "outputs")):
270
+ ports = node.get(key, info.get(key))
271
+ if ports is None:
272
+ continue
273
+ canon = _canon_ports(ports, core)
274
+ declared = getattr(decl, attr) if decl is not None else None
275
+ if declared is not None and canon == _canon_ports(declared, core):
276
+ continue # the node repeats the interface's face: no information
277
+ out[key] = canon
278
+ config = node.get("config")
279
+ if isinstance(config, dict) and _is_composite_address(address):
280
+ config = dict(config)
281
+ inner_table: _EdgeTable = {}
282
+ inner_schema = config.get("schema") or {}
283
+ config["schema"] = _norm_schema(inner_schema, core, (), inner_table) or {}
284
+ config["state"] = _norm_state(config.get("state") or {}, inner_schema, core, (), inner_table, registry)
285
+ if config:
286
+ out["config"] = _canon(config, core)
287
+ for key in ("inputs", "outputs", "_triggers"):
288
+ if node.get(key):
289
+ out[key] = _canon_wires(node[key])
290
+ # a process without an interval runs every 1.0: the default is not a difference
291
+ if (
292
+ node.get("interval") is not None
293
+ and not _is_number(node["interval"])
294
+ or (_is_number(node.get("interval")) and not math.isclose(float(node["interval"]), 1.0))
295
+ ):
296
+ out["interval"] = _canon(node["interval"], core)
297
+ if node.get("priority") is not None:
298
+ out["priority"] = _canon(node["priority"], core)
299
+ for key, value in node.items():
300
+ if key in out or key in RUNTIME_EDGE_KEYS or key in ("_type", "address", "config", "inputs", "outputs"):
301
+ continue
302
+ if key in ("_inputs", "_outputs", "_triggers", "interval", "priority"):
303
+ continue
304
+ out[str(key)] = _canon(value, core)
305
+ return out
306
+
307
+
308
+ def _is_composite_address(address: Any) -> bool:
309
+ text = _canon_address(address, None) if address is not None else ""
310
+ return isinstance(text, str) and text.split(":", 1)[-1].lstrip("!").rsplit(".", 1)[-1] == "Composite"
311
+
312
+
313
+ def _canon_address(address: Any, core: Any) -> Any:
314
+ if is_site(address):
315
+ return _canon_site(address, core)
316
+ if isinstance(address, dict) and "protocol" in address:
317
+ return f"{address['protocol']}:{address.get('data', '')}"
318
+ if isinstance(address, str) and ":" not in address:
319
+ return f"local:{address}"
320
+ return address
321
+
322
+
323
+ def _canon_site(site: dict[str, Any], core: Any) -> dict[str, Any]:
324
+ out: dict[str, Any] = {"_type": "site"}
325
+ if "_sort" in site:
326
+ sort = site["_sort"]
327
+ out["_sort"] = _canon_type(sort, core) if core is not None else _canon(sort, core)
328
+ if "_default" in site:
329
+ out["_default"] = _canon(site["_default"], core)
330
+ return out
331
+
332
+
333
+ def _canon_wires(wires: Any) -> Any:
334
+ if isinstance(wires, dict):
335
+ return {str(k): _canon_wires(v) for k, v in sorted(wires.items(), key=lambda kv: str(kv[0]))}
336
+ if isinstance(wires, str):
337
+ return [wires]
338
+ if isinstance(wires, (list, tuple)):
339
+ return [int(s) if isinstance(s, (int, np.integer)) and not isinstance(s, bool) else str(s) for s in wires]
340
+ return wires
341
+
342
+
343
+ # --- values ------------------------------------------------------------------------------------------------------
344
+
345
+
346
+ def _canon(value: Any, core: Any) -> Any:
347
+ if is_site(value):
348
+ return _canon_site(value, core)
349
+ if isinstance(value, dict):
350
+ return {str(k): _canon(v, core) for k, v in sorted(value.items(), key=lambda kv: str(kv[0]))}
351
+ if isinstance(value, (list, tuple)):
352
+ return [_canon(v, core) for v in value]
353
+ if isinstance(value, np.ndarray):
354
+ return _canon(value.tolist(), core)
355
+ if isinstance(value, np.generic):
356
+ return value.item()
357
+ return value
358
+
359
+
360
+ def _to_list(value: Any) -> Any:
361
+ return value.tolist() if isinstance(value, np.ndarray) else value
362
+
363
+
364
+ def _is_number(value: Any) -> bool:
365
+ return isinstance(value, (int, float, np.integer, np.floating)) and not isinstance(value, bool)
366
+
367
+
368
+ def _flatten(tree: Any, prefix: str = "") -> dict[str, Any]:
369
+ if isinstance(tree, dict) and tree:
370
+ out: dict[str, Any] = {}
371
+ for key, value in tree.items():
372
+ out.update(_flatten(value, f"{prefix}/{key}" if prefix else str(key)))
373
+ return out
374
+ if isinstance(tree, list) and tree and any(isinstance(v, (dict, list)) for v in tree):
375
+ out = {}
376
+ for index, value in enumerate(tree):
377
+ out.update(_flatten(value, f"{prefix}[{index}]"))
378
+ return out
379
+ return {prefix: tree}
380
+
381
+
382
+ def _show(value: Any) -> str:
383
+ text = json.dumps(value, default=str)
384
+ return text if len(text) <= 120 else text[:117] + "..."