simulo-interfaces 0.13.0__tar.gz → 0.14.0__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 (56) hide show
  1. {simulo_interfaces-0.13.0/src/simulo_interfaces.egg-info → simulo_interfaces-0.14.0}/PKG-INFO +1 -1
  2. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/pyproject.toml +1 -1
  3. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/__init__.py +116 -0
  4. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/artifacts.py +198 -43
  5. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/enums.py +3 -3
  6. simulo_interfaces-0.14.0/src/simulo/interfaces/platform/policy_bundle.py +2100 -0
  7. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/runs.py +8 -1
  8. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0/src/simulo_interfaces.egg-info}/PKG-INFO +1 -1
  9. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo_interfaces.egg-info/SOURCES.txt +1 -0
  10. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/MANIFEST.in +0 -0
  11. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/PYPI.md +0 -0
  12. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/setup.cfg +0 -0
  13. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/__init__.py +0 -0
  14. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/__init__.py +0 -0
  15. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/actuator.py +0 -0
  16. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/asset.py +0 -0
  17. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/entity.py +0 -0
  18. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/light.py +0 -0
  19. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/materials.py +0 -0
  20. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/physics.py +0 -0
  21. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/pose.py +0 -0
  22. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/primitives.py +0 -0
  23. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/prop.py +0 -0
  24. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/record.py +0 -0
  25. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/robot.py +0 -0
  26. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/scene.py +0 -0
  27. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/sensor.py +0 -0
  28. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/terrain.py +0 -0
  29. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/visual.py +0 -0
  30. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/authoring/world.py +0 -0
  31. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/exceptions.py +0 -0
  32. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/ids.py +0 -0
  33. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/app.py +0 -0
  34. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/asset.py +0 -0
  35. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/asset_catalog.py +0 -0
  36. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/callbacks.py +0 -0
  37. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/debug.py +0 -0
  38. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/domain.py +0 -0
  39. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/manifest.py +0 -0
  40. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/runtime.py +0 -0
  41. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/submit.py +0 -0
  42. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/platform/volume.py +0 -0
  43. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/py.typed +0 -0
  44. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/__init__.py +0 -0
  45. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/anomaly.py +0 -0
  46. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/components.py +0 -0
  47. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/env.py +0 -0
  48. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/player.py +0 -0
  49. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/policy.py +0 -0
  50. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/scenario.py +0 -0
  51. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/task.py +0 -0
  52. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/tensors.py +0 -0
  53. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo/interfaces/runtime/trainer.py +0 -0
  54. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo_interfaces.egg-info/dependency_links.txt +0 -0
  55. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo_interfaces.egg-info/requires.txt +0 -0
  56. {simulo_interfaces-0.13.0 → simulo_interfaces-0.14.0}/src/simulo_interfaces.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simulo-interfaces
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Type contracts for the Simulo SDK. Installed automatically as a dependency of the simulo package.
5
5
  Author-email: Simulo Team <team@simulo.ai>
6
6
  License: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simulo-interfaces"
7
- version = "0.13.0"
7
+ version = "0.14.0"
8
8
  description = "Type contracts for the Simulo SDK. Installed automatically as a dependency of the simulo package."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.11"
@@ -16,7 +16,10 @@ from simulo.interfaces.platform.artifacts import (
16
16
  MAX_OUTPUT_FLAG_CODE_CHARS,
17
17
  MAX_OUTPUT_FLAG_DETAIL_CHARS,
18
18
  MAX_OUTPUTS_BYTES,
19
+ OUTPUT_DROPPED_DETAILS,
19
20
  OUTPUT_FLAG_ANOMALY,
21
+ OUTPUT_FLAG_OUTPUT_DROPPED,
22
+ OUTPUTS_UNDELIVERED_CODE,
20
23
  WORKER_JOB_ARTIFACTS_PRESIGN_ROUTE_TEMPLATE,
21
24
  WORKER_JOB_ARTIFACTS_ROUTE_TEMPLATE,
22
25
  Artifact,
@@ -109,6 +112,60 @@ from simulo.interfaces.platform.manifest import (
109
112
  PreflightResponse,
110
113
  RuntimeManifest,
111
114
  )
115
+ from simulo.interfaces.platform.policy_bundle import (
116
+ EXPORT_VALIDATION_JOB_TYPE,
117
+ JOB_EXPORTS_ROUTE_TEMPLATE,
118
+ MAX_POLICY_BUNDLE_UNCOMPRESSED_BYTES,
119
+ MAX_POLICY_NAME_TOKEN_CHARS,
120
+ POLICY_BUNDLE_ACTION_SCHEMA_FILE,
121
+ POLICY_BUNDLE_ARCHIVE_NAME_TEMPLATE,
122
+ POLICY_BUNDLE_ARTIFACT_KIND,
123
+ POLICY_BUNDLE_FILES,
124
+ POLICY_BUNDLE_MANIFEST_FILE,
125
+ POLICY_BUNDLE_MODEL_ONNX_FILE,
126
+ POLICY_BUNDLE_MODEL_PT_FILE,
127
+ POLICY_BUNDLE_NORMALIZATION_FILE,
128
+ POLICY_BUNDLE_OBSERVATION_SCHEMA_FILE,
129
+ POLICY_BUNDLE_OPTIONAL_FILES,
130
+ POLICY_BUNDLE_README_FILE,
131
+ POLICY_BUNDLE_REQUIRED_FILES,
132
+ POLICY_BUNDLE_REQUIREMENTS_FILE,
133
+ POLICY_BUNDLE_ROOT_DIR_TEMPLATE,
134
+ POLICY_BUNDLE_SCHEMA_VERSION,
135
+ POLICY_BUNDLE_TEST_VECTORS_FILE,
136
+ POLICY_BUNDLE_VERIFY_FILE,
137
+ POLICY_EQUIVALENCE_ABS_TOLERANCE,
138
+ POLICY_EQUIVALENCE_REL_TOLERANCE,
139
+ POLICY_GOLDEN_VECTOR_COUNT,
140
+ POLICY_NAME_TOKEN_FALLBACK,
141
+ POLICY_TENSOR_DTYPES,
142
+ POLICY_TEST_VECTOR_EXPECTED_OUTPUTS_KEY,
143
+ POLICY_TEST_VECTOR_INPUTS_KEY,
144
+ SUPPORTED_POLICY_BUNDLE_SCHEMA_MAJORS,
145
+ UNKNOWN,
146
+ PolicyActionComponent,
147
+ PolicyActionSchema,
148
+ PolicyBounds,
149
+ PolicyBundleManifest,
150
+ PolicyEquivalenceSpec,
151
+ PolicyExportFailureReason,
152
+ PolicyExportFormat,
153
+ PolicyExportValidation,
154
+ PolicyExportVerdict,
155
+ PolicyHistorySpec,
156
+ PolicyNormalization,
157
+ PolicyObservationComponent,
158
+ PolicyObservationSchema,
159
+ PolicySourceSpec,
160
+ PolicyTensorSpec,
161
+ Unknown,
162
+ is_supported_policy_bundle_schema,
163
+ policy_bundle_archive_name,
164
+ policy_bundle_root_dir,
165
+ policy_bundle_schema_major,
166
+ policy_name_token,
167
+ validate_policy_bundle,
168
+ )
112
169
  from simulo.interfaces.platform.runs import (
113
170
  JOB_CANCEL_ROUTE_TEMPLATE,
114
171
  JOB_LIST_SCOPES,
@@ -201,6 +258,9 @@ __all__ = [
201
258
  "MAX_OUTPUT_FLAG_CODE_CHARS",
202
259
  "MAX_OUTPUT_FLAG_DETAIL_CHARS",
203
260
  "OUTPUT_FLAG_ANOMALY",
261
+ "OUTPUT_FLAG_OUTPUT_DROPPED",
262
+ "OUTPUT_DROPPED_DETAILS",
263
+ "OUTPUTS_UNDELIVERED_CODE",
204
264
  "DECLARED_OUTPUT_MISSING_CODE",
205
265
  # job-run contract (run record schema + jobs HTTP API shape)
206
266
  "TERMINAL_JOB_STATUSES",
@@ -322,6 +382,62 @@ __all__ = [
322
382
  "ConvertedArchive",
323
383
  "AssetValidationFinalize",
324
384
  "AssetPublishOptions",
385
+ # portable policy export bundle contract (issue #654) — schema version,
386
+ # bundle layout, naming rule, export routes/job type, equivalence
387
+ # defaults, enums, explicit-unknown marker, manifest dataclasses, the
388
+ # export-validation result, and the cross-file coherence check
389
+ "POLICY_BUNDLE_SCHEMA_VERSION",
390
+ "SUPPORTED_POLICY_BUNDLE_SCHEMA_MAJORS",
391
+ "policy_bundle_schema_major",
392
+ "is_supported_policy_bundle_schema",
393
+ "POLICY_BUNDLE_MODEL_ONNX_FILE",
394
+ "POLICY_BUNDLE_MODEL_PT_FILE",
395
+ "POLICY_BUNDLE_MANIFEST_FILE",
396
+ "POLICY_BUNDLE_OBSERVATION_SCHEMA_FILE",
397
+ "POLICY_BUNDLE_ACTION_SCHEMA_FILE",
398
+ "POLICY_BUNDLE_NORMALIZATION_FILE",
399
+ "POLICY_BUNDLE_TEST_VECTORS_FILE",
400
+ "POLICY_BUNDLE_VERIFY_FILE",
401
+ "POLICY_BUNDLE_REQUIREMENTS_FILE",
402
+ "POLICY_BUNDLE_README_FILE",
403
+ "POLICY_BUNDLE_FILES",
404
+ "POLICY_BUNDLE_REQUIRED_FILES",
405
+ "POLICY_BUNDLE_OPTIONAL_FILES",
406
+ "MAX_POLICY_BUNDLE_UNCOMPRESSED_BYTES",
407
+ "POLICY_TEST_VECTOR_INPUTS_KEY",
408
+ "POLICY_TEST_VECTOR_EXPECTED_OUTPUTS_KEY",
409
+ "POLICY_BUNDLE_ARTIFACT_KIND",
410
+ "POLICY_BUNDLE_ROOT_DIR_TEMPLATE",
411
+ "POLICY_BUNDLE_ARCHIVE_NAME_TEMPLATE",
412
+ "MAX_POLICY_NAME_TOKEN_CHARS",
413
+ "POLICY_NAME_TOKEN_FALLBACK",
414
+ "policy_name_token",
415
+ "policy_bundle_root_dir",
416
+ "policy_bundle_archive_name",
417
+ "JOB_EXPORTS_ROUTE_TEMPLATE",
418
+ "EXPORT_VALIDATION_JOB_TYPE",
419
+ "POLICY_EQUIVALENCE_ABS_TOLERANCE",
420
+ "POLICY_EQUIVALENCE_REL_TOLERANCE",
421
+ "POLICY_GOLDEN_VECTOR_COUNT",
422
+ "POLICY_TENSOR_DTYPES",
423
+ "PolicyExportFormat",
424
+ "PolicyExportVerdict",
425
+ "PolicyExportFailureReason",
426
+ "PolicyExportValidation",
427
+ "Unknown",
428
+ "UNKNOWN",
429
+ "PolicyTensorSpec",
430
+ "PolicyBounds",
431
+ "PolicyHistorySpec",
432
+ "PolicySourceSpec",
433
+ "PolicyEquivalenceSpec",
434
+ "PolicyObservationComponent",
435
+ "PolicyActionComponent",
436
+ "PolicyBundleManifest",
437
+ "PolicyObservationSchema",
438
+ "PolicyActionSchema",
439
+ "PolicyNormalization",
440
+ "validate_policy_bundle",
325
441
  # protocols
326
442
  "RuntimeProtocol",
327
443
  "AssetProtocol",
@@ -118,6 +118,29 @@ migration). Instead ``JobRecord.outputs`` carries an optional
118
118
  even if the transition loses a race) and partial recordings are dropped —
119
119
  cancel-uploaded checkpoints are intentionally **unsummarized**. A decision,
120
120
  not an oversight.
121
+ * **A registered artifact reaches the general job-artifacts road on the
122
+ ``completed`` path only** — never on cancel, timeout, or failure. Also a
123
+ decision, and the reason it is not the worker's to reverse: the plan IS the
124
+ drained registration set, and the registry is drained **after the job body
125
+ returns** (``runner``'s ``--outputs-out`` write is on its success path only,
126
+ mirroring ``--result-out``). A cancelled, timed-out or crashed run never
127
+ reaches that drain, so no plan file exists and there is nothing to upload;
128
+ the worker's ``status == completed`` gate makes that explicit and
129
+ fail-closed rather than reading whatever sidecar happens to be on disk from
130
+ an earlier attempt. The two older roads look asymmetric here because they
131
+ are **sniffed** rather than planned — ``_gather_recordings`` /
132
+ ``_gather_models`` scan the run directory, so they still find (and, for
133
+ checkpoints, still upload) files a failed run left behind. This road has no
134
+ sniffer by construction.
135
+
136
+ The user-visible consequence, stated rather than buried: a ``report`` or
137
+ ``log`` that a FAILING run registers to explain itself is **not delivered**.
138
+ That is the price of "a partial file is not an honest artifact"; the file
139
+ stays on the worker's disk. The worker deliberately emits no warning for it
140
+ either — with no plan file it cannot distinguish "registered nothing" from
141
+ "registered, never drained", so the only available warning is one that would
142
+ fire on every failing job. A job that must hand back diagnostics on failure
143
+ should write them **before** the work that can fail and return normally.
121
144
 
122
145
  The ``/artifacts`` projection is a read-time union, not a home
123
146
  --------------------------------------------------------------
@@ -138,6 +161,7 @@ contract, including the ``pages >= 1`` invariant that holds even for
138
161
  ``total == 0``.
139
162
  """
140
163
 
164
+ import re
141
165
  from collections.abc import Mapping
142
166
  from dataclasses import dataclass, field
143
167
 
@@ -284,6 +308,74 @@ MAX_OUTPUT_FLAG_DETAIL_CHARS = 256
284
308
  #: comes from the persisted-rows projection, never from the flag).
285
309
  OUTPUT_FLAG_ANOMALY = "anomaly"
286
310
 
311
+ #: :attr:`OutputFlag.code` announcing that at least one output the run
312
+ #: REGISTERED never became an artifact — the uploader refused it. ``detail``
313
+ #: carries the refusal reason, one of :data:`OUTPUT_DROPPED_DETAILS`, and the
314
+ #: producer emits ONE flag per DISTINCT reason (the :data:`OUTPUT_FLAG_ANOMALY`
315
+ #: rule: a flag states a property of the run, never an inventory of files).
316
+ #: The worker emits it; the CLI renders it.
317
+ #:
318
+ #: WHY IT EXISTS. A registration the platform refuses is a defect in the JOB —
319
+ #: it named a path the platform will never accept — not a transient condition.
320
+ #: Every other trace of it is unreachable: the refusal is written to the
321
+ #: WORKER's own stderr, while the log the platform uploads is the CHILD's.
322
+ #: Without this flag such a run is reported a plain ``completed`` having
323
+ #: delivered less than it claimed, with nothing anywhere for a user to see.
324
+ #:
325
+ #: SCOPE. This flag rides the ``complete`` body, which the control plane keeps
326
+ #: for a ``completed`` job only. It therefore cannot describe a run that
327
+ #: delivered NOTHING AT ALL — that run is not ``completed``, it is terminal
328
+ #: :data:`OUTPUTS_UNDELIVERED_CODE`. The two signals are mutually exclusive by
329
+ #: construction, and between them every refused output is reported.
330
+ OUTPUT_FLAG_OUTPUT_DROPPED = "output_dropped"
331
+
332
+ #: The closed ``detail`` vocabulary of :data:`OUTPUT_FLAG_OUTPUT_DROPPED` — why
333
+ #: one registered output was refused:
334
+ #:
335
+ #: * ``malformed`` — the registration's ``name``/``path`` was not a string;
336
+ #: * ``unresolvable`` — the path could not be resolved on the worker;
337
+ #: * ``outside_roots`` — the resolved path lies outside every directory the
338
+ #: platform points a job's writes at. Uploading it anyway would let a
339
+ #: registration name any file the worker can read, so the refusal is the
340
+ #: guard working, and the flag is how the run says so;
341
+ #: * ``no_pipeline`` — the declared kind has no artifact home to land in (a
342
+ #: checkpoint whose filename carries no role, or a kind outside
343
+ #: :class:`~simulo.interfaces.platform.enums.ArtifactKind`).
344
+ OUTPUT_DROPPED_DETAILS: tuple[str, ...] = (
345
+ "malformed",
346
+ "unresolvable",
347
+ "outside_roots",
348
+ "no_pipeline",
349
+ )
350
+
351
+ #: Terminal ``status_reason`` of a run whose body exited 0 but which delivered
352
+ #: NO artifact at all: every output it registered was refused (see
353
+ #: :data:`OUTPUT_DROPPED_DETAILS`) and it produced no recording and no
354
+ #: checkpoint either. Emitted by the worker, rendered by the CLI.
355
+ #:
356
+ #: Such a run is reported ``failed``, not ``completed``. Green-with-nothing is
357
+ #: the worse of the two outcomes: a job whose whole purpose is one file, shown
358
+ #: successful beside an empty artifact list, gives nobody a reason to look —
359
+ #: whereas a job marked failed still has its log, and the run cost the same
360
+ #: either way.
361
+ #:
362
+ #: The condition is deliberately "delivered nothing", not "every registered
363
+ #: output was refused". A run whose checkpoints or recordings landed DID
364
+ #: deliver something, and stays ``completed`` carrying
365
+ #: :data:`OUTPUT_FLAG_OUTPUT_DROPPED` for what it lost — failing it would throw
366
+ #: away hours of real training over one mis-registered path.
367
+ #:
368
+ #: THE RESULT SURVIVES. ``GET /v1/jobs/{id}/result`` serves the result of a
369
+ #: run reported under this code, exactly as it does for a completed run — the
370
+ #: one status other than ``completed`` for which a result is stored at all.
371
+ #: The same argument that keeps a run with surviving checkpoints ``completed``
372
+ #: applies to the dict the body returned: this run's code RAN, its metrics are
373
+ #: as real as any completed run's, and only its FILES were refused. Discarding
374
+ #: them too would destroy work the refusal never touched. The typed
375
+ #: ``outputs`` summary is NOT preserved the same way — it describes the
376
+ #: artifacts, which is precisely what this code already says.
377
+ OUTPUTS_UNDELIVERED_CODE = "outputs_undelivered"
378
+
287
379
  #: Terminal ``status_reason`` code of a job whose declared **non-optional**
288
380
  #: output was missing after the run body succeeded (the runner's validation —
289
381
  #: see the module docstring). Emitted by the runner, rendered by the CLI.
@@ -367,6 +459,88 @@ def _validate_artifact_name(owner: str, name: str) -> None:
367
459
  raise ValueError(f"{owner} must not be a dots-only relative-path token, got {_truncated(name)}.")
368
460
 
369
461
 
462
+ #: The SERVER's finalize-time filename rule, mirrored — the second, STRICTER
463
+ #: gate an artifact ``name`` must clear before its bytes may be uploaded.
464
+ #:
465
+ #: Canonical implementation: ``simulo_control_plane.storage.base._KEY_SEGMENT_RE``,
466
+ #: applied to ``ArtifactFinalizeRequest.name`` (and ``ModelFinalizeRequest.name``)
467
+ #: through ``storage.base.is_safe_filename_segment`` — anything else is a 422.
468
+ #: Mirrored here rather than imported for the reason ``jobs/constants.py``
469
+ #: records at length: the control-plane build is self-contained and this
470
+ #: contract package is NOT installed in its deploy image, so the dependency
471
+ #: cannot run in that direction.
472
+ #:
473
+ #: **Why a mirror is worth carrying.** The upload order is presign → PUT →
474
+ #: finalize, so a name that clears :func:`_validate_artifact_name` and then
475
+ #: fails the server's rule has ALREADY written bytes to object storage when the
476
+ #: 422 arrives: the artifact is lost, the object is permanently unreferenced
477
+ #: (no row ever names it), and a job may repeat that up to
478
+ #: ``simulo.outputs.MAX_REGISTERED_OUTPUTS`` times at unbounded size. Measured
479
+ #: divergences — names :func:`_validate_artifact_name` ACCEPTS and this rule
480
+ #: rejects: ``"my report.html"`` (space), ``"résumé.pdf"`` (non-ASCII),
481
+ #: ``"eval (1).json"`` (parentheses), ``".hidden"`` (leading dot).
482
+ #:
483
+ #: Those are ordinary things to call a file, and the reachable path is the
484
+ #: PUBLIC one: ``simulo.save_artifact("my report.html", path, kind="report")``
485
+ #: passes the constructor and then orphans its bytes. A worker that checks
486
+ #: BEFORE the presign turns that into an honest, named refusal on the warning
487
+ #: channel instead of an unreferenced object.
488
+ #:
489
+ #: NOT affected, measured rather than assumed: the policy-export bundle name.
490
+ #: ``policy_bundle.policy_name_token`` reduces a job title to ``[a-z0-9._-]``
491
+ #: with a ``[a-z0-9]`` first character — deliberately, citing this very gate —
492
+ #: so ``résumé-arm`` composes ``r-sum-arm-policy-best.zip``, which this rule
493
+ #: ACCEPTS. That is a second, independent guard on one caller; this one is the
494
+ #: general gate every other registered name needs.
495
+ #:
496
+ #: The drift guard is ``tests/integration/test_contract_mirror.py`` — the one
497
+ #: environment holding both packages. It compares this pattern against the
498
+ #: control plane's real compiled regex AND runs both implementations over a
499
+ #: table of names, so a future divergence goes red there rather than orphaning
500
+ #: objects in production.
501
+ SAFE_FILENAME_SEGMENT_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._-]*\Z"
502
+ _SAFE_FILENAME_SEGMENT_RE = re.compile(SAFE_FILENAME_SEGMENT_PATTERN)
503
+
504
+
505
+ def is_safe_filename_segment(value: str) -> bool:
506
+ """``True`` when ``value`` is a name the control plane's finalize accepts.
507
+
508
+ Deliberately the same NAME as the control plane's own helper
509
+ (``storage.base.is_safe_filename_segment``), so the two read as the one
510
+ rule they are. Anchored with ``\\Z`` and not ``$`` for the reason recorded
511
+ on the server's copy: Python's ``$`` also matches just before a trailing
512
+ newline, so ``"rollout.mcap\\n"`` would wrongly pass.
513
+
514
+ NOT a replacement for :func:`_validate_artifact_name`, which stays the
515
+ contract's own construction-time rule (cross-platform separators, control
516
+ characters, dots-only tokens, the UTF-8 byte ceiling, and the type check).
517
+ This is the narrower *server-acceptance* question, asked by an uploader
518
+ before it spends bytes.
519
+ """
520
+ return bool(value) and bool(_SAFE_FILENAME_SEGMENT_RE.match(value))
521
+
522
+
523
+ def _validate_finalizable_artifact_name(owner: str, name: str) -> None:
524
+ """Reject any ``name`` the server's finalize would answer 422 for.
525
+
526
+ Both gates, in the order an uploader must apply them: the contract's own
527
+ :func:`_validate_artifact_name` first (it owns the type check and the
528
+ length-before-echo discipline, so an over-length or non-``str`` value is
529
+ reported by its size/type and never reproduced), then
530
+ :func:`is_safe_filename_segment`.
531
+
532
+ Every rejection is a ``ValueError`` whose message names the offending value
533
+ (clamped by :func:`_truncated` — an uploader's message is worker-log
534
+ content and must never amplify its own input) and says which rule it broke.
535
+ """
536
+ _validate_artifact_name(owner, name)
537
+ if not is_safe_filename_segment(name):
538
+ raise ValueError(
539
+ f"{owner} must be a single safe filename segment the control plane's finalize will accept "
540
+ f"(letters, digits, '.', '_', '-'; must start with a letter or digit), got {_truncated(name)}."
541
+ )
542
+
543
+
370
544
  def _validate_artifact_path(owner: str, path: str) -> None:
371
545
  """Reject any ``path`` that could not name a real file for any consumer.
372
546
 
@@ -518,54 +692,33 @@ class ArtifactDeclaration:
518
692
 
519
693
  @dataclass(frozen=True, slots=True, kw_only=True)
520
694
  class Artifact:
521
- """One registered job output — the execution-time record of a real file.
522
-
523
- This is the type ``simulo.save_artifact(name, path)`` registers (tier 2)
524
- and the type tier-0 producers register as they write files (the anomaly
525
- monitor registers each capture session with its actual generated
526
- filename — PR-B4). A registration always names a file the run really
527
- produced at a path that really exists, which is exactly what
528
- distinguishes it from :class:`ArtifactDeclaration` (the pre-run shape,
529
- no path field): the drained registration set is the plan side of the
530
- worker's ``plan ∪ sniff`` union, keyed by ``Path.resolve()`` of
531
- :attr:`path` — declarations never enter that union. It is an authoring
532
- value, not a wire row — it validates at construction so mistakes surface
533
- on the author's machine (the
534
- :class:`~simulo.interfaces.runtime.anomaly.DebugOnAnomaly` precedent);
535
- the persisted/served row is :class:`ArtifactRecord`.
695
+ """A file registration created while a job is running.
696
+
697
+ ``simulo.save_artifact(name, path)`` builds this value and validates it
698
+ before adding it to the job's local output registry. It describes a real
699
+ file at a real path, but it is not confirmation that the file was uploaded.
700
+ The current managed platform does not publish general registered files for
701
+ download. Use a job result for small JSON values, a named volume for files
702
+ another job needs, or the supported model and recording workflows for
703
+ downloadable output.
536
704
  """
537
705
 
538
706
  name: str
539
- """Logical name — the single filename the platform stores, lists, and
540
- saves downloads under. Never a path: separators (including the Windows
541
- drive ``:``), control characters, dots-only and whitespace-only tokens,
542
- empty and over-:data:`MAX_ARTIFACT_NAME_BYTES` names are rejected at
543
- construction (a traversal-shaped name would otherwise become the
544
- filename a client writes a download to — see
545
- :func:`_validate_artifact_name` for the full rule and the download-site
546
- obligation)."""
707
+ """Logical filename for the registration. It must be one filename, never
708
+ a path. Separators, control characters, dots-only and whitespace-only
709
+ values, empty values, and names over
710
+ :data:`MAX_ARTIFACT_NAME_BYTES` are rejected."""
547
711
 
548
712
  path: str
549
- """Filesystem path of the produced file in the job's working tree at
550
- execution time. The worker's ``plan ∪ sniff`` de-duplication key is this
551
- path **resolved** (``Path.resolve()``), the plan's kind winning.
552
-
553
- Validated at construction for TYPE (a ``str``), LENGTH
554
- (≤ :data:`MAX_ARTIFACT_PATH_BYTES`), non-emptiness, and absence of a NUL
555
- byte — the four things that make a value unusable as a path *by any
556
- consumer*, checked here so the consumer does not discover them mid-upload.
557
- Deliberately NOT validated for shape or location: a path is legitimately
558
- absolute and may point anywhere the job can write, so *where* it points is
559
- contained at the uploader (see the ``simulo.outputs`` module warning), not
560
- here."""
713
+ """Filesystem path of the produced file while the job is running.
714
+ Construction checks that it is a non-empty, NUL-free string within
715
+ :data:`MAX_ARTIFACT_PATH_BYTES`. The path may be absolute or relative."""
561
716
 
562
717
  kind: ArtifactKind = ArtifactKind.FILE
563
- """What this output is (see :class:`~simulo.interfaces.platform.enums.ArtifactKind`).
564
- ``save_artifact`` registers :attr:`~simulo.interfaces.platform.enums.ArtifactKind.FILE`
565
- by default; tier-0 producers stamp the callback's implied kind when they
566
- register (the anomaly monitor stamps ``anomaly_capture``). A plain wire
567
- string is normalized to the enum at construction; an unknown kind string
568
- is a ``ValueError`` on the author's machine."""
718
+ """What kind of file this is. The default is
719
+ :attr:`~simulo.interfaces.platform.enums.ArtifactKind.FILE`. You may pass
720
+ an ``ArtifactKind`` value or its string form; unknown values raise
721
+ ``ValueError``."""
569
722
 
570
723
  def __post_init__(self) -> None:
571
724
  object.__setattr__(self, "kind", ArtifactKind(self.kind))
@@ -623,8 +776,10 @@ class ArtifactRecord:
623
776
  (a checkpoint is NOT a trained model — Rule #8, stated in the
624
777
  :mod:`~simulo.interfaces.platform.domain` module docstring and on its
625
778
  ``Checkpoint``/``TrainedModel`` types).
626
- ``trained_model`` has no producer today and is only ever stamped
627
- explicitly by a future exporter, never inferred from a ``models`` row;
779
+ ``trained_model`` DOES have a producer — the worker's general
780
+ job-artifacts road (``worker._upload_job_artifact``), whose rows live in
781
+ ``job_artifacts`` — and it is only ever stamped explicitly, by the job
782
+ that registers the bundle, never inferred from a ``models`` row;
628
783
  * rows projected from ``job_recordings`` map a legacy NULL kind to
629
784
  :attr:`~simulo.interfaces.platform.enums.ArtifactKind.RECORDING` —
630
785
  never to ``replay_output`` (see the enum docstrings)."""
@@ -172,6 +172,6 @@ class ArtifactKind(StrEnum):
172
172
  :attr:`ResourceKind.DATASET` classifies an input mount."""
173
173
 
174
174
  FILE = "file"
175
- """The catch-all: an opaque produced file. The default kind of
176
- ``simulo.save_artifact`` and the kind assigned to a produced-but-
177
- undeclared output (which warns and registers, never fails)."""
175
+ """A general produced file. This is the default kind used by
176
+ ``simulo.save_artifact``. The current managed platform records the
177
+ registration but does not publish general files for download."""