bigraph-schema 1.0.0__py3-none-any.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.
bigraph_schema/core.py ADDED
@@ -0,0 +1,759 @@
1
+ """
2
+ Bigraph-Schema Core
3
+ ===================
4
+
5
+ This module defines the **Core** class — the main operational interface for
6
+ `bigraph-schema`. Core manages the translation between *compiled*
7
+ (dataclass-based) and *encoded* (JSON-compatible) representations of both
8
+ **schemas** and **states**.
9
+
10
+ Core provides a consistent API for all major transformations:
11
+ - `access` / `render`: parse and serialize schema definitions
12
+ - `default` / `infer`: connect schemas to example states
13
+ - `serialize` / `realize`: encode and decode state data
14
+
15
+ These methods form a reversible, type-aware layer for schema construction,
16
+ validation, and data transformation.
17
+
18
+ `CoreVisitor` implements the parsing backend, converting textual bigraph
19
+ expressions into structured schema nodes (`Union`, `Tuple`, `Array`, `Link`, etc.).
20
+ """
21
+
22
+ import sys
23
+ import copy
24
+ import typing
25
+ import inspect
26
+
27
+ from pprint import pformat as pf
28
+
29
+ import numpy as np
30
+ from numpy import dtype
31
+ import numpy.lib.format as nf
32
+
33
+ import pytest
34
+ import logging
35
+
36
+ from plum import dispatch
37
+ from parsimonious.nodes import NodeVisitor
38
+ from dataclasses import dataclass, is_dataclass, replace
39
+ import importlib.metadata
40
+
41
+ from bigraph_schema.schema import (
42
+ BASE_TYPES,
43
+ resolve_path,
44
+ convert_jump,
45
+ convert_path,
46
+ blank_context,
47
+ Node,
48
+ Union,
49
+ Tuple,
50
+ Boolean,
51
+ Or,
52
+ And,
53
+ Xor,
54
+ Number,
55
+ Integer,
56
+ Float,
57
+ Delta,
58
+ Nonnegative,
59
+ String,
60
+ Enum,
61
+ Wrap,
62
+ Maybe,
63
+ Overwrite,
64
+ List,
65
+ Map,
66
+ Tree,
67
+ Array,
68
+ Key,
69
+ Path,
70
+ Wires,
71
+ Schema,
72
+ Link,
73
+ Jump,
74
+ Star,
75
+ Index,
76
+ )
77
+
78
+ from bigraph_schema.parse import visit_expression
79
+ from bigraph_schema.edge import Edge
80
+ from bigraph_schema.methods import (
81
+ reify_schema,
82
+ handle_parameters,
83
+ infer,
84
+ default,
85
+ resolve,
86
+ generalize,
87
+ check,
88
+ validate,
89
+ render,
90
+ serialize,
91
+ realize,
92
+ merge,
93
+ jump,
94
+ traverse,
95
+ apply)
96
+
97
+ from bigraph_schema.package import discover_packages
98
+
99
+
100
+ def schema_keys(schema):
101
+ keys = []
102
+ for key in schema.__dataclass_fields__:
103
+ if key.startswith('_'):
104
+ keys.append(key)
105
+ return keys
106
+
107
+
108
+ class CoreVisitor(NodeVisitor):
109
+ """Visitor that converts parsed bigraph expressions into schema node structures.
110
+
111
+ Operates within a `Core` context, mapping grammar constructs
112
+ (unions, merges, type parameters, and defaults) into dataclass-based nodes.
113
+ Handles normalization of nested expressions (e.g. `tuple[int,float]`,
114
+ `link[a:int|b:string]`, `(x:y|z:w)`) into instances of `Union`, `Tuple`,
115
+ or structured dicts.
116
+ """
117
+
118
+ def __init__(self, operation):
119
+ """Initialize with the active `Core`."""
120
+ self.operation = operation
121
+
122
+ def visit_expression(self, node, visit):
123
+ """Top-level entry; returns first child."""
124
+ return visit[0]
125
+
126
+ def visit_union(self, node, visit):
127
+ """Parse `a~b~c` into a `Union(_options=[a,b,c])`."""
128
+ head = [visit[0]]
129
+ tail = [tree['visit'][1] for tree in visit[1]['visit']]
130
+ return Union(_options=head + tail)
131
+
132
+ def visit_merge(self, node, visit):
133
+ """Parse `a|b|c`; dicts merge to one mapping, others form a `Tuple`."""
134
+ head = [visit[0]]
135
+ tail = [tree['visit'][1] for tree in visit[1]['visit']]
136
+ nodes = head + tail
137
+ if all(isinstance(tree, dict) for tree in nodes):
138
+ merged = {}
139
+ for tree in nodes:
140
+ merged.update(tree)
141
+ return merged
142
+ else:
143
+ try:
144
+ values = tuple([int(x) for x in nodes])
145
+
146
+ return values
147
+ except Exception as e:
148
+ return Tuple(_values=nodes)
149
+
150
+ def visit_tree(self, node, visit):
151
+ """Delegate directly to nested element."""
152
+ return visit[0]
153
+
154
+ def visit_bigraph(self, node, visit):
155
+ """Alias for tree; allows recursion within nested bigraphs."""
156
+ return visit[0]
157
+
158
+ def visit_group(self, node, visit):
159
+ """Handle grouped subexpression `( ... )`; return tuple or dict."""
160
+ group_value = visit[1]
161
+ return group_value if isinstance(group_value, (list, tuple, dict, Tuple)) else (group_value,)
162
+
163
+ def visit_nest(self, node, visit):
164
+ """Handle `key:subtype` pairs (used in trees/maps)."""
165
+ return {visit[0]: visit[2]}
166
+
167
+ def visit_type_name(self, node, visit):
168
+ """Resolve base type, parameters, and defaults into schema nodes."""
169
+ schema = visit[0]
170
+
171
+ # Parse parameter list
172
+ type_parameters = [
173
+ parameter
174
+ for parameter in visit[1]['visit']]
175
+
176
+ if type_parameters:
177
+ schema = handle_parameters(self.operation, schema, type_parameters[0])
178
+
179
+ # Parse default value `{...}`
180
+ default_visit = visit[2]['visit']
181
+ if default_visit:
182
+ default = default_visit[0]
183
+ if isinstance(schema, Node):
184
+ schema._default = default
185
+ elif isinstance(schema, dict):
186
+ schema['_default'] = default
187
+
188
+ return schema
189
+
190
+ def visit_parameter_list(self, node, visit):
191
+ """Return ordered list of parameters `[A,B,C]`."""
192
+ first = [visit[1]]
193
+ rest = [inner['visit'][1] for inner in visit[2]['visit']]
194
+ return first + rest
195
+
196
+ def visit_default_block(self, node, visit):
197
+ """Extract contents of `{...}` blocks."""
198
+ return visit[1]
199
+
200
+ def visit_default(self, node, visit):
201
+ """Return text inside default braces as string."""
202
+ return node.text
203
+
204
+ def visit_symbol(self, node, visit):
205
+ """Resolve bare symbol names via the operation registry or parse visitor."""
206
+ return self.operation.access(node.text)
207
+
208
+ def visit_nothing(self, node, visit):
209
+ """Handle empty productions (e.g., trailing commas)."""
210
+ return None
211
+
212
+ def generic_visit(self, node, visit):
213
+ """Fallback: return raw parse node and visited children."""
214
+ return {'node': node, 'visit': visit}
215
+
216
+
217
+ class Core:
218
+ """Bigraph-schema operation: registry, parsing, normalization, and ops.
219
+
220
+ - Maintains a registry mapping type keys to node constructors (see `BASE_TYPES`).
221
+ - Normalizes schema representations (strings, dicts, lists) into dataclass nodes
222
+ via `access(...)` using the bigraph grammar (`parse.visit_expression`).
223
+ - Exposes core methods (`infer`, `render`, `default`, `resolve`, `check`,
224
+ `serialize`, `realize`, `merge`, `jump`, `traverse`, `bind`, `apply`).
225
+ - Post-access invariants: e.g., `Array._shape -> tuple[int,...]`,
226
+ `Array._data -> numpy.dtype`; node fields like `_values`, `_options`,
227
+ `_key/_value`, `_inputs/_outputs` are populated.
228
+ """
229
+
230
+ def __init__(self, types):
231
+ """Initialize operation with a base type registry (e.g., `BASE_TYPES`)."""
232
+
233
+ self.packages_distributions = {
234
+ key: list(set(values))
235
+ for key, values in importlib.metadata.packages_distributions().items()
236
+ for value in values}
237
+
238
+ self.distributions_packages = {
239
+ value: key
240
+ for key, values in self.packages_distributions.items()
241
+ for value in values}
242
+
243
+ self.registry = {}
244
+ self.link_registry = {}
245
+ self.method_registry = {}
246
+
247
+ self.parse_visitor = CoreVisitor(self)
248
+
249
+ self.register_types(types)
250
+ self.register_link('edge', Edge)
251
+
252
+ def register_type(self, key, data):
253
+ """Register a single type key; deep-merge if it already exists."""
254
+ found = self.access(data)
255
+ if key in self.registry:
256
+ self.update_type(key, found)
257
+ else:
258
+ self.registry[key] = found
259
+
260
+ def register_types(self, types):
261
+ """Bulk register multiple type keys into the operation registry."""
262
+ for key, data in types.items():
263
+ self.register_type(key, data)
264
+
265
+ def update_type(self, key, data):
266
+ """Deep-merge metadata/overrides into an existing registry entry."""
267
+ if self.registry[key] != data:
268
+ self.registry[key] = self.resolve(self.registry[key], data)
269
+
270
+ def register_link(self, key, link):
271
+ if key in self.registry:
272
+ self.update_link(key, link)
273
+ else:
274
+ self.link_registry[key] = link
275
+
276
+ def register_links(self, links):
277
+ """Bulk register multiple link into the operation registry."""
278
+ for key, link in links.items():
279
+ self.register_link(key, link)
280
+
281
+ def update_link(self, key, data):
282
+ """Deep-merge metadata/overrides into an existing registry entry."""
283
+ self.link_registry[key] = data
284
+
285
+ def register_method(self, key, data):
286
+ self.method_registry[key] = data
287
+
288
+ def call_method(self, key, *args, **kwargs):
289
+ method = self.method_registry.get(key)
290
+ if method is None:
291
+ raise Exception(f'no method {key} in the method registry')
292
+
293
+ return method(self, *args, **kwargs)
294
+
295
+ def select_fields(self, base, schema):
296
+ """Project dict `schema` onto dataclass `base` fields, normalizing values via `access` (except `_default`)."""
297
+ select = {}
298
+ for key in base.__dataclass_fields__.keys():
299
+ schema_key = schema.get(key)
300
+ if schema_key:
301
+ down = schema_key if key == '_default' else self.access(schema_key)
302
+ select[key] = down
303
+ return select
304
+
305
+ def make_instance(self, base, state):
306
+ """Instantiate dataclass `base` from dict `state` after field selection/normalization."""
307
+ fields = self.select_fields(base, state)
308
+ instance = base(**fields)
309
+ return instance
310
+
311
+ def access_type(self, value):
312
+ if isinstance(value, dict) and '_type' in value:
313
+ schema = self.access(value['_type'])
314
+
315
+ default_value = None
316
+ if '_default' in value:
317
+ default_value = value['_default']
318
+ elif isinstance(schema, Node) and schema._default is not None:
319
+ default_value = schema._default
320
+
321
+ if not isinstance(schema, Node):
322
+ raise Exception(f'accessing {value} but schema is not found\n{schema}')
323
+
324
+ schema = replace(
325
+ schema,
326
+ **{'_default': default_value})
327
+
328
+ parameters = {
329
+ key: subvalue
330
+ for key, subvalue in value.items()
331
+ if not key in ('_type', '_default')}
332
+
333
+ schema = reify_schema(self, schema, parameters)
334
+ return schema
335
+
336
+ def resolve_inherit(self, key):
337
+ result = {}
338
+ if '_inherit' in key:
339
+ inherit = key['_inherit']
340
+ if not isinstance(inherit, list):
341
+ inherit = [inherit]
342
+ for ancestor in inherit:
343
+ found = self.access(ancestor)
344
+ if not result:
345
+ result = found
346
+ else:
347
+ result = self.resolve(result, found)
348
+ return result
349
+
350
+ def access(self, key):
351
+ """Interpret an encoded schema or object and produce a compiled node.
352
+
353
+ Converts strings, dicts, or lists into dataclass-based schema instances.
354
+ Acts as the main entry point for parsing bigraph expressions and building
355
+ normalized in-memory representations.
356
+ """
357
+
358
+ # TODO: consider other terms for this?
359
+ # * compile
360
+ # * parse
361
+
362
+ if is_dataclass(key):
363
+ return key
364
+
365
+ elif isinstance(key, str):
366
+ if key not in self.registry:
367
+ try:
368
+ return visit_expression(key, self.parse_visitor)
369
+ except Exception as e:
370
+ raise Exception(f'unable to parse type "{key}"\n\ndue to\n{e}')
371
+ else:
372
+ entry = self.registry[key]
373
+ if callable(entry):
374
+ return entry()
375
+ elif isinstance(entry, Node):
376
+ return entry
377
+ elif isinstance(entry, dict):
378
+ return self.access(entry)
379
+
380
+ elif isinstance(key, dict):
381
+ if '_type' in key:
382
+ return self.access_type(key)
383
+
384
+ else:
385
+ result = self.resolve_inherit(key)
386
+
387
+ for subkey, subitem in key.items():
388
+ if isinstance(subkey, str):
389
+ subitem = subitem if subkey.startswith('_') else self.access(subitem)
390
+ if isinstance(result, Node):
391
+ if hasattr(result, subkey):
392
+ result = replace(result, **{subkey: subitem})
393
+ else:
394
+ setattr(result, subkey, subitem)
395
+ else:
396
+ result[subkey] = subitem
397
+
398
+ return result
399
+
400
+ elif isinstance(key, list):
401
+ return [self.access(element) for element in key]
402
+ else:
403
+ return key
404
+
405
+ def infer_merges(self, state, path=()):
406
+ """Derive a schema that matches the structure of an example state.
407
+
408
+ Analyzes values to infer types, shapes, and nested relationships, generating
409
+ a schema node that captures the structure of the provided data.
410
+ """
411
+ return infer(self, state, path=path)
412
+
413
+ def infer(self, state, path=()):
414
+ """Derive a schema that matches the structure of an example state.
415
+
416
+ Analyzes values to infer types, shapes, and nested relationships, generating
417
+ a schema node that captures the structure of the provided data.
418
+ """
419
+ schema, merges = infer(self, state, path=path)
420
+ merge_schema = self.resolve_merges(schema, merges)
421
+
422
+ return merge_schema
423
+
424
+ def render(self, schema, defaults=False):
425
+ """Produce a serializable view of a compiled schema.
426
+
427
+ Converts internal dataclass nodes into JSON-friendly dicts or strings.
428
+ This is the inverse of `access()`, ensuring round-trip fidelity between
429
+ code representations and stored schema definitions.
430
+ """
431
+ found = self.access(schema)
432
+ return render(found, defaults=defaults)
433
+
434
+ def default_merges(self, schema, path=()):
435
+ """Generate a representative state that satisfies a schema.
436
+
437
+ Uses type defaults and explicit `_default` values to instantiate an example
438
+ state consistent with the given schema.
439
+ """
440
+ found = self.access(schema)
441
+
442
+ value = default(found)
443
+ return realize(self, found, value, path=path)
444
+
445
+ def default(self, schema, path=()):
446
+ found = self.access(schema)
447
+ value = default(found)
448
+ return self.realize(found, value, path=path)
449
+
450
+ def resolve(self, current_schema, update_schema, path=None):
451
+ """Unify two schemas under node semantics (e.g., Map/Tree/Link field-wise resolution)."""
452
+ current = self.access(current_schema)
453
+ update = self.access(update_schema)
454
+
455
+ if path:
456
+ return resolve(current, update, path=path)
457
+
458
+ try:
459
+ if current == update:
460
+ return current
461
+ else:
462
+ return resolve(current, update)
463
+ except ValueError:
464
+ # numpy grumble grumble
465
+ return resolve(current, update)
466
+
467
+ def generalize(self, current_schema, update_schema):
468
+ """Unify two schemas under node semantics (e.g., Map/Tree/Link field-wise resolution)."""
469
+ current = self.access(current_schema)
470
+ update = self.access(update_schema)
471
+ return generalize(current, update)
472
+
473
+ def check(self, schema, state):
474
+ """Returns True if the `state` fits the `schema`."""
475
+ found = self.access(schema)
476
+ return check(found, state)
477
+
478
+ def validate(self, schema, state, message=None):
479
+ """Returns a nested description of how the state does not match the schema"""
480
+ found = self.access(schema)
481
+ validation = validate(self, found, state)
482
+ if validation:
483
+ message = f'state failed schema validation:\\nschema: {pf(render(schema))}\n\nstate: {pf(state)}'
484
+ raise Exception(f'{message}: {validation}')
485
+
486
+ def serialize(self, schema, state):
487
+ """Convert a structured Python state into an encoded representation.
488
+
489
+ Encodes typed values into JSON-compatible primitives while respecting the
490
+ schema’s structure and constraints.
491
+ """
492
+ found = self.access(schema)
493
+ return serialize(found, state)
494
+
495
+ def realize(self, schema, state, path=()):
496
+ """Convert an encoded representation back into structured Python values.
497
+
498
+ Decodes strings, numbers, and nested structures into their appropriate types,
499
+ guided by the provided schema.
500
+ """
501
+ found = self.access(schema)
502
+
503
+ decode_schema, decode_state, merges = realize(
504
+ self,
505
+ found,
506
+ state,
507
+ path=path)
508
+
509
+ if merges:
510
+ merge_schema = self.resolve_merges({}, merges)
511
+ decode_schema = self.resolve(decode_schema, merge_schema)
512
+ merge_state = self.fill(merge_schema, decode_state)
513
+ else:
514
+ merge_state = decode_state
515
+
516
+ return decode_schema, merge_state
517
+
518
+ def generalize_merges(self, schema, merges):
519
+ if len(merges) > 0:
520
+ merge_schema = {}
521
+ for path, subschema in merges:
522
+ merge_schema = self.resolve(
523
+ merge_schema,
524
+ subschema,
525
+ resolve_path(path))
526
+
527
+ schema = self.generalize(schema, merge_schema)
528
+
529
+ return schema
530
+
531
+ def resolve_merges(self, schema, merges):
532
+ if len(merges) > 0:
533
+ merge_schema = {}
534
+ for path, subschema in merges:
535
+ merge_schema = self.resolve(
536
+ merge_schema,
537
+ subschema,
538
+ resolve_path(path))
539
+
540
+ schema = self.resolve(schema, merge_schema)
541
+
542
+ return schema
543
+
544
+ def resolve_schemas(self, schemas):
545
+ if len(schemas) > 0:
546
+ schema = schemas[0]
547
+ for subschema in schemas[1:]:
548
+ schema = self.resolve(schema, subschema)
549
+ return schema
550
+
551
+ def jump(self, schema, state, raw_key):
552
+ """Navigate by logical jump (`Key`/`Index`/`Star`)."""
553
+ found = self.access(schema)
554
+ key = convert_jump(raw_key)
555
+ context = blank_context(found, state, ())
556
+ return jump(found, state, key, context)
557
+
558
+ def traverse(self, schema, state, raw_path):
559
+ """Traverse along a resolved path (supports `..` and wildcards) via `convert_path`."""
560
+ found = self.access(schema)
561
+ path = convert_path(raw_path)
562
+ context = blank_context(found, state, path)
563
+ return traverse(found, state, path, context)
564
+
565
+ def bind(self, schema, state, raw_key, target):
566
+ """Bind a logical key (jump) to a target."""
567
+ found = self.access(schema)
568
+ key = convert_jump(raw_key)
569
+ return bind(found, state, key, target)
570
+
571
+ def merge(self, schema, state, merge_state, path=()):
572
+ """Schema-aware merge of `merge_state` into `state`."""
573
+ found = self.access(schema)
574
+ return merge(found, state, merge_state, path=path)
575
+
576
+ def fill(self, schema, state, overwrite=False):
577
+ found = self.access(schema)
578
+ base_schema, base_state, merges = self.default_merges(found)
579
+ merge_schema = self.resolve_merges(base_schema, merges)
580
+
581
+ if overwrite:
582
+ return merge(merge_schema, state, base_state)
583
+ else:
584
+ return merge(merge_schema, base_state, state)
585
+
586
+ def view_ports(self, schema, state, path, ports_schema, wires):
587
+ if isinstance(wires, str):
588
+ wires = [wires]
589
+
590
+ if isinstance(wires, (list, tuple)):
591
+ _, result = self.traverse(schema, state, list(path) + list(wires))
592
+
593
+ elif isinstance(wires, dict):
594
+ result = {}
595
+ for port_key, subport in wires.items():
596
+ subschema, subwires = self.jump(
597
+ ports_schema,
598
+ wires,
599
+ port_key)
600
+
601
+ inner_view = self.view_ports(
602
+ schema,
603
+ state,
604
+ path,
605
+ subschema,
606
+ subwires)
607
+
608
+ if inner_view is not None:
609
+ result[port_key] = inner_view
610
+
611
+ else:
612
+ raise Exception(f'trying to view state at path {path} with these ports:\n{ports_schema}\nbut not sure what these wires are:\n{wires}')
613
+
614
+ return result
615
+
616
+ def view(self, schema, state, link_path, ports_key='inputs'):
617
+ found = self.access(schema)
618
+ link_schema, link_state = self.traverse(schema, state, link_path)
619
+ ports_schema = getattr(link_schema, f'_{ports_key}')
620
+ wires = link_state.get(ports_key) or {}
621
+ view = self.view_ports(
622
+ schema,
623
+ state,
624
+ link_path[:-1],
625
+ ports_schema,
626
+ wires)
627
+
628
+ return view
629
+
630
+ def project_ports(self, ports_schema, wires, path, view):
631
+ project_schema = {}
632
+ project_state = {}
633
+
634
+ if isinstance(wires, str):
635
+ wires = [wires]
636
+
637
+ if isinstance(wires, (list, tuple)):
638
+ destination = resolve_path(list(path) + list(wires))
639
+
640
+ project_schema = self.resolve(
641
+ project_schema,
642
+ ports_schema,
643
+ path=destination)
644
+
645
+ project_state = self.merge(
646
+ project_schema,
647
+ project_state,
648
+ view,
649
+ path=destination)
650
+
651
+ elif isinstance(wires, dict):
652
+ if isinstance(view, list):
653
+ result = [
654
+ self.project_ports(ports_schema, wires, path, state)
655
+ for state in view]
656
+ project_schema = Tuple(_values=[
657
+ item[0]
658
+ for item in result])
659
+ project_state = [
660
+ item[1]
661
+ for item in result]
662
+ else:
663
+ branches = []
664
+ for key, subwires in wires.items():
665
+ subports, subview = self.jump(ports_schema, view, key)
666
+ if subview is None:
667
+ continue
668
+
669
+ subschema, substate = self.project_ports(
670
+ subports,
671
+ subwires,
672
+ path,
673
+ subview)
674
+
675
+ if substate is not None:
676
+ branches.append((subschema, substate))
677
+
678
+ project_schema = Node()
679
+ project_state = {}
680
+ for branch_schema, branch_state in branches:
681
+ project_schema = resolve(project_schema, branch_schema)
682
+ project_state = self.merge(project_schema, project_state, branch_state)
683
+
684
+ else:
685
+ raise Exception(
686
+ f'inverting state\n {view}\naccording to ports schema\n {ports_schema}\nbut wires are not recognized\n {wires}')
687
+
688
+ return project_schema, project_state
689
+
690
+ def project(self, schema, state, link_path, view, ports_key='outputs'):
691
+ found = self.access(schema)
692
+ link_schema, link_state = self.traverse(schema, state, link_path)
693
+ ports_schema = getattr(link_schema, f'_{ports_key}')
694
+ wires = link_state.get(ports_key) or {}
695
+ project_schema, project_state = self.project_ports(
696
+ ports_schema,
697
+ wires,
698
+ link_path[:-1],
699
+ view)
700
+
701
+ return project_schema, project_state
702
+
703
+ def combine(self, schema, state, update_schema, update_state):
704
+ resolved = self.resolve(schema, update_schema)
705
+ merged = self.merge(resolved, state, update_state)
706
+ decode_schema, decode_state = self.realize(resolved, merged)
707
+
708
+ return decode_schema, decode_state
709
+
710
+ def link_state(self, link, path):
711
+ result_schema = {}
712
+ result_state = {}
713
+
714
+ instance = link.get('instance')
715
+
716
+ if instance is not None:
717
+ initial_state = instance.initial_state()
718
+
719
+ for ports_key in ['inputs', 'outputs']:
720
+ ports_schema = link.get(f'_{ports_key}', {})
721
+ wires = link.get(ports_key, {})
722
+ project_schema, project_state = self.project_ports(ports_schema, wires, path[:-1], initial_state)
723
+ result_schema, result_state = self.combine(
724
+ result_schema, result_state,
725
+ project_schema, project_state)
726
+
727
+ return result_schema, result_state
728
+
729
+ def wire_schema(self, schema, state, wires, path=None):
730
+ outcome = {}
731
+ path = path or []
732
+
733
+ if isinstance(wires, dict):
734
+ for key, subwires in wires.items():
735
+ outcome[key] = self.wire_schema(
736
+ schema,
737
+ state,
738
+ wires[key],
739
+ path)
740
+
741
+ else:
742
+ outcome, _ = self.traverse(schema, state, path + wires)
743
+
744
+ return outcome
745
+
746
+ def apply(self, schema, state, update, path=()):
747
+ """Apply a schema-aware update/patch; provides minimal context."""
748
+ if update:
749
+ found = self.access(schema)
750
+ return apply(found, state, update, path)
751
+ else:
752
+ return state, []
753
+
754
+
755
+ def allocate_core(top=None):
756
+ core = Core(BASE_TYPES)
757
+ core = discover_packages(core, top)
758
+
759
+ return core