labcode 0.9.0__tar.gz → 0.10.1__tar.gz

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.
Files changed (113) hide show
  1. {labcode-0.9.0 → labcode-0.10.1}/CHANGELOG.md +63 -0
  2. {labcode-0.9.0 → labcode-0.10.1}/PKG-INFO +4 -4
  3. {labcode-0.9.0 → labcode-0.10.1}/docs/SPECIFICATIONS.md +40 -28
  4. {labcode-0.9.0 → labcode-0.10.1}/examples/README.md +3 -3
  5. {labcode-0.9.0 → labcode-0.10.1}/labcode/_child.py +8 -9
  6. {labcode-0.9.0 → labcode-0.10.1}/labcode/backend.py +84 -35
  7. {labcode-0.9.0 → labcode-0.10.1}/labcode/dialect.py +18 -6
  8. {labcode-0.9.0 → labcode-0.10.1}/labcode/objectid.py +122 -33
  9. {labcode-0.9.0 → labcode-0.10.1}/labcode/run_cli.py +5 -0
  10. {labcode-0.9.0 → labcode-0.10.1}/labcode/runner.py +20 -3
  11. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/PKG-INFO +4 -4
  12. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/SOURCES.txt +2 -0
  13. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/requires.txt +3 -3
  14. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/scm_file_list.json +2 -0
  15. labcode-0.10.1/labcode.egg-info/scm_version.json +8 -0
  16. {labcode-0.9.0 → labcode-0.10.1}/pyproject.toml +18 -4
  17. labcode-0.10.1/tests/fixtures/dispense_read.workflow.yaml +81 -0
  18. {labcode-0.9.0 → labcode-0.10.1}/tests/test_backend.py +88 -4
  19. {labcode-0.9.0 → labcode-0.10.1}/tests/test_dialect.py +30 -3
  20. {labcode-0.9.0 → labcode-0.10.1}/tests/test_objectid.py +109 -0
  21. {labcode-0.9.0 → labcode-0.10.1}/tests/test_run_cli.py +32 -18
  22. labcode-0.10.1/tests/test_structured_labcode.py +135 -0
  23. labcode-0.9.0/labcode.egg-info/scm_version.json +0 -8
  24. {labcode-0.9.0 → labcode-0.10.1}/.github/workflows/ci.yml +0 -0
  25. {labcode-0.9.0 → labcode-0.10.1}/.github/workflows/publish.yml +0 -0
  26. {labcode-0.9.0 → labcode-0.10.1}/.gitignore +0 -0
  27. {labcode-0.9.0 → labcode-0.10.1}/LICENSE +0 -0
  28. {labcode-0.9.0 → labcode-0.10.1}/MANIFEST.in +0 -0
  29. {labcode-0.9.0 → labcode-0.10.1}/README.md +0 -0
  30. {labcode-0.9.0 → labcode-0.10.1}/examples/lads_plate_cycle.env.yaml +0 -0
  31. {labcode-0.9.0 → labcode-0.10.1}/examples/lads_seal.env.yaml +0 -0
  32. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/plate_line.boundary.yaml +0 -0
  33. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/plate_line.observation.yaml +0 -0
  34. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/plate_line.plan.yaml +0 -0
  35. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/plate_line.svg +0 -0
  36. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/shared_bench.boundary.yaml +0 -0
  37. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/shared_bench.observation.yaml +0 -0
  38. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/shared_bench.plan.yaml +0 -0
  39. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/shared_bench.svg +0 -0
  40. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/sila2_plate_cycle.boundary.yaml +0 -0
  41. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/sila2_plate_cycle.observation.yaml +0 -0
  42. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/sila2_plate_cycle.plan.yaml +0 -0
  43. {labcode-0.9.0 → labcode-0.10.1}/examples/outputs/sila2_plate_cycle.svg +0 -0
  44. {labcode-0.9.0 → labcode-0.10.1}/examples/plate_line.boundary.yaml +0 -0
  45. {labcode-0.9.0 → labcode-0.10.1}/examples/plate_line.env.yaml +0 -0
  46. {labcode-0.9.0 → labcode-0.10.1}/examples/plate_line.workflow.yaml +0 -0
  47. {labcode-0.9.0 → labcode-0.10.1}/examples/preflight_sila2_env.py +0 -0
  48. {labcode-0.9.0 → labcode-0.10.1}/examples/render_plate_line.py +0 -0
  49. {labcode-0.9.0 → labcode-0.10.1}/examples/render_shared_bench.py +0 -0
  50. {labcode-0.9.0 → labcode-0.10.1}/examples/render_sila2_plate_cycle.py +0 -0
  51. {labcode-0.9.0 → labcode-0.10.1}/examples/run_all_lads_examples.py +0 -0
  52. {labcode-0.9.0 → labcode-0.10.1}/examples/run_all_sila2_examples.py +0 -0
  53. {labcode-0.9.0 → labcode-0.10.1}/examples/run_sila2_plate_cycle.py +0 -0
  54. {labcode-0.9.0 → labcode-0.10.1}/examples/run_sila2_plate_cycle_no_atc.py +0 -0
  55. {labcode-0.9.0 → labcode-0.10.1}/examples/run_sila2_seal.py +0 -0
  56. {labcode-0.9.0 → labcode-0.10.1}/examples/shared_bench.afternoon.boundary.yaml +0 -0
  57. {labcode-0.9.0 → labcode-0.10.1}/examples/shared_bench.env.yaml +0 -0
  58. {labcode-0.9.0 → labcode-0.10.1}/examples/shared_bench.morning.boundary.yaml +0 -0
  59. {labcode-0.9.0 → labcode-0.10.1}/examples/shared_bench.run.yaml +0 -0
  60. {labcode-0.9.0 → labcode-0.10.1}/examples/shared_bench.workflow.yaml +0 -0
  61. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_plate_cycle.boundary.yaml +0 -0
  62. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_plate_cycle.workflow.yaml +0 -0
  63. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_plate_cycle.wrapped.env.yaml +0 -0
  64. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_plate_cycle_no_atc.boundary.yaml +0 -0
  65. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_plate_cycle_no_atc.workflow.yaml +0 -0
  66. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_plate_cycle_no_atc.wrapped.env.yaml +0 -0
  67. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_seal.boundary.yaml +0 -0
  68. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_seal.env.yaml +0 -0
  69. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_seal.workflow.yaml +0 -0
  70. {labcode-0.9.0 → labcode-0.10.1}/examples/sila2_seal.wrapped.env.yaml +0 -0
  71. {labcode-0.9.0 → labcode-0.10.1}/labcode/__init__.py +0 -0
  72. {labcode-0.9.0 → labcode-0.10.1}/labcode/__main__.py +0 -0
  73. {labcode-0.9.0 → labcode-0.10.1}/labcode/cli.py +0 -0
  74. {labcode-0.9.0 → labcode-0.10.1}/labcode/connections.py +0 -0
  75. {labcode-0.9.0 → labcode-0.10.1}/labcode/extension.py +0 -0
  76. {labcode-0.9.0 → labcode-0.10.1}/labcode/idgen.py +0 -0
  77. {labcode-0.9.0 → labcode-0.10.1}/labcode/lads.py +0 -0
  78. {labcode-0.9.0 → labcode-0.10.1}/labcode/lads_commands.py +0 -0
  79. {labcode-0.9.0 → labcode-0.10.1}/labcode/lads_unit.py +0 -0
  80. {labcode-0.9.0 → labcode-0.10.1}/labcode/otel.py +0 -0
  81. {labcode-0.9.0 → labcode-0.10.1}/labcode/otel_sila2.py +0 -0
  82. {labcode-0.9.0 → labcode-0.10.1}/labcode/probe.py +0 -0
  83. {labcode-0.9.0 → labcode-0.10.1}/labcode/py.typed +0 -0
  84. {labcode-0.9.0 → labcode-0.10.1}/labcode/record.py +0 -0
  85. {labcode-0.9.0 → labcode-0.10.1}/labcode/sila2.py +0 -0
  86. {labcode-0.9.0 → labcode-0.10.1}/labcode/sila2_commands.py +0 -0
  87. {labcode-0.9.0 → labcode-0.10.1}/labcode/sila2_instrument.py +0 -0
  88. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/dependency_links.txt +0 -0
  89. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/entry_points.txt +0 -0
  90. {labcode-0.9.0 → labcode-0.10.1}/labcode.egg-info/top_level.txt +0 -0
  91. {labcode-0.9.0 → labcode-0.10.1}/setup.cfg +0 -0
  92. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/device_script.env.yaml +0 -0
  93. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/device_script.workflow.yaml +0 -0
  94. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/joint.run.yaml +0 -0
  95. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/replenishment.env.yaml +0 -0
  96. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/reroute_device.env.yaml +0 -0
  97. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/reroute_transporter.env.yaml +0 -0
  98. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/transport.env.yaml +0 -0
  99. {labcode-0.9.0 → labcode-0.10.1}/tests/fixtures/transport.workflow.yaml +0 -0
  100. {labcode-0.9.0 → labcode-0.10.1}/tests/test_cli.py +0 -0
  101. {labcode-0.9.0 → labcode-0.10.1}/tests/test_joint.py +0 -0
  102. {labcode-0.9.0 → labcode-0.10.1}/tests/test_lads.py +0 -0
  103. {labcode-0.9.0 → labcode-0.10.1}/tests/test_lads_unit.py +0 -0
  104. {labcode-0.9.0 → labcode-0.10.1}/tests/test_otel.py +0 -0
  105. {labcode-0.9.0 → labcode-0.10.1}/tests/test_otel_child.py +0 -0
  106. {labcode-0.9.0 → labcode-0.10.1}/tests/test_otel_grpc.py +0 -0
  107. {labcode-0.9.0 → labcode-0.10.1}/tests/test_otel_sila2.py +0 -0
  108. {labcode-0.9.0 → labcode-0.10.1}/tests/test_probe.py +0 -0
  109. {labcode-0.9.0 → labcode-0.10.1}/tests/test_record.py +0 -0
  110. {labcode-0.9.0 → labcode-0.10.1}/tests/test_recording.py +0 -0
  111. {labcode-0.9.0 → labcode-0.10.1}/tests/test_sila2.py +0 -0
  112. {labcode-0.9.0 → labcode-0.10.1}/tests/test_sila2_commands.py +0 -0
  113. {labcode-0.9.0 → labcode-0.10.1}/tests/test_sila2_instrument.py +0 -0
@@ -6,6 +6,69 @@ specification the section numbers refer to is [`docs/SPECIFICATIONS.md`](docs/SP
6
6
  Releases before 0.9.0 are described in the messages of their `Release vX.Y.Z` commits
7
7
  (`git log --grep '^Release v'`).
8
8
 
9
+ ## 0.10.1 — 2026-10-07
10
+
11
+ labcode follows specification revision 0.5. No code changes; the siblings move.
12
+
13
+ ### Changed — may need your attention
14
+
15
+ - **A composite returns every output it declares.** An output port with no `returns`
16
+ entry is now `output_not_returned`, and a `returns` entry naming no output port is
17
+ `return_port_not_found` (ofplang-validate 0.4). Until now a Pure Data output could go
18
+ unreturned.
19
+ - **The `scheduling` section is gone from the language.** It is now an unknown key, and
20
+ `scheduling_policies` in `features` an unknown feature. Delete the section and the
21
+ feature name; nothing else depended on them, and no plan used them.
22
+ - An Object that comes in at the boundary and is returned as it came is now planned and
23
+ run, moved to the spot its output is bound to (ofplang-schedule 0.13.1, ofplang-run
24
+ 0.13.1).
25
+
26
+ Requires ofplang-validate 0.4, ofplang-schedule 0.13.1 and ofplang-run 0.13.1.
27
+
28
+ ## 0.10.0 — 2026-10-05
29
+
30
+ labcode runs a protocol repeated over every plate (`map` / `fold`), and stops making up
31
+ a value a script did not compute.
32
+
33
+ ### Added
34
+
35
+ - **`map` and `fold`** run, as ofplang-schedule 0.13 expands them and ofplang-run 0.13
36
+ executes the expansion. An **Array of Objects** at the run boundary is one Object per
37
+ element: its `view` is a list of views, one per spot, and each element gets its own
38
+ `_id`, keyed by the element (`plates[1]`, §4.3). An invocation's node path carries its
39
+ iteration index, and so do the keys of what it creates.
40
+ - **What a run made up, it says** (`lc run` prints each as `lc run: warning: ...`):
41
+ - `entry_input_defaulted` — a boundary view written in part, or not at all: the
42
+ declared fields it left out run on their type's default (§4.2). Once per Object.
43
+ - `output_view_defaulted` — a script that did not return an Object it created: the
44
+ view runs on its type's default (§1.2). Once per process and port.
45
+ - `_id` is never reported; minting it is labcode's identity, not a default.
46
+
47
+ ### Changed — may need your attention
48
+
49
+ - **A script must return every Pure Data output it declares** (§1.2). Until now one left
50
+ out took its type's default — `return {}` from a `read` gave `od: 0.0`, a reading
51
+ nothing took. It now fails the operation (`script_output_names`), as v0 §22.2 does. An
52
+ Object output may still be left out: a mapped one is carried, a created one gets its
53
+ default view (reported).
54
+ - **A process with no script anywhere that declares a Pure Data output is refused** by
55
+ `lc run` before anything runs (§2): a no-op can compute nothing, so every run of it
56
+ would fail. One with only Object outputs still runs as a no-op, with the warning it
57
+ always had. A process with a script on some modes only fails if a mode without one is
58
+ chosen.
59
+ - **A boundary view that is not a mapping** (or, for an Array, not a list of them) is
60
+ refused. It used to be replaced by an empty view without a word.
61
+ - **An Object mapped from an input with no `_id`** fails the operation
62
+ (`missing_object_id`). No run can reach this; it is there to catch a broken invariant
63
+ early.
64
+ - A v0 §22 workflow script in a language other than Python fails the operation
65
+ (`script_language`) instead of running as a no-op.
66
+ - Requires ofplang-validate 0.3, ofplang-schedule 0.13 and ofplang-run 0.13
67
+ (specification revision 0.4).
68
+
69
+ `lc export` is unchanged; drawing a run that has `map` / `fold` invocations in it is not
70
+ yet supported there.
71
+
9
72
  ## 0.9.0 — 2026-09-28
10
73
 
11
74
  labcode can now drive a lab over **LADS OPC UA** as well as SiLA2, and gains `lc export`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.9.0
3
+ Version: 0.10.1
4
4
  Summary: labcode -- a dialect wrapper over the Object-Flow Programming Language toolchain
5
5
  Author-email: Kazunari Kaizu <kwaizu@gmail.com>
6
6
  License-Expression: MIT
@@ -20,9 +20,9 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.10
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: ofplang-validate<0.3,>=0.2
24
- Requires-Dist: ofplang-schedule<0.13,>=0.9
25
- Requires-Dist: ofplang-run<0.13,>=0.11
23
+ Requires-Dist: ofplang-validate<0.5,>=0.4
24
+ Requires-Dist: ofplang-schedule<0.14,>=0.13.1
25
+ Requires-Dist: ofplang-run<0.14,>=0.13.1
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Requires-Dist: opentelemetry-sdk>=1.20; extra == "test"
@@ -74,26 +74,29 @@ port names**, each bound to that port's view value (Pure Data or an Object's vie
74
74
  — as in a v0 §22 `python_script_processes` script. External `import` is allowed; there is
75
75
  no sandbox.
76
76
 
77
- **Partial outputs.** Unlike v0 §22.2 (which requires the script to return *every* output
78
- exactly), a labcode process script `return`s only the outputs it **computes** — a subset.
79
- The backend fills the rest:
77
+ **Object outputs need not be returned.** As in v0 §22.2, a labcode process script
78
+ returns its outputs as a mapping, and **every Pure Data output it declares must be in
79
+ it**: a measurement nothing computed has no value to give, and a default would be a
80
+ made-up reading. One left out fails the operation (`script_output_names`), as does a
81
+ returned name that is not a declared output (this catches a typo'd name). Each returned
82
+ value must conform to its port's type.
83
+
84
+ An **Object** output may be left out, because labcode can supply it without making a
85
+ value up:
80
86
 
81
87
  - an Object output declared in `objects.map` is **carried from its input** (the same
82
88
  Object, its view unchanged) — so a pass-through need not restate it;
83
- - any other unset output gets a **typed default** for its type.
84
-
85
- The script's returned values override these. Each returned value must conform to its port's
86
- type, and **returning a name that is not a declared output is an error** (this catches a
87
- typo'd output name). A §22.2-strict script — one that returns every output explicitly —
88
- works unchanged.
89
+ - an Object output declared in `objects.create` gets its type's **default view** (and a
90
+ freshly minted `_id`, §4.2). That view is a value labcode made up, so the run reports it
91
+ (`output_view_defaulted`, once per process and port); returning the view says what it
92
+ is, and the warning goes away. `_id` is never part of what is reported (§4.2).
89
93
 
90
- So for `read` (input `plate`, outputs `plate` via `objects.map` + `od`), all three are
91
- equivalent to returning `{"plate": plate, "od": 0.42}`… except the defaulted forms:
94
+ So for `read` (input `plate`, outputs `plate` via `objects.map` + `od`):
92
95
 
93
96
  ```python
94
97
  return {"plate": plate, "od": 0.42} # explicit
95
98
  return {"od": 0.42} # plate carried by objects.map
96
- return {} # plate carried; od defaults to 0.0
99
+ return {} # fails: od is not returned
97
100
  ```
98
101
 
99
102
  ### 1.3 `x-labcode` on a transport route
@@ -645,15 +648,20 @@ For a dispatched `(process, mode)`, labcode resolves the code to run in this ord
645
648
 
646
649
  1. the mode's `x-labcode.script.code` (2 — the labcode device script), else
647
650
  2. the workflow process's own `script.code` (1 — a v0 §22 script process), else
648
- 3. none — the operation runs as a **typed-default no-op** (its outputs are typed
649
- defaults; a device not yet scripted).
651
+ 3. none — the operation runs as a **no-op** (a device not yet scripted): its Object
652
+ outputs are supplied as above (§1.2), and a Pure Data output, which only a script
653
+ could compute, fails the operation (`script_output_names`).
650
654
 
651
655
  **Exclusivity (error).** A process MUST NOT carry both a workflow `script` (1) and an env
652
656
  `x-labcode.script` (2) on any of its modes; that is ambiguous and is rejected.
653
657
 
654
- **Typed-default reachability (warning).** A process with neither (1) nor (2) on any mode
655
- will run as a typed-default no-op. This is allowed — convenient while mocking a device —
656
- but `lc run` warns about it, so an unimplemented device is not silently a no-op.
658
+ **No-op reachability (warning, or error).** A process with neither (1) nor (2) on any
659
+ mode will run as a no-op. This is allowed — convenient while mocking a device — but `lc
660
+ run` warns about it, so an unimplemented device is not silently a no-op. A process that
661
+ also declares a **Pure Data output** is refused instead: every run of it would fail, so
662
+ it fails before anything runs. A process with a script on some modes and not others is
663
+ not refused — which mode runs is the scheduler's choice — and fails only if a mode
664
+ without one is chosen.
657
665
 
658
666
  **Transport and replenishment routes have no such chain.** There is nothing for them to
659
667
  fall back to: a workflow describes neither a physical move nor a refill, so the route's
@@ -744,13 +752,17 @@ An Object's `_id` is set at its two points of origin, then **carried** everywher
744
752
  `objects.map` and transport copy the whole view, so `_id` propagates for free:
745
753
 
746
754
  - **`objects.create`** — a newly created Object's `_id` is minted when the operation
747
- produces it (in the backend's output fill). A device script need not know about `_id`:
748
- it returns only what it computes, and the fill supplies `_id` (like any other unset
749
- output, §1.2).
755
+ produces it. A device script need not know about `_id`: it returns the view (or leaves
756
+ the Object out, §1.2), and labcode supplies `_id`.
750
757
  - **run boundary** — a whole-workflow Object *input* enters at the boundary; `lc run`
751
- mints its `_id` (filling any other declared view field with a typed default so the
752
- seeded value conforms), **unless the boundary already carries one** — so a result
753
- boundary fed back in round-trips its ids.
758
+ mints its `_id`, **unless the boundary already carries one** — so a result boundary fed
759
+ back in round-trips its ids. An **Array of Objects** is one Object per element: its
760
+ `view`, if given, is a list of views, one per spot, and each element gets its own `_id`.
761
+ A view may be written in part: a declared field it leaves out takes its type's default,
762
+ and the run reports which (`entry_input_defaulted`, once per element). A view that is
763
+ not a mapping is refused, not replaced.
764
+ - **What is reported.** `_id` is never in a warning: minting it is this feature, not a
765
+ default. Only a declared view field labcode had to fill is.
754
766
  - **`objects.map`** — a mapped Object output carries its input's `_id` unchanged
755
767
  (identity preserved), even if a §22.2-strict script returned the port explicitly.
756
768
 
@@ -759,16 +771,16 @@ An Object's `_id` is set at its two points of origin, then **carried** everywher
759
771
  Ids come from a swappable generator (`labcode.idgen.IdGenerator`). The default
760
772
  (`SeededUuid4Generator`) mints **reproducible** uuid4-shaped ids from a seed and a
761
773
  *provenance key* — the node instance + output port for a create, the port name for a
762
- boundary input — **not** draw order. So the same workflow yields the same ids on every
774
+ boundary input (`plates[1]` for an element of an Array) — **not** draw order. So the same workflow yields the same ids on every
763
775
  run, and the wall-clock backend's jittering completion order cannot change them (which is
764
776
  what keeps checked-in example observations stable). A real run wanting globally-unique
765
777
  ids per physical Object swaps in `RealUuid4Generator` (via
766
778
  `labcode_backend_factory(id_generator=...)`).
767
779
 
768
- > The provenance key is the runner's node-instance identity + port. Today each create
769
- > node runs once, so node-path + port is unique; when dynamic control flow (e.g.
770
- > `do_while`) is added, that node-instance identity must include the iteration index so
771
- > ids stay unique and reproducible.
780
+ > The provenance key is the runner's node-instance identity + port. An invocation of an
781
+ > expanded `map` / `fold` has its iteration index in its node path (`Load/2`), so each
782
+ > invocation's creates key apart and stay reproducible. Dynamic control flow
783
+ > (`do_while`) is not run.
772
784
 
773
785
  ## 5. Not yet in this version (roadmap)
774
786
 
@@ -29,8 +29,8 @@ load ──[move]──> dispense ──[move]──> read ──[move]──> s
29
29
  **Every process mode and every transport route carries a script**: `load` makes the
30
30
  Plate, `dispense` dispenses the Tube into the Plate (both Objects are carried through by
31
31
  `objects.map`, so its script returns nothing), `read` measures `od` (the Plate is
32
- carried, so the script returns only what it computes — labcode's *partial outputs*, see
33
- the spec), `store` takes it; the transport scripts perform the move and may read the
32
+ carried, so the script returns only the reading — an Object output need not be
33
+ returned, see the spec), `store` takes it; the transport scripts perform the move and may read the
34
34
  moved Plate's `view` (its barcode).
35
35
 
36
36
  labcode runs each script **out-of-process** on a wall clock, discovering completion by
@@ -189,7 +189,7 @@ result boundary does not, carrying none.
189
189
  Where `plate_line`'s scripts are mocks that only return values, this example's scripts open
190
190
  SiLA2 connections and issue real commands. It is the integration check for labcode's SiLA2
191
191
  story: labcode schedules, dispatches out-of-process, a script talks SiLA2, an instrument acts,
192
- and the produced value comes back through labcode's partial outputs.
192
+ and the produced value comes back as the script's return, the plate carried by labcode.
193
193
 
194
194
  ```
195
195
  (in) plate ──[move]──▶ seal ──[move]──▶ (out) plate
@@ -1,12 +1,11 @@
1
- """labcode's out-of-process child harness (the partial-output dialect).
1
+ """labcode's out-of-process child harness.
2
2
 
3
- Like ``ofplang.run.simulator._child``, but labcode process scripts follow the labcode
4
- dialect's **partial** output convention (SPECIFICATIONS.md §1.2): a script returns only the
5
- outputs it computes, and the backend fills the rest -- carrying an Object output through
6
- ``objects.map`` (identity preserved) and defaulting the others. So this child does **no**
7
- output verification; it just returns the script's raw result mapping. The merge, the
8
- conformance check, and the rejection of any *extra* (undeclared) output name all happen in
9
- `labcode.backend.LabcodeBackend`.
3
+ Like ``ofplang.run.simulator._child``, but a labcode process script may leave out an
4
+ Object output (SPECIFICATIONS.md §1.2), which the backend supplies -- carried through
5
+ ``objects.map``, or a created Object's default view. So this child does **no** output
6
+ verification; it just returns the script's raw result mapping. The check that every Pure
7
+ Data output is there, the rejection of any *extra* (undeclared) output name, the Object
8
+ fill and the conformance check all happen in `labcode.backend.LabcodeBackend`.
10
9
 
11
10
  A transport or refill script is side-effect only (its return is ignored). Protocol (JSON on
12
11
  stdin, outcome to ``result_path``) is identical to the upstream child; only the process
@@ -61,7 +60,7 @@ def _execute(job: dict, result_path: str) -> int:
61
60
  f"{type(raw).__name__}, not a mapping",
62
61
  }}
63
62
  else:
64
- # A partial mapping is allowed: the backend merges and verifies it (§1.2).
63
+ # Object outputs may be missing: the backend supplies and verifies (§1.2).
65
64
  payload = {"outputs": raw}
66
65
  except DeviceComputationError as exc:
67
66
  payload = {"error": {"code": exc.code, "message": str(exc)}}
@@ -66,7 +66,7 @@ from labcode.extension import (
66
66
  transporter_connections,
67
67
  )
68
68
  from labcode.idgen import DEFAULT_ID_GENERATOR, IdGenerator
69
- from labcode.objectid import RESERVED_ID, stamp_object_ids
69
+ from labcode.objectid import RESERVED_ID, object_output_ports, stamp_object_ids
70
70
  from labcode.probe import (
71
71
  Availability,
72
72
  CadenceReporter,
@@ -289,8 +289,8 @@ def _labcode_child_spawn(job: dict, env: Mapping[str, str] | None = None):
289
289
 
290
290
 
291
291
  class LabcodeBackend(SubprocessBackend):
292
- """A `SubprocessBackend` for the labcode dialect: partial process outputs + transport
293
- execution.
292
+ """A `SubprocessBackend` for the labcode dialect: Object outputs filled for a script +
293
+ transport execution.
294
294
 
295
295
  Two dialect behaviours on top of the base backend:
296
296
 
@@ -299,11 +299,12 @@ class LabcodeBackend(SubprocessBackend):
299
299
  moved Object's view bound as locals (``from_spot`` / ``to_spot`` / ``transporter`` /
300
300
  ``view``). It reuses the upstream machinery -- `_start_child_op` launches it and the
301
301
  settle loop completes / fails it (ofplang-run >= 0.1.6) -- so only dispatch is added.
302
- * **Partial process outputs** (SPECIFICATIONS.md §1.2). A process script returns only
303
- the outputs it computes; `_resolve_model` fills the rest from `default_device_model`
304
- (carrying an Object output via ``objects.map``, defaulting the others) and merges the
305
- script's values on top. A returned name that is not a declared output is an error.
306
- This needs the partial-tolerant `labcode._child` (the default `spawn`), since the
302
+ * **Object outputs need not be returned** (SPECIFICATIONS.md §1.2). A process script
303
+ returns every Pure Data output it declares, and may leave out an Object output:
304
+ `_resolve_model` carries a mapped one (``objects.map``) and gives a created one a
305
+ default view, reported in `warnings`. A Pure Data output left out, or a returned
306
+ name that is not a declared output, is an error -- nothing makes a measurement up.
307
+ This needs the Object-tolerant `labcode._child` (the default `spawn`), since the
307
308
  upstream child verifies outputs exactly."""
308
309
 
309
310
  def __init__(
@@ -343,6 +344,11 @@ class LabcodeBackend(SubprocessBackend):
343
344
  # with the run boundary's minting so a run's ids are consistent; default is the
344
345
  # reproducible seeded generator.
345
346
  self._id_gen = id_generator or DEFAULT_ID_GENERATOR
347
+ # Values this backend made up and said so (`ofplang.run` `RunWarning`): the view
348
+ # of a created Object its script did not return. One per (job, process, port),
349
+ # however many operations of it there were.
350
+ self.warnings: list = []
351
+ self._warned: set = set()
346
352
 
347
353
  # -- recording what the run did (`labcode.record`) -------------------------------
348
354
 
@@ -422,46 +428,89 @@ class LabcodeBackend(SubprocessBackend):
422
428
 
423
429
  def _resolve_model(self, process, mode, inputs, output_schema, definition,
424
430
  node=None, job=None):
425
- """The value model `_complete` calls at completion: merge the script's *partial*
426
- outputs onto the defaults (§1.2), then stamp Object identities (`_id`).
427
-
428
- `default_device_model` supplies the base (``objects.map`` Object carry + typed
429
- defaults); the script's returned values override it. A returned name outside
430
- `output_schema` is a runtime failure. `stamp_object_ids` then mints ``_id`` for a
431
- created Object and carries it for a mapped one (keyed by `node`, the workflow
432
- provenance, **and by `job`** -- two jobs of one workflow render the same node
433
- path, so the node alone would mint one identity for two plates). A timed op (no
434
- child) or a child error is handled as in the base backend.
431
+ """The value model `_complete` calls at completion: the script's outputs, with any
432
+ Object output it left out filled (§1.2), then Object identities (`_id`) stamped.
433
+
434
+ Every Pure Data output must come from the script: one it leaves out -- or a
435
+ process with no script at all that declares one (§2) -- is a runtime failure
436
+ (`script_output_names`), since nothing else could compute it and a default would
437
+ be a made-up measurement. An Object output may be left out: `default_device_model`
438
+ carries a mapped one, and gives a created one a default view, which is reported
439
+ (`warnings`). A returned name outside `output_schema` is a runtime failure.
440
+ `stamp_object_ids` then mints ``_id`` for a created Object and carries it for a
441
+ mapped one (keyed by `node`, the workflow provenance, **and by `job`** -- two jobs
442
+ of one workflow render the same node path, so the node alone would mint one
443
+ identity for two plates). A child error is handled as in the base backend.
435
444
 
436
445
  🔴 Both halves arrive here rather than at dispatch because this is where an
437
446
  identity is *made*: the runner offers provenance to a model that declares it
438
447
  (`ofplang-run` >= 0.11), and the simulator carries it on the operation from the
439
448
  dispatch that started it."""
440
449
  pending = self._pending
450
+ created, mapped = object_output_ports(definition)
451
+ object_ports = set(created) | set(mapped)
441
452
  if not isinstance(pending, dict): # the _TIMED sentinel: no child ran
442
- outputs = default_device_model(process, mode, inputs, output_schema, definition)
443
- return stamp_object_ids(
444
- outputs, definition, inputs or {}, node, self._id_gen, output_schema,
445
- job=job,
446
- )
447
- if "error" in pending:
448
- err = pending["error"]
449
- raise DeviceComputationError(
450
- err.get("message", "child failed"), code=err.get("code", "child_error")
451
- )
452
- raw = pending.get("outputs") or {}
453
- extra = set(raw) - set(output_schema or {})
454
- if extra:
455
- raise DeviceComputationError(
456
- f"script process {process!r} returned undeclared output names {sorted(extra)}",
457
- code="script_output_names",
458
- )
453
+ # A workflow script in a language no child runs is a failure, as in the base
454
+ # backend -- not a process without a script.
455
+ script = (definition or {}).get("script")
456
+ if script is not None and python_code(script) is None:
457
+ language = script.get("language") if isinstance(script, dict) else None
458
+ raise DeviceComputationError(
459
+ f"script process {process!r} declares unsupported script language "
460
+ f"{language!r}",
461
+ code="script_language",
462
+ )
463
+ raw: dict = {}
464
+ what = f"process {process!r} mode {mode!r} has no script, and nothing computes"
465
+ else:
466
+ if "error" in pending:
467
+ err = pending["error"]
468
+ raise DeviceComputationError(
469
+ err.get("message", "child failed"), code=err.get("code", "child_error")
470
+ )
471
+ raw = pending.get("outputs") or {}
472
+ extra = set(raw) - set(output_schema or {})
473
+ if extra:
474
+ raise DeviceComputationError(
475
+ f"script process {process!r} returned undeclared output names "
476
+ f"{sorted(extra)}",
477
+ code="script_output_names",
478
+ )
479
+ what = f"script process {process!r} did not return"
480
+ # Every Pure Data output has to be computed: there is no default for one (D59).
481
+ missing = sorted(set(output_schema or {}) - object_ports - set(raw))
482
+ if missing:
483
+ raise DeviceComputationError(f"{what} output(s) {missing}", code="script_output_names")
459
484
  base = default_device_model(process, mode, inputs, output_schema, definition)
485
+ # A created Object the script did not return starts with a default view: say so.
486
+ for port in created:
487
+ if port not in raw:
488
+ self._warn_defaulted_view(process, port, node, job, output_schema)
460
489
  return stamp_object_ids(
461
490
  {**base, **raw}, definition, inputs or {}, node, self._id_gen, output_schema,
462
491
  job=job,
463
492
  )
464
493
 
494
+ def _warn_defaulted_view(self, process, port, node, job, output_schema) -> None:
495
+ """Report a created Object whose view runs on its type's defaults, once per job,
496
+ process and port. ``_id`` is not a default -- it is minted -- so a type with no
497
+ other view field gives nothing to report."""
498
+ from ofplang.run.runner.job import RunWarning
499
+
500
+ fields = (output_schema or {}).get(port, {}).get("fields") or {}
501
+ defaulted = [field for field in fields if field != RESERVED_ID]
502
+ key = (job or "", process, port)
503
+ if not defaulted or key in self._warned:
504
+ return
505
+ self._warned.add(key)
506
+ where = "/".join(str(step) for step in node) if node else "?"
507
+ self.warnings.append(RunWarning(
508
+ "output_view_defaulted",
509
+ f"process {process!r} did not return its created Object {port!r} (first at "
510
+ f"{where}), so view field(s) {defaulted} run on their type's default",
511
+ job or "",
512
+ ))
513
+
465
514
  def advance(self, until: int) -> int:
466
515
  """Pace the wall clock as the base backend does, and say so once if this run
467
516
  cannot keep the cadence it was asked for.
@@ -70,7 +70,7 @@ from labcode.extension import (
70
70
  transport_label,
71
71
  unknown_key_messages,
72
72
  )
73
- from labcode.objectid import RESERVED_ID, reserved_collisions
73
+ from labcode.objectid import RESERVED_ID, object_output_ports, reserved_collisions
74
74
 
75
75
 
76
76
  @dataclass
@@ -375,12 +375,24 @@ def _validate_processes(
375
375
  f"process {name!r} has both a workflow script (1) and an env "
376
376
  f"x-labcode.script (2); they are mutually exclusive"
377
377
  )
378
- # typed-default reachability -- a warning, not an error.
378
+ # A process with no script anywhere runs as a no-op. That is allowed -- a device not
379
+ # scripted yet, while the rest is mocked -- and warned about. But a no-op computes
380
+ # nothing, so a Pure Data output it declares could only be made up: such a process
381
+ # is certain to fail every time it runs, and is refused here, before it does (D59).
379
382
  if not wf_has_script and not modes_with_script:
380
- warnings.append(
381
- f"process {name!r} has no script (workflow or x-labcode); its operations "
382
- f"will run as a typed-default no-op"
383
- )
383
+ created, mapped = object_output_ports(wf_procs.get(name))
384
+ declared = (wf_procs.get(name) or {}).get("outputs") or {}
385
+ computed = sorted(set(declared) - set(created) - set(mapped))
386
+ if computed:
387
+ errors.append(
388
+ f"process {name!r} has no script (workflow or x-labcode) but declares "
389
+ f"output(s) {computed}, which only a script can compute"
390
+ )
391
+ else:
392
+ warnings.append(
393
+ f"process {name!r} has no script (workflow or x-labcode); its operations "
394
+ f"will run as a no-op"
395
+ )
384
396
 
385
397
 
386
398
  def _check_reserved_locals(wf_process: Any, label: str, errors: list, flavor: str) -> None: