labcode 0.1.2__tar.gz → 0.1.4__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 (65) hide show
  1. {labcode-0.1.2 → labcode-0.1.4}/.github/workflows/ci.yml +14 -0
  2. labcode-0.1.4/MANIFEST.in +6 -0
  3. {labcode-0.1.2 → labcode-0.1.4}/PKG-INFO +4 -2
  4. labcode-0.1.4/SPECIFICATIONS.md +432 -0
  5. labcode-0.1.4/examples/README.md +227 -0
  6. labcode-0.1.4/examples/run_all_sila2_examples.py +84 -0
  7. labcode-0.1.4/examples/run_sila2_seal.py +300 -0
  8. labcode-0.1.4/examples/sila2_seal.boundary.yaml +19 -0
  9. labcode-0.1.4/examples/sila2_seal.env.yaml +130 -0
  10. labcode-0.1.4/examples/sila2_seal.workflow.yaml +60 -0
  11. labcode-0.1.4/examples/sila2_seal.wrapped.env.yaml +127 -0
  12. {labcode-0.1.2 → labcode-0.1.4}/labcode/backend.py +131 -18
  13. labcode-0.1.4/labcode/dialect.py +444 -0
  14. labcode-0.1.4/labcode/extension.py +353 -0
  15. labcode-0.1.4/labcode/probe.py +192 -0
  16. {labcode-0.1.2 → labcode-0.1.4}/labcode/run_cli.py +63 -7
  17. {labcode-0.1.2 → labcode-0.1.4}/labcode/runner.py +46 -9
  18. labcode-0.1.4/labcode/sila2.py +153 -0
  19. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/PKG-INFO +4 -2
  20. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/SOURCES.txt +14 -0
  21. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/requires.txt +4 -1
  22. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/scm_file_list.json +16 -1
  23. labcode-0.1.4/labcode.egg-info/scm_version.json +8 -0
  24. {labcode-0.1.2 → labcode-0.1.4}/pyproject.toml +12 -7
  25. labcode-0.1.4/tests/fixtures/reroute_device.env.yaml +60 -0
  26. labcode-0.1.4/tests/fixtures/reroute_transporter.env.yaml +57 -0
  27. {labcode-0.1.2 → labcode-0.1.4}/tests/test_backend.py +63 -0
  28. labcode-0.1.4/tests/test_dialect.py +501 -0
  29. labcode-0.1.4/tests/test_probe.py +438 -0
  30. {labcode-0.1.2 → labcode-0.1.4}/tests/test_run_cli.py +65 -0
  31. labcode-0.1.4/tests/test_sila2.py +201 -0
  32. labcode-0.1.2/SPECIFICATIONS.md +0 -188
  33. labcode-0.1.2/examples/README.md +0 -85
  34. labcode-0.1.2/labcode/dialect.py +0 -151
  35. labcode-0.1.2/labcode.egg-info/scm_version.json +0 -8
  36. labcode-0.1.2/tests/test_dialect.py +0 -104
  37. {labcode-0.1.2 → labcode-0.1.4}/.github/workflows/publish.yml +0 -0
  38. {labcode-0.1.2 → labcode-0.1.4}/.gitignore +0 -0
  39. {labcode-0.1.2 → labcode-0.1.4}/LICENSE +0 -0
  40. {labcode-0.1.2 → labcode-0.1.4}/README.md +0 -0
  41. {labcode-0.1.2 → labcode-0.1.4}/examples/outputs/plate_line.boundary.yaml +0 -0
  42. {labcode-0.1.2 → labcode-0.1.4}/examples/outputs/plate_line.observation.yaml +0 -0
  43. {labcode-0.1.2 → labcode-0.1.4}/examples/outputs/plate_line.plan.yaml +0 -0
  44. {labcode-0.1.2 → labcode-0.1.4}/examples/outputs/plate_line.svg +0 -0
  45. {labcode-0.1.2 → labcode-0.1.4}/examples/plate_line.boundary.yaml +0 -0
  46. {labcode-0.1.2 → labcode-0.1.4}/examples/plate_line.env.yaml +0 -0
  47. {labcode-0.1.2 → labcode-0.1.4}/examples/plate_line.workflow.yaml +0 -0
  48. {labcode-0.1.2 → labcode-0.1.4}/examples/render_plate_line.py +0 -0
  49. {labcode-0.1.2 → labcode-0.1.4}/labcode/__init__.py +0 -0
  50. {labcode-0.1.2 → labcode-0.1.4}/labcode/__main__.py +0 -0
  51. {labcode-0.1.2 → labcode-0.1.4}/labcode/_child.py +0 -0
  52. {labcode-0.1.2 → labcode-0.1.4}/labcode/cli.py +0 -0
  53. {labcode-0.1.2 → labcode-0.1.4}/labcode/idgen.py +0 -0
  54. {labcode-0.1.2 → labcode-0.1.4}/labcode/objectid.py +0 -0
  55. {labcode-0.1.2 → labcode-0.1.4}/labcode/py.typed +0 -0
  56. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/dependency_links.txt +0 -0
  57. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/entry_points.txt +0 -0
  58. {labcode-0.1.2 → labcode-0.1.4}/labcode.egg-info/top_level.txt +0 -0
  59. {labcode-0.1.2 → labcode-0.1.4}/setup.cfg +0 -0
  60. {labcode-0.1.2 → labcode-0.1.4}/tests/fixtures/device_script.env.yaml +0 -0
  61. {labcode-0.1.2 → labcode-0.1.4}/tests/fixtures/device_script.workflow.yaml +0 -0
  62. {labcode-0.1.2 → labcode-0.1.4}/tests/fixtures/transport.env.yaml +0 -0
  63. {labcode-0.1.2 → labcode-0.1.4}/tests/fixtures/transport.workflow.yaml +0 -0
  64. {labcode-0.1.2 → labcode-0.1.4}/tests/test_cli.py +0 -0
  65. {labcode-0.1.2 → labcode-0.1.4}/tests/test_objectid.py +0 -0
@@ -43,3 +43,17 @@ jobs:
43
43
  cache: pip
44
44
  - run: pip install -e ".[dev]"
45
45
  - run: mypy
46
+
47
+ # `uv sync --extra sila2` is how a development environment gets built here, and
48
+ # uv.lock records what it produces. This asserts the lock still agrees with
49
+ # pyproject.toml, so a raised dependency floor or a new extra cannot leave the
50
+ # environment pinned to versions the project no longer accepts. It deliberately
51
+ # does not ask whether newer releases exist -- that is `uv lock --upgrade`, run
52
+ # when someone means to move. The jobs above stay on pip so they keep testing
53
+ # against the latest released siblings.
54
+ lock:
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v4
58
+ - uses: astral-sh/setup-uv@v7
59
+ - run: uv lock --check
@@ -0,0 +1,6 @@
1
+ # setuptools-scm hands the sdist every file git tracks, and uv.lock is tracked so
2
+ # that the versions a developer actually gets stay reviewable. That lock describes
3
+ # the development environment only -- an installer resolves the wheel's dependency
4
+ # metadata and never reads it -- so it has no business in the distribution, where
5
+ # it would roughly double the sdist. Excluded here; still in the repository.
6
+ exclude uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labcode
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -22,13 +22,15 @@ Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
23
  Requires-Dist: ofplang-validate>=0.1.3
24
24
  Requires-Dist: ofplang-schedule>=0.1.3
25
- Requires-Dist: ofplang-run>=0.1.10
25
+ Requires-Dist: ofplang-run>=0.1.11
26
26
  Provides-Extra: test
27
27
  Requires-Dist: pytest>=7.0; extra == "test"
28
28
  Provides-Extra: dev
29
29
  Requires-Dist: pytest>=7.0; extra == "dev"
30
30
  Requires-Dist: ruff>=0.16; extra == "dev"
31
31
  Requires-Dist: mypy>=1.11; extra == "dev"
32
+ Provides-Extra: sila2
33
+ Requires-Dist: sila2>=0.14; extra == "sila2"
32
34
  Dynamic: license-file
33
35
 
34
36
  # labcode
@@ -0,0 +1,432 @@
1
+ # labcode dialect specification
2
+
3
+ labcode is a dialect of the Object-Flow Programming Language (ofplang). A labcode
4
+ workflow **is** a portable v0 ofplang workflow; the dialect lives entirely in the
5
+ **execution environment** (§5), as an `x-labcode` extension that says *how* each device
6
+ operation is physically carried out. `lc run` drives the workflow on the labcode backend
7
+ (`ofplang.run.SubprocessBackend`), sourcing each operation's script from `x-labcode` and
8
+ running it out-of-process on a wall clock.
9
+
10
+ This document is the reference for the `x-labcode` extension; `labcode.dialect` is its
11
+ conformance validator, run at the `lc run` front door.
12
+
13
+ ## 1. `x-labcode` in an environment (P5)
14
+
15
+ The extension appears in four places, each answering a different question: on a **process
16
+ mode** and on a **transport route** it says *what to run* (a `script`, §1.1–§1.3); on a
17
+ **device** and on a **transporter** it says *how to reach the machine* (a `connection`,
18
+ §1.4), which is what lets a script be the commands alone (§1.6). Nowhere else — see §1.7.
19
+
20
+ An environment process mode (§5) may carry an `x-labcode` mapping holding a `script`: the
21
+ Python that carries out that `(process, mode)`.
22
+
23
+ ```yaml
24
+ processes:
25
+ measure_od:
26
+ modes:
27
+ - id: v0
28
+ devices: [reader]
29
+ duration: 45 # the scheduler's estimate; real time is the script's own
30
+ x-labcode:
31
+ script:
32
+ language: python # the only supported language
33
+ code: |
34
+ return {"od": read_plate(plate)}
35
+ ```
36
+
37
+ `x-labcode` is tolerated (and ignored) by `ofplang-schedule` (>= 0.1.2): the environment
38
+ still validates and schedules as plain v0. Only labcode interprets it.
39
+
40
+ ### 1.1 Shape
41
+
42
+ - `x-labcode` MUST be a mapping. On a process mode or a transport route its only key is
43
+ `script`.
44
+ - `x-labcode.script`, if present, MUST be a mapping with:
45
+ - `language`: MUST be `python`.
46
+ - `code`: MUST be a string (an implementation-provided Python function body).
47
+ - `flavor` (optional, default `raw`): MUST be `raw` or `sila2` — how `code` is meant to
48
+ be run (§1.6). `sila2` is the **recommended** way to drive a SiLA2 lab: the code is the
49
+ commands alone and labcode supplies the clients. `raw` is the whole function body,
50
+ written by its author — the general escape hatch, and what a script that connects for
51
+ itself (or speaks something other than SiLA2) uses.
52
+
53
+ **Unknown keys are an error** — in `x-labcode` at every position, and in the mappings it
54
+ holds. A key this version does not know is either a typo or a feature it does not have;
55
+ either way, ignoring it would mean a document that says one thing and a run that does
56
+ another (a misspelled `flavour:` running unwrapped, a `probe:` on a mode monitoring
57
+ nothing). This applies only inside `x-labcode` — the workflow's own `script` (v0 §22)
58
+ belongs to `ofplang-validate`.
59
+
60
+ ### 1.2 Calling convention (process)
61
+
62
+ The script runs as the body of a function whose parameters are the operation's **input
63
+ port names**, each bound to that port's view value (Pure Data or an Object's view record)
64
+ — as in a v0 §22 `python_script_processes` script. External `import` is allowed; there is
65
+ no sandbox.
66
+
67
+ **Partial outputs.** Unlike v0 §22.2 (which requires the script to return *every* output
68
+ exactly), a labcode process script `return`s only the outputs it **computes** — a subset.
69
+ The backend fills the rest:
70
+
71
+ - an Object output declared in `objects.map` is **carried from its input** (the same
72
+ Object, its view unchanged) — so a pass-through need not restate it;
73
+ - any other unset output gets a **typed default** for its type.
74
+
75
+ The script's returned values override these. Each returned value must conform to its port's
76
+ type, and **returning a name that is not a declared output is an error** (this catches a
77
+ typo'd output name). A §22.2-strict script — one that returns every output explicitly —
78
+ works unchanged.
79
+
80
+ So for `read` (input `plate`, outputs `plate` via `objects.map` + `od`), all three are
81
+ equivalent to returning `{"plate": plate, "od": 0.42}`… except the defaulted forms:
82
+
83
+ ```python
84
+ return {"plate": plate, "od": 0.42} # explicit
85
+ return {"od": 0.42} # plate carried by objects.map
86
+ return {} # plate carried; od defaults to 0.0
87
+ ```
88
+
89
+ ### 1.3 `x-labcode` on a transport route
90
+
91
+ An environment `transports[]` route may carry an `x-labcode` with a `script`: the Python
92
+ that physically carries out that move (e.g. commanding a robot arm). Same shape as §1.1
93
+ (`language: python`, string `code`).
94
+
95
+ ```yaml
96
+ transports:
97
+ - transporter: arm
98
+ from: reader.stage
99
+ to: sealer.stage
100
+ duration: 3
101
+ x-labcode:
102
+ script:
103
+ language: python
104
+ code: |
105
+ grip = "gentle" if (view or {}).get("fragile") else "firm"
106
+ move_plate(from_spot, to_spot, grip=grip)
107
+ ```
108
+
109
+ **Calling convention (transport).** The script runs as a function body with these locals:
110
+ `from_spot`, `to_spot`, `transporter` (the physical route), and `view` — the view value of
111
+ the moved Object. `view` is **best-effort and MAY be `None`** (the runner resolves it from
112
+ the producing arc; when it cannot, it is `None`), so a script that reads it should tolerate
113
+ `None`. A transport script is **side-effect only**: its return value is ignored and no
114
+ output is verified. Success is "it ran without raising"; an exception is a graceful failure
115
+ (the move ends `failed`, no material is moved — the run stops).
116
+
117
+ A route with no `x-labcode.script` runs as a plain timed move — the runner's material
118
+ bookkeeping only, with no device command (a warned no-op for a real move, from != to).
119
+
120
+ ### 1.4 `x-labcode` on a device or a transporter
121
+
122
+ An environment `devices[]` or `transporters[]` entry may carry an `x-labcode` with two
123
+ keys: `connection` — **where that machine is**, written once per physical machine rather
124
+ than repeated in every script that drives it — and `probe` (§1.5) — whether to check that
125
+ it still answers.
126
+
127
+ ```yaml
128
+ devices:
129
+ - id: plateloc
130
+ spots: [stage]
131
+ x-labcode:
132
+ connection: { kind: sila2, host: 127.0.0.1, port: 50053, insecure: true }
133
+
134
+ transporters:
135
+ - id: arm
136
+ x-labcode:
137
+ connection: { kind: sila2, host: 127.0.0.1, port: 50057, insecure: true }
138
+ ```
139
+
140
+ | field | required | default | meaning |
141
+ |---|---|---|---|
142
+ | `kind` | no | `sila2` | the protocol; `sila2` is the only value this version defines |
143
+ | `host` | **yes** | — | a non-empty string |
144
+ | `port` | **yes** | — | an integer in 1..65535 |
145
+ | `insecure` | no | `false` | connect without TLS |
146
+
147
+ **TLS is not supported in this version.** There is nowhere in the schema to put the
148
+ credentials it needs (a root certificate, at least), so a `connection` whose effective
149
+ `insecure` is false is rejected at the front door — including one that simply omits the
150
+ key and takes the default — and refused again if a script reaches the connect helper
151
+ directly. Every connection must therefore say `insecure: true` today.
152
+ The default stays `false` so that supporting TLS later is a pure addition: new fields,
153
+ and the error goes away. The check applies to every declared `connection`, whether or not
154
+ a script uses it.
155
+
156
+ **A `sila2` script needs somewhere to connect** (checked at the front door):
157
+
158
+ - a mode script with `flavor: sila2` requires **at least one** of that mode's `devices[]`
159
+ to declare a `connection`;
160
+ - a transport script with `flavor: sila2` requires that route's `transporter` to declare
161
+ one.
162
+
163
+ Declaring a `connection` on a device no script connects to is allowed — it is how an
164
+ environment is prepared before the scripts that use it are written.
165
+
166
+ ### 1.5 Availability — `probe`
167
+
168
+ A machine that stops answering should not keep receiving work. A `probe` policy asks labcode
169
+ to check the machines it knows how to reach, and to tell the scheduler about the ones it
170
+ cannot: their process modes, and the transports they carry or touch, are dropped from the
171
+ environment the scheduler sees, so the run **routes around them**.
172
+
173
+ `probe` may be written on a device or a transporter, and at the **environment root** as a
174
+ document-wide default. The root's fields sit under a machine's own, **field by field**, so
175
+ a machine can change one thing without restating the rest.
176
+
177
+ | field | default | meaning |
178
+ |---|---|---|
179
+ | `enabled` | `false` | whether this machine is probed at all |
180
+ | `timeout` | `5` | how long one check may take, in **real seconds** |
181
+ | `interval` | `once` | `once` (check at the start of the run and keep that answer), a number of **real seconds** to re-check on, or `0` to re-check on every replan |
182
+
183
+ `timeout` and `interval` are real seconds — probing is work done against the real world, so
184
+ it has nothing to do with the environment's time unit or the run's wall-clock pacing.
185
+
186
+ **Writing a policy does not enable it.** `enabled` defaults to false wherever it is not
187
+ said, so adding an `interval` to an environment cannot start probing something that was not
188
+ being probed before; an environment with no `probe` at all behaves exactly as it did before
189
+ this version. A policy that nothing enables is a **warning** — it does nothing, which is
190
+ unlikely to be what its author meant.
191
+
192
+ **A probed machine needs an address.** A machine whose effective policy is enabled must
193
+ declare a `connection` — otherwise there is nothing to probe, and that is an error. This
194
+ matters when enabling probing document-wide, because the root reaches *every* machine: a
195
+ plain holding device with no connection then has to be excluded on purpose.
196
+
197
+ ```yaml
198
+ # Per machine: enable only the ones with an address (nothing to write for a holding device)
199
+ devices:
200
+ - id: plateloc
201
+ spots: [stage]
202
+ x-labcode:
203
+ connection: { kind: sila2, host: 127.0.0.1, port: 50053, insecure: true }
204
+ probe: { enabled: true, interval: 60 }
205
+ - { id: station, spots: [slot1] }
206
+
207
+ # Document-wide: enable once, and exclude what cannot be reached
208
+ x-labcode:
209
+ probe: { enabled: true, interval: 60 }
210
+ devices:
211
+ - id: plateloc
212
+ spots: [stage]
213
+ x-labcode:
214
+ connection: { kind: sila2, host: 127.0.0.1, port: 50053, insecure: true }
215
+ - id: station
216
+ spots: [slot1]
217
+ x-labcode:
218
+ probe: { enabled: false }
219
+ ```
220
+
221
+ **What a probe is.** Opening a TCP connection to the declared address, and nothing more. It
222
+ needs no client library, and it establishes **reachability, not readiness**: a machine whose
223
+ port is open but whose software is wedged reads as up here, and that case surfaces where it
224
+ belongs — as the operation that tried to command it failing.
225
+
226
+ **What it does to a run.**
227
+
228
+ - Only **new scheduling** is affected. An operation already running on a machine that has
229
+ just gone down is not touched.
230
+ - **If there is no other way, the run fails.** A workflow that needs a machine nothing can
231
+ replace stops with the scheduler's "no route" error rather than dispatching onto it. That
232
+ error names an arc or a mode, not a machine, so `lc run` appends the machines the probe
233
+ found unreachable — otherwise the answer to "why is there no route" is not in the message.
234
+ - **Recovery is automatic** — for a policy that re-checks. With `once` (the default) the
235
+ first answer stands for the whole run; with an `interval`, a machine that comes back
236
+ returns to the plan.
237
+ - **A check costs run-loop time**, and that cost is subject to §3.1 below: probing happens
238
+ in the process driving the run, one machine at a time, on the replan that asks for it.
239
+ This is the loop's most expensive optional step, so it is the likeliest thing to make a
240
+ cycle outgrow its poll period — which is why `interval: 0` is a setting for a diagnosis
241
+ rather than for operating a lab.
242
+ - **What costs is the machine that is *not* answering.** A reachable machine answers in well
243
+ under a millisecond on a local network. An unreachable one is only cheap when something
244
+ actively refuses the connection; a machine that was switched off, that left the network, or
245
+ that sits behind a host holding the port open while nothing serves it takes up to its
246
+ `timeout` to read as down. The cost of a round therefore follows the machines that are
247
+ down, not the ones that are up — so the round to size the poll period against (§3.1) is
248
+ the one in which the most of them are.
249
+ - `lc run --no-probe` ignores the policies and treats every machine as reachable (the
250
+ document is still validated, so an environment that is wrong about probing stays wrong).
251
+ Each machine whose reachability changes is reported on stderr.
252
+
253
+ ### 1.6 Calling convention (`flavor: sila2`)
254
+
255
+ A `sila2` script is the **commands alone**: labcode opens a client to each of the
256
+ operation's machines, runs the code with them in scope, and closes them afterwards. On top
257
+ of the input ports of §1.2 (or the transport locals of §1.3), the code sees:
258
+
259
+ | name | meaning |
260
+ |---|---|
261
+ | `sila2_clients` | the clients by **device id** (`transporter id` for a transport), in `devices[]` order, holding **only** the machines that declared a `connection` |
262
+ | `sila2_client` | the first of them — the one name a single-machine operation needs |
263
+
264
+ ```yaml
265
+ x-labcode:
266
+ script:
267
+ language: python
268
+ flavor: sila2
269
+ code: |
270
+ # `sila2_client` is already connected to this mode's device.
271
+ return {"od": sila2_client.OpticalDensityProvider.MeasureOD().OD}
272
+ ```
273
+
274
+ - **These two names are reserved.** A script's inputs are bound as its function's
275
+ parameters, so an input port of the same name would be silently overwritten by a client;
276
+ a process that declares one is rejected at the front door (as a `_id` view field is,
277
+ §4.1).
278
+ - **One connection per operation**, opened before the code runs and closed after it — on
279
+ any exit, including a `return` or an exception, and including a *later* connection
280
+ failing after an earlier one opened. There is no pooling and no reconnection: reaching an
281
+ instrument is assumed, and failing to is an ordinary operation failure naming the machine.
282
+ - **Everything else is still the script's own.** The flavor supplies connections, nothing
283
+ more: waiting for an observable command to finish (the standard `sila2` polling pattern)
284
+ belongs in the code, as it does in a `raw` script.
285
+ - A `sila2` script is only interpreted where the dialect is — in an environment
286
+ `x-labcode`. A workflow's own `script` (v0 §22) has no `flavor`.
287
+
288
+ ### 1.7 Where an `x-labcode` may appear
289
+
290
+ The positions of §1 are the only ones: the environment **root** (`probe` defaults only),
291
+ `processes.<p>.modes[]`, `transports[]`, `devices[]` and `transporters[]`. An `x-labcode`
292
+ anywhere else in the environment — on a process, beside `time` — is an **error**, as is a
293
+ key at a position that does not define it (a `connection` at the root, a `probe` on a mode).
294
+ Nothing would read it, and `ofplang-schedule` tolerates an `x-` key at *every* position
295
+ without interpreting it, so a misplaced block would otherwise stay silent forever.
296
+
297
+ This rule covers the environment only. An `x-labcode` in the **workflow** is not reported:
298
+ that document is portable v0, read by other implementations, and what extension keys it
299
+ carries is not labcode's business.
300
+
301
+ ## 2. Code source resolution and exclusivity
302
+
303
+ For a dispatched `(process, mode)`, labcode resolves the code to run in this order:
304
+
305
+ 1. the mode's `x-labcode.script.code` (2 — the labcode device script), else
306
+ 2. the workflow process's own `script.code` (1 — a v0 §22 script process), else
307
+ 3. none — the operation runs as a **typed-default no-op** (its outputs are typed
308
+ defaults; a device not yet scripted).
309
+
310
+ **Exclusivity (error).** A process MUST NOT carry both a workflow `script` (1) and an env
311
+ `x-labcode.script` (2) on any of its modes; that is ambiguous and is rejected.
312
+
313
+ **Typed-default reachability (warning).** A process with neither (1) nor (2) on any mode
314
+ will run as a typed-default no-op. This is allowed — convenient while mocking a device —
315
+ but `lc run` warns about it, so an unimplemented device is not silently a no-op.
316
+
317
+ ## 3. Execution model
318
+
319
+ Each dispatched operation runs in its own child process (real, wall-clock-paced); the
320
+ runner discovers completion by polling, so a multi-minute computation never blocks it.
321
+ The advisory `duration` is the scheduler's estimate; the real duration is the script's.
322
+ A script error (an exception, a wrong/ missing output name, a non-conformant value) is a
323
+ graceful runtime failure (§22.2): the operation ends `failed` and the run stops.
324
+
325
+ Cadence: the nominal poll period is `poll_interval × seconds_per_tick`. labcode defaults
326
+ `seconds_per_tick` to ~20 s (so a real op is polled at an observable cadence, not
327
+ sub-second, which would flood the replan loop); `lc run --seconds-per-tick/--speed/
328
+ --poll-interval/--margin` override it.
329
+
330
+ **The running-task margin defaults to the poll interval.** When the scheduler replans, a
331
+ still-running operation is pinned to end at `max(reported end, now + margin)` — the margin
332
+ is how far ahead of *now* an operation that has not finished is assumed to run for. A
333
+ positive margin is therefore what keeps that operation's successor from being planned at
334
+ `now` and dispatched onto a resource it has not released; and a real operation overruns its
335
+ estimate as a matter of course, so labcode defaults the margin to one poll interval rather
336
+ than to 0. Note that this does not depend on the cadence holding (§3.1): the pin moves with
337
+ `now`, so skipped ticks cannot erode the protection.
338
+
339
+ ### 3.1 A poll cycle has to fit its poll period
340
+
341
+ One turn of the loop costs the driving process real time: replanning, dispatching, and
342
+ whatever else the dialect does before it waits again. Call that the **cycle cost** and the
343
+ nominal poll period the **budget**. The relation between them decides how the run behaves,
344
+ and there is no third case:
345
+
346
+ - **cost < budget** — the loop waits out the difference, so a turn takes exactly the budget
347
+ and the clock lands on the next tick. The cost is *absorbed*: the run keeps the cadence it
348
+ was asked for and each operation's recorded duration reflects the lab. This is the case
349
+ the defaults are chosen for, and the case a lab should run in.
350
+ - **cost > budget** — there is nothing left to wait for. The loop stops waiting, the ticks
351
+ it could not observe are **skipped**, and the clock jumps to the tick real time has
352
+ reached. Nothing is falsified by that: the clock still tells real time, and the lab really
353
+ did keep running while the loop was busy. But the *effective* period becomes the cycle
354
+ cost, so `poll_interval` and `seconds_per_tick` no longer set the cadence, and every
355
+ operation's recorded duration is rounded up to that coarser grid — a fast operation can be
356
+ recorded as having taken a whole cycle. **`lc run` reports the first slip** (how long the
357
+ cycle took, what the period was, how many ticks went unobserved), because the fix is a
358
+ setting only the caller can change.
359
+
360
+ The report comes from the wait, so a cycle that never reaches it says nothing. When the
361
+ replan at the top of a cycle fails outright — there is no route, because the work needs a
362
+ machine that is gone — the run ends there, and no slip is reported however long that cycle
363
+ took. The absence of the message means the loop never got as far as waiting; it is not a
364
+ statement that the cycle was cheap.
365
+
366
+ So: keep the budget comfortably larger than the cycle cost. What the cycle costs is not
367
+ fixed — replanning grows with the workflow, and a dialect step such as availability probing
368
+ (§1.5) can add seconds — so the margin wants to be generous rather than exact. A run whose
369
+ recorded times matter (a checked-in example, a comparison against the plan's estimates)
370
+ needs this to hold; a run that only has to *complete* does not.
371
+
372
+ ## 4. Object identity — the reserved `_id` view key
373
+
374
+ labcode gives every Object a stable, value-layer identity so it can be traced across
375
+ steps and in the observation document. The identity lives in the Object's **view** under
376
+ the reserved key **`_id`** (a `String`). This is a dialect feature layered on portable
377
+ v0: the workflow the user writes carries no `_id`; `lc run` injects and mints it.
378
+
379
+ ### 4.1 Type rewrite
380
+
381
+ Before running, `lc run` rewrites the workflow in memory: it adds `_id: { type: String }`
382
+ to the `view` of **every `domain: object` type** (creating `view` if the type had none).
383
+ `_id` is an ordinary legal v0 view field (a leading-underscore identifier, not reserved
384
+ in core, and a primitive `String`), so the rewritten document validates and schedules
385
+ unchanged, and the runner's closed-shape view conformance treats `_id` as a normal
386
+ declared field. labcode runs this rewritten document directly (no temp file:
387
+ `ofplang.run.run_workflow` accepts an in-memory document).
388
+
389
+ **Reserved (error).** A user type that itself declares a `_id` view field is rejected at
390
+ the dialect front door — labcode owns `_id`, and silently clobbering the field would be
391
+ worse than a clear error.
392
+
393
+ ### 4.2 Where an id comes from
394
+
395
+ An Object's `_id` is set at its two points of origin, then **carried** everywhere else —
396
+ `objects.map` and transport copy the whole view, so `_id` propagates for free:
397
+
398
+ - **`objects.create`** — a newly created Object's `_id` is minted when the operation
399
+ produces it (in the backend's output fill). A device script need not know about `_id`:
400
+ it returns only what it computes, and the fill supplies `_id` (like any other unset
401
+ output, §1.2).
402
+ - **run boundary** — a whole-workflow Object *input* enters at the boundary; `lc run`
403
+ mints its `_id` (filling any other declared view field with a typed default so the
404
+ seeded value conforms), **unless the boundary already carries one** — so a result
405
+ boundary fed back in round-trips its ids.
406
+ - **`objects.map`** — a mapped Object output carries its input's `_id` unchanged
407
+ (identity preserved), even if a §22.2-strict script returned the port explicitly.
408
+
409
+ ### 4.3 Reproducibility
410
+
411
+ Ids come from a swappable generator (`labcode.idgen.IdGenerator`). The default
412
+ (`SeededUuid4Generator`) mints **reproducible** uuid4-shaped ids from a seed and a
413
+ *provenance key* — the node instance + output port for a create, the port name for a
414
+ boundary input — **not** draw order. So the same workflow yields the same ids on every
415
+ run, and the wall-clock backend's jittering completion order cannot change them (which is
416
+ what keeps checked-in example observations stable). A real run wanting globally-unique
417
+ ids per physical Object swaps in `RealUuid4Generator` (via
418
+ `labcode_backend_factory(id_generator=...)`).
419
+
420
+ > The provenance key is the runner's node-instance identity + port. Today each create
421
+ > node runs once, so node-path + port is unique; when dynamic control flow (e.g.
422
+ > `do_while`) is added, that node-instance identity must include the iteration index so
423
+ > ids stay unique and reproducible.
424
+
425
+ ## 5. Not yet in this version (roadmap)
426
+
427
+ - **A deeper probe** — asking a machine something (a SiLA2 property read) rather than only
428
+ opening a connection to it, so "answering" can be checked and not just "listening"
429
+ (§1.5). It would be an opt-in depth, since it costs a real exchange per check.
430
+ - **Probing in parallel** — checking machines concurrently, so a lab with many unreachable
431
+ machines does not pay for them one timeout at a time (§1.5).
432
+ - **TLS** — the fields a secure connection needs, lifting the restriction in §1.4.