Files
SciMesh/mkdocs/sdk/authoring-workloads.md
T

296 lines
12 KiB
Markdown

# Authoring workloads
A workload is a **user script** that imports the SDK. For the standard
`core-batch-v1` shape — one input dataset, deterministic shards, one merged
result — subclass [`MapReduceWorkload`](../api/sdk-batch.md) and implement
the scientific hooks. The SDK assembles everything else: the immutable
manifest, the map/reduce stages, the workflow DAG, the digest-pinned
planner/runner/reducer handlers, and the exact-artifact verifier.
## The minimal workload
This is the complete `molwt-filter` workload (the built-in minimal example):
it filters molecules by exact RDKit molecular weight and needs only **one**
scientific hook, because the scaffold's default sharding and concatenation
already cover partitioning and reduction.
```python
from pathlib import Path
from typing import Any, Mapping
from scimesh.sdk import (
ArtifactSchema,
ComponentRef,
MapReduceWorkload,
PortSpec,
SchemaRef,
WorkloadId,
)
from scimesh.sdk.registry import WorkloadDefinition
class MolwtFilterWorkload(MapReduceWorkload):
workload_id = WorkloadId("molwt-filter", "1.0.0")
description = (
"Filter molecules by exact RDKit molecular weight, one canonical "
"CSV row per kept input molecule, in deterministic input order."
)
parameters_schema = {
"type": "object",
"additionalProperties": False,
"properties": {
"min_molwt": {"type": "number", "minimum": 0},
"max_molwt": {"type": "number", "minimum": 0},
"skip_invalid": {"type": "boolean", "default": True},
},
}
input_port = PortSpec(ArtifactSchema(
SchemaRef("molecule-table", 1),
"text/tab-separated-values",
"utf-8",
max_bytes=10 * 1024 * 1024 * 1024,
validator=ComponentRef("delimited-table", 1),
validator_configuration={"required_columns": ["canonical_smiles", "chembl_id"]},
max_records=100_000_000,
canonicalizer="scimesh-tsv-v1",
))
partial_port = output_port = PortSpec(ArtifactSchema(
SchemaRef("molwt-filtered-table", 1),
"text/csv",
"utf-8",
max_bytes=100 * 1024 * 1024 * 1024,
validator=ComponentRef("delimited-table", 1),
validator_configuration={"columns": ["chembl_id", "canonical_smiles", "molwt"]},
max_records=100_000_000,
canonicalizer="molwt-filtered-table-v1",
))
map_parameter_names = ("min_molwt", "max_molwt", "skip_invalid")
def domain_validate(self, parameters: Mapping[str, Any]) -> None:
if parameters.get("min_molwt") is None and parameters.get("max_molwt") is None:
raise ValueError("at least one of min_molwt or max_molwt is required")
def compute_shard(self, inputs, parameters, output_path):
# `inputs` maps every map-stage input port to a materialized file;
# here there is one port: "input".
return filter_molecules_by_molwt(
inputs["input"],
output_path,
min_molwt=parameters.get("min_molwt"),
max_molwt=parameters.get("max_molwt"),
skip_invalid=parameters.get("skip_invalid", True),
)
```
Because the scaffold provides default `partition_input` (row-bounded shards
that keep the header, `shard_rows` rows each) and default `reduce_partials`
(concatenation with one header), no further code is required.
## Required class attributes
| Attribute | Meaning |
| --- | --- |
| `workload_id` | `WorkloadId("name", "1.0.0")` — the immutable identity |
| `description` | Shown in `scimesh workload list`, the UI library, and the catalog export |
| `parameters_schema` | Strict JSON object schema (`additionalProperties: false`); the registry validates jobs against it before the planner runs |
| `input_port` | External input port (`PortSpec`) |
| `partial_port` | One map output artifact (`PortSpec`) |
| `output_port` | Final result artifact (`PortSpec`) |
## Optional class attributes
| Attribute | Default | Meaning |
| --- | --- | --- |
| `map_stage_inputs` | `{"input": input_port}` | Map-stage input ports; extra ports must share the external input schema |
| `map_parameter_names` | `()` | Parameter projection for map tasks |
| `reduce_parameter_names` | `map_parameter_names` | Parameter projection for the reducer |
| `capabilities` | `(workload_id.name,)` | Advertised capabilities |
| `trust_modes` | `(trusted, untrusted_quorum)` | Declared trust modes |
| `workflow_id` | `"<name>-map-reduce-v1"` | Workflow identity |
| `limits` | derived from port bounds | `WorkloadLimits` |
| `resources` / `execution` | CPU-1 core defaults | Per-task resource and execution profile |
| `shard_rows` | `1000` | Rows per shard for the default `partition_input` |
| `map_entry_point` / `reduce_entry_point` | derived from the module | Handler keys (can stay default) |
| `ui_elements` | `()` | `UIElement` declarations that shape the coordinator "new computation" form |
| `reduction` | `"ordered-concat"` | Coordinator reduction mode: `ordered-concat` (row tables) or `top-k` |
| `upload_ready` | `True` | Whether a single uploaded dataset can drive the workload (multi-input planners set `False`) |
## Workload-declared UI elements
The coordinator "new computation" page renders one form per workload from the
embedded catalog. Workloads that need friendlier controls than plain schema
fields declare them:
```python
from scimesh.sdk import UIElement
ui_elements = (
UIElement(
"min_molwt", "number", "Minimum molecular weight",
help="Keep molecules with MolWt at least this value. Optional.",
placeholder="e.g. 100", order=1,
),
UIElement(
"skip_invalid", "checkbox", "Skip invalid molecules",
help="Skip rows with invalid SMILES instead of failing the shard.",
default=True, order=3,
),
)
```
Each `field` must name a `parameters_schema` property. Widgets: `text`,
`textarea`, `number`, `select` (requires `options`), and `checkbox`. When a
workload declares no elements, the page falls back to schema-derived controls
(defaults and descriptions from the schema still apply). The schema remains
the authoritative contract: the coordinator re-validates submitted parameters
against it server-side.
## Scientific hooks
Override only what your workload needs:
| Hook | Default | Purpose |
| --- | --- | --- |
| `domain_validate(parameters)` | none | Extra job-parameter validation (the JSON schema already ran) |
| `resolved_parameters(request)` | `dict(request.parameters)` | Values persisted into the plan |
| `resolved_parameters_for_plan(job, input_path, resolved)` | unchanged | Plan-time enrichment (e.g. one-time query resolution) |
| `partition_input(input_path, parameters, workspace)` | row-bounded sharding | Deterministic shard files, one per map task |
| `plan_tasks(shard_paths, resolved, job, negotiated, map_stage, context)` | one task per shard | Custom task construction |
| `task_parameters(resolved)` | filtered projection | Map-task parameters |
| `compute_shard(inputs, parameters, output_path)` | **required** | One map task; returns metrics |
| `parse_partial_key(key)` / `validate_partial_keys(parsed)` | `map.<8-digit>`, contiguous | Partial-key policy for the reducer |
| `reduce_partials(partial_paths, parameters, output_path)` | header-preserving concatenation | Deterministic merge |
Hooks must be **deterministic**: identical inputs and parameters must
produce byte-identical partials, in any worker, in any completion order.
Floats should be formatted with a fixed precision (for example `f"{v:.6f}"`),
and output row order must be canonical.
## Running a workload locally
```python
from scimesh.sdk import (
ArtifactCollection,
JobRequest,
LocalArtifactStore,
LocalCoreBatchExecutor,
WorkloadRegistry,
)
from scimesh.workloads.library import default_sdk_runtime
workload = MolwtFilterWorkload(
package_digest=current_scimesh_package_digest(),
environment_digest=current_environment_digest(),
)
registry = WorkloadRegistry()
registry.register(workload.definition(), enabled=True)
store = LocalArtifactStore(Path("artifacts"))
artifact = store.import_file(
Path("molecules.tsv"),
declaration=workload.manifest.inputs["input"].schema,
)
request = JobRequest(
workload=workload.manifest.workload,
parameters={"min_molwt": 40.0},
inputs={"input": ArtifactCollection.single(artifact)},
)
result = LocalCoreBatchExecutor(
registry, default_sdk_runtime(), store, Path("work"),
).execute(request, workload.manifest.package.digest)
print(store.materialize(result.outputs["result"].items[0].artifact).read_text())
```
`LocalCoreBatchExecutor` runs the full pipeline — negotiation, planning,
map tasks, stage verification, reduce, final verification — in-process. It
is a correctness harness, not an isolation boundary: it accepts only
trusted, single-threaded, trusted-network profiles and rejects everything
else before a handler runs.
## Custom planning: block pairs
Workloads that need more than one input per task override `plan_tasks` and
`map_stage_inputs`. The built-in `similarity-graph` plans one task per block
pair `(i, j)` with `i <= j`:
```python
map_stage_inputs = {"left": block_port, "right": block_port}
def plan_tasks(self, shard_paths, resolved, job, negotiated, map_stage, context):
block_refs = [
context.sink.seal(path, declaration=self.input_port.schema)
for path in shard_paths
]
tasks = []
for left in range(len(block_refs)):
for right in range(left, len(block_refs)):
tasks.append(self.task_spec(
map_stage, job, negotiated,
f"map/{left:04d}x{right:04d}",
{"left_block": left, "right_block": right,
"threshold": resolved["threshold"]},
{"left": ArtifactCollection.single(block_refs[left]),
"right": ArtifactCollection.single(block_refs[right])},
))
return tasks
```
Its reducer overrides `parse_partial_key`/`validate_partial_keys` to parse
`map.<i>x<j>` keys and enforce the pair-coverage invariant (every unordered
molecule pair compared exactly once).
## Packaging and discovery
Workloads are installed as part of a Python distribution and declared as
entry points:
```toml
[project.entry-points."scimesh.workloads"]
"my-workload@1.0.0" = "my_package.workload:workload_definition"
```
The factory returns a `WorkloadDefinition` (or a `MapReduceWorkload`
instance with a `definition()` method). An administrator then supplies an
`AllowedPackage(distribution, WorkloadId, "sha256:...")` allowlist entry;
discovery loads the entry point only when the installed package content
matches the pinned digest.
```python
from scimesh.sdk import AllowedPackage, WorkloadId, WorkloadRegistry
registry = WorkloadRegistry()
registry.discover_installed((
AllowedPackage("my-dist", WorkloadId("my-workload", "1.0.0"), "sha256:" + "a" * 64),
))
```
## Tests and golden parity
Add a regression test for every behavioral change:
- **Byte parity**: run the workload through `LocalCoreBatchExecutor` and
compare the final artifact bytes with a single-process reference computed
by the scientific core directly.
- **Determinism**: planning twice must produce identical JSON; results must
be invariant to shard/block sizes.
- **Fail-closed**: invalid parameters, missing ports, forged outputs, and
unsupported trust modes must be rejected.
- **Verifier policy**: for `untrusted_quorum`, two distinct owners with
identical outputs must be accepted, conflicting outputs rejected.
Use small TSV fixtures — never the full ChEMBL extract, which takes minutes
even for one shard.
## Rules
1. Keep the scientific core callable without a coordinator.
2. Inline a strict JSON parameter schema (`additionalProperties: false`);
the planner still performs domain validation.
3. Give every external and stage port an `ArtifactSchema` with bounds.
4. Return only sink-sealed artifacts in `OutputManifest`; the harness binds
task key and provenance itself.
5. Select a verifier compatible with determinism and trust: v1 permits
`untrusted_quorum` only for `byte_exact` plus `exact-artifact@1`.
6. Never put a filesystem path or transport URL into a plan or task.