hyperstudy-perception 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
  2. hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
  3. hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
  4. hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
  5. hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. perception/__init__.py +0 -0
  7. perception/_pipeline/RELEASE.json +59 -0
  8. perception/_pipeline/cloud/runpod/guard.sh +37 -0
  9. perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  10. perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  11. perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  12. perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  13. perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  14. perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  15. perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  16. perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  17. perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  18. perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  19. perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  20. perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  21. perception/_pipeline/envs/api/pyproject.toml +12 -0
  22. perception/_pipeline/envs/api/uv.lock +276 -0
  23. perception/_pipeline/envs/audio/pyproject.toml +12 -0
  24. perception/_pipeline/envs/audio/uv.lock +575 -0
  25. perception/_pipeline/envs/core/pyproject.toml +12 -0
  26. perception/_pipeline/envs/core/uv.lock +157 -0
  27. perception/_pipeline/envs/faces/pyproject.toml +12 -0
  28. perception/_pipeline/envs/faces/uv.lock +1180 -0
  29. perception/_pipeline/graphs/decode.yaml +11 -0
  30. perception/_pipeline/graphs/faces.yaml +14 -0
  31. perception/_pipeline/graphs/full.yaml +31 -0
  32. perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  33. perception/_pipeline/graphs/mvp.yaml +17 -0
  34. perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  35. perception/_pipeline/nodes/audio/asr/node.py +74 -0
  36. perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  37. perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  38. perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  39. perception/_pipeline/nodes/conftest.py +64 -0
  40. perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  41. perception/_pipeline/nodes/core/decode/media.py +104 -0
  42. perception/_pipeline/nodes/core/decode/node.py +56 -0
  43. perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  44. perception/_pipeline/nodes/testdata/README.md +10 -0
  45. perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  46. perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  47. perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  48. perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  49. perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  50. perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  51. perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  52. perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  53. perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  54. perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  55. perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  56. perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  57. perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  58. perception/_pipeline/schema/README.md +54 -0
  59. perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  60. perception/_pipeline/sdk/pyproject.toml +13 -0
  61. perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  62. perception/artifacts.py +180 -0
  63. perception/backends/__init__.py +0 -0
  64. perception/backends/base.py +179 -0
  65. perception/backends/cloud.py +214 -0
  66. perception/backends/native.py +140 -0
  67. perception/backends/probe.py +44 -0
  68. perception/backends/remote.py +140 -0
  69. perception/cli.py +780 -0
  70. perception/estimates.py +68 -0
  71. perception/export.py +100 -0
  72. perception/hosts.py +201 -0
  73. perception/ir.py +245 -0
  74. perception/keys.py +259 -0
  75. perception/manifests.py +169 -0
  76. perception/plan.py +170 -0
  77. perception/release.py +61 -0
  78. perception/remote_driver.py +990 -0
  79. perception/runpod.py +100 -0
  80. perception/scheduler.py +552 -0
  81. perception/setup.py +72 -0
  82. perception/store.py +219 -0
@@ -0,0 +1,13 @@
1
+ [project]
2
+ name = "perception-node"
3
+ version = "0.1.0"
4
+ description = "Node side of the perception graph's file protocol (standard library only)"
5
+ requires-python = ">=3.10"
6
+ dependencies = []
7
+
8
+ [build-system]
9
+ requires = ["hatchling"]
10
+ build-backend = "hatchling.build"
11
+
12
+ [tool.hatch.build.targets.wheel]
13
+ packages = ["src/perception_node"]
@@ -0,0 +1,157 @@
1
+ """The node side of the perception graph's file protocol. Standard library only: it is installed into every model
2
+ environment, which share nothing else.
3
+
4
+ The coordinator makes a fresh attempt directory, writes request.json into it and runs the node with
5
+ `--request PATH`. The node reads its inputs (paths), writes outputs under the request's out_dir, and finishes by
6
+ writing response.json next to the request. The coordinator then validates, hashes and publishes the outputs; a node
7
+ never writes into the artifact store, so a node that crashes or is orphaned cannot corrupt it.
8
+
9
+ from perception_node import Node, NodeError
10
+
11
+ with Node() as node:
12
+ video = node.input("video") # a Path
13
+ shots = detect(video, node.config["threshold"])
14
+ node.write_json("shots", "shots.json", shots)
15
+ node.model("PySceneDetect AdaptiveDetector", licence_mode="commercial-ok", licence="BSD-3-Clause")
16
+
17
+ Errors: raise NodeError(code, message). Codes: transient (worth retrying: a network error, a busy device),
18
+ input (the inputs cannot be processed), schema, privacy, licence (terminal). Any other exception is reported as
19
+ `internal` with its traceback, and is terminal: retrying a bug does not fix it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import platform
28
+ import sys
29
+ import time
30
+ import traceback
31
+ from pathlib import Path
32
+
33
+ PROTOCOL = 1
34
+ # resources: the node ran out of memory (GPU or RAM). Terminal like the others: the same input on the same device would
35
+ # run out again, so the fix is a smaller memory setting in the graph's config, or another device.
36
+ ERROR_CODES = ("transient", "input", "schema", "privacy", "licence", "resources", "internal")
37
+ MEMORY_SETTINGS = ("batch_size",) # config keys that trade memory for speed, named in the message
38
+
39
+
40
+ def out_of_memory(exc: BaseException) -> bool:
41
+ """PyTorch's OutOfMemoryError (CUDA), MPS's RuntimeError, and Python's MemoryError."""
42
+ return (isinstance(exc, MemoryError) or type(exc).__name__ == "OutOfMemoryError"
43
+ or (isinstance(exc, RuntimeError) and "out of memory" in str(exc).lower()))
44
+
45
+
46
+ class NodeError(Exception):
47
+ def __init__(self, code: str, message: str):
48
+ if code not in ERROR_CODES:
49
+ raise ValueError(f"unknown error code {code!r}")
50
+ super().__init__(message)
51
+ self.code = code
52
+
53
+
54
+ class Node:
55
+ def __init__(self, argv: list[str] | None = None):
56
+ parser = argparse.ArgumentParser()
57
+ parser.add_argument("--request", type=Path, required=True)
58
+ args = parser.parse_args(argv)
59
+ self.request_path: Path = args.request
60
+ self.request: dict = json.loads(self.request_path.read_text())
61
+ if self.request.get("protocol") != PROTOCOL:
62
+ raise SystemExit(f"request protocol {self.request.get('protocol')} is not {PROTOCOL}")
63
+ self.out_dir = Path(self.request["out_dir"])
64
+ self.out_dir.mkdir(parents=True, exist_ok=True)
65
+ self.config: dict = self.request.get("config", {})
66
+ self.accelerator: str = self.request.get("accelerator", "cpu") # cpu | mps | cuda: where placement put this run
67
+ self._outputs: dict[str, dict] = {}
68
+ self._models: list[dict] = []
69
+ self._usage: dict = {}
70
+ self._t0 = time.time()
71
+
72
+ # --- inputs ---------------------------------------------------------------------------------------------
73
+
74
+ def input(self, name: str) -> Path:
75
+ try:
76
+ return Path(self.request["inputs"][name]["path"])
77
+ except KeyError:
78
+ raise NodeError("input", f"no input {name!r} in the request") from None
79
+
80
+ def input_json(self, name: str):
81
+ return json.loads(self.input(name).read_text())
82
+
83
+ def secret(self, name: str) -> str:
84
+ """A secret the request names, from the environment the coordinator gave this process."""
85
+ if name not in self.request.get("secrets", []):
86
+ raise NodeError("input", f"secret {name} was not requested by this node's manifest")
87
+ value = os.environ.get(name)
88
+ if not value:
89
+ raise NodeError("input", f"secret {name} is not set")
90
+ return value
91
+
92
+ # --- outputs ---------------------------------------------------------------------------------------------
93
+
94
+ def path(self, name: str, relative: str) -> Path:
95
+ """Where to write output `name` (a file, or a directory for tree outputs). Declares the output."""
96
+ p = self.out_dir / relative
97
+ p.parent.mkdir(parents=True, exist_ok=True)
98
+ self._outputs[name] = {"path": relative}
99
+ return p
100
+
101
+ def write_json(self, name: str, relative: str, doc) -> Path:
102
+ p = self.path(name, relative)
103
+ p.write_text(json.dumps(doc, separators=(",", ":")))
104
+ return p
105
+
106
+ def producer(self, timings: dict | None = None, device: str | None = None) -> dict:
107
+ """The bundle layer `producer` block: stage and models from the manifest, and the device.
108
+
109
+ Deliberately without the run time or timings: a stored layer must be the same bytes whenever the same inputs
110
+ produce the same data, or every downstream node would miss the cache after an upstream rerun. Timings go to
111
+ the response's usage; `perception export` writes run_at and timings into the bundle's copy of the layer."""
112
+ if timings:
113
+ self._usage.setdefault("timings", {}).update(timings)
114
+ base = self.request.get("producer", {})
115
+ return {**base, "models": self._models or base.get("models", []), "config": self.config,
116
+ "device": device, "run_at": None, "timings": {}}
117
+
118
+ def model(self, id: str, licence_mode: str, source: str | None = None, revision: str | None = None, licence: str | None = None):
119
+ """Record a model this run used (the coordinator checks it against the manifest's licence mode)."""
120
+ self._models.append({"id": id, "source": source, "revision": revision, "licence": licence, "licence_mode": licence_mode})
121
+
122
+ def usage(self, **values):
123
+ """Billable or measured usage: seconds, tokens_in, tokens_out, usd, device..."""
124
+ self._usage.update(values)
125
+
126
+ # --- lifecycle -------------------------------------------------------------------------------------------
127
+
128
+ def __enter__(self) -> "Node":
129
+ return self
130
+
131
+ def __exit__(self, exc_type, exc, tb) -> bool:
132
+ error = None
133
+ if exc is not None:
134
+ if isinstance(exc, NodeError):
135
+ error = {"code": exc.code, "message": str(exc)}
136
+ elif isinstance(exc, KeyboardInterrupt):
137
+ error = {"code": "transient", "message": "interrupted"}
138
+ elif out_of_memory(exc):
139
+ knobs = [f"{k} (now {self.config[k]})" for k in MEMORY_SETTINGS if k in self.config]
140
+ hint = f"lower {' or '.join(knobs)} in the graph's config for this node" if knobs else "run it on a device with more memory"
141
+ error = {"code": "resources", "message": f"out of memory on {self.accelerator}: {exc}. To fix: {hint}.",
142
+ "traceback": traceback.format_exc()}
143
+ else:
144
+ error = {"code": "internal", "message": f"{exc_type.__name__}: {exc}", "traceback": traceback.format_exc()}
145
+ response = {
146
+ "protocol": PROTOCOL, "ok": error is None, "outputs": self._outputs if error is None else {},
147
+ "models": self._models, "usage": {"seconds": round(time.time() - self._t0, 3), **self._usage},
148
+ "host": {"platform": sys.platform, "machine": platform.machine(), "python": platform.python_version()},
149
+ "error": error,
150
+ }
151
+ tmp = self.request_path.with_name("response.json.tmp")
152
+ tmp.write_text(json.dumps(response, indent=1))
153
+ os.replace(tmp, self.request_path.with_name("response.json")) # the coordinator never sees half a response
154
+ if error:
155
+ print(f"node failed ({error['code']}): {error['message']}", file=sys.stderr)
156
+ sys.exit(1)
157
+ return False
@@ -0,0 +1,180 @@
1
+ """Artifact types: what flows between nodes, and how a node's output is checked before it is published.
2
+
3
+ The bundle's layer schemas are artifact types (`bundle.<layer>@0.1`): a layer a node writes is exactly the layer the
4
+ annotator and HyperStudy read. A few internal types carry what bundles do not (media metadata, audio, keyframes).
5
+ A type is a file or a tree (a directory); its validator returns a list of problems (empty: valid).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from dataclasses import dataclass
13
+ from functools import cache
14
+ from pathlib import Path
15
+ from typing import Callable, Literal
16
+
17
+ from jsonschema import Draft202012Validator
18
+ from referencing import Registry, Resource
19
+
20
+ from .release import REPO
21
+
22
+ SCHEMA_DIR = REPO / "schema"
23
+ BUNDLE_SCHEMA = SCHEMA_DIR / "bundle-0.1.schema.json"
24
+ BUNDLE_LAYERS = ("shots", "transcript", "speakers", "faces", "bodies", "hands", "gestures", "facing", "gaze", "objects",
25
+ "sounds", "text", "prosody", "waveform")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ArtifactType:
30
+ id: str
31
+ shape: Literal["file", "tree", "source"] # source: an input that stays where it is (the video), never copied
32
+ validate: Callable[[Path], list[str]]
33
+ layer: str | None = None # the bundle layer this type is, if any
34
+ # the kinds of data it holds, in policy.allow_upload's terms: what leaves the machine when a node that reads it runs
35
+ # elsewhere (a lab server, a cloud machine). None: anything (a JSON or text file could hold any of them)
36
+ kinds: tuple[str, ...] | None = None
37
+
38
+
39
+ @cache
40
+ def _bundle_schema():
41
+ schema = json.loads(BUNDLE_SCHEMA.read_text())
42
+ return schema, Registry().with_resource(schema["$id"], Resource.from_contents(schema))
43
+
44
+
45
+ def _json_schema(schema: dict) -> Callable[[Path], list[str]]:
46
+ validator = Draft202012Validator(schema)
47
+
48
+ def check(path: Path) -> list[str]:
49
+ try:
50
+ doc = json.loads(path.read_text())
51
+ except (OSError, ValueError) as e:
52
+ return [f"not JSON: {e}"]
53
+ return [f"{e.json_path}: {e.message[:200]}" for e in list(validator.iter_errors(doc))[:20]]
54
+ return check
55
+
56
+
57
+ def _layer(name: str) -> Callable[[Path], list[str]]:
58
+ def check(path: Path) -> list[str]:
59
+ schema, registry = _bundle_schema()
60
+ try:
61
+ doc = json.loads(path.read_text())
62
+ except (OSError, ValueError) as e:
63
+ return [f"not JSON: {e}"]
64
+ validator = Draft202012Validator({"$ref": f"{schema['$id']}#/$defs/layer/{name}"}, registry=registry)
65
+ problems = [f"{e.json_path}: {e.message[:200]}" for e in list(validator.iter_errors(doc))[:20]]
66
+ if doc.get("layer") != name:
67
+ problems.append(f"layer is {doc.get('layer')!r}, not {name!r}")
68
+ return problems
69
+ return check
70
+
71
+
72
+ def _wav(path: Path) -> list[str]:
73
+ with open(path, "rb") as f:
74
+ head = f.read(12)
75
+ return [] if head[:4] == b"RIFF" and head[8:12] == b"WAVE" else ["not a RIFF/WAVE file"]
76
+
77
+
78
+ def _tree_of(suffixes: tuple[str, ...]) -> Callable[[Path], list[str]]:
79
+ def check(path: Path) -> list[str]:
80
+ if not path.is_dir():
81
+ return ["not a directory"]
82
+ files = [p for p in path.rglob("*") if p.is_file()]
83
+ bad = [str(p.relative_to(path)) for p in files if p.suffix.lower() not in suffixes and p.name != "index.json"]
84
+ return [f"unexpected files: {bad[:5]}"] if bad else []
85
+ return check
86
+
87
+
88
+ MEDIA_SCHEMA = {
89
+ "type": "object",
90
+ "required": ["sha256", "duration", "width", "height", "fps", "time_base", "variable_frame_rate"],
91
+ "properties": {
92
+ "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"},
93
+ "duration": {"type": "number", "exclusiveMinimum": 0},
94
+ "width": {"type": "integer"}, "height": {"type": "integer"},
95
+ "fps": {"type": "number", "exclusiveMinimum": 0, "description": "average frame rate"},
96
+ "r_frame_rate": {"type": "string"}, "avg_frame_rate": {"type": "string"},
97
+ "time_base": {"type": "string", "description": "the video stream's PTS time base, e.g. 1/30000"},
98
+ "start_pts": {"type": ["integer", "null"]},
99
+ "variable_frame_rate": {"type": "boolean"},
100
+ "audio": {"type": ["object", "null"]},
101
+ "filename": {"type": "string"},
102
+ },
103
+ }
104
+
105
+ # What each layer can hold. Shots carry captions, which describe and name the people on screen; prosody measures the
106
+ # audio and carries each utterance's words; the waveform is a measure of the audio. Every layer is listed: a new one must say what it holds.
107
+ LAYER_KINDS: dict[str, tuple[str, ...]] = {
108
+ "shots": ("transcript", "faces"), "transcript": ("transcript",), "speakers": ("transcript",), "text": ("transcript",),
109
+ "faces": ("faces", "embeddings"), "bodies": (), "hands": (), "gestures": (), "facing": (), "gaze": (), "objects": (),
110
+ "sounds": (), "prosody": ("audio", "transcript"), "waveform": ("audio",),
111
+ }
112
+ assert set(LAYER_KINDS) == set(BUNDLE_LAYERS)
113
+
114
+ TYPES: dict[str, ArtifactType] = {
115
+ "video.v1": ArtifactType("video.v1", "source", lambda p: [] if p.is_file() else ["missing"], kinds=("video",)),
116
+ "media.v1": ArtifactType("media.v1", "file", _json_schema(MEDIA_SCHEMA), kinds=()), # the video's format and timing
117
+ "audio.wav.v1": ArtifactType("audio.wav.v1", "file", _wav, kinds=("audio",)),
118
+ # small images (keyframes, crops) a layer refers to as "assets/<path>"; export merges every assets.v1 tree into the
119
+ # bundle's assets/ directory. Either kind of image: both kinds are counted
120
+ "assets.v1": ArtifactType("assets.v1", "tree", _tree_of((".jpg", ".jpeg", ".png", ".webp")), kinds=("frames", "faces")),
121
+ "json.v1": ArtifactType("json.v1", "file", _json_schema({})),
122
+ "text.v1": ArtifactType("text.v1", "file", lambda p: []),
123
+ **{f"bundle.{name}@0.1": ArtifactType(f"bundle.{name}@0.1", "file", _layer(name), layer=name, kinds=LAYER_KINDS[name])
124
+ for name in BUNDLE_LAYERS},
125
+ }
126
+
127
+
128
+ def get(type_id: str) -> ArtifactType:
129
+ if type_id not in TYPES:
130
+ raise KeyError(f"unknown artifact type {type_id}")
131
+ return TYPES[type_id]
132
+
133
+
134
+ # --- content hashes ---------------------------------------------------------------------------------------------
135
+
136
+ def sha256_file(path: Path) -> str:
137
+ h = hashlib.sha256()
138
+ with open(path, "rb") as f:
139
+ for chunk in iter(lambda: f.read(1 << 20), b""):
140
+ h.update(chunk)
141
+ return h.hexdigest()
142
+
143
+
144
+ def sha256_tree(path: Path) -> str:
145
+ """A directory's hash: over its sorted relative paths and their file hashes (so renames and edits both count)."""
146
+ h = hashlib.sha256()
147
+ for p in sorted(x for x in path.rglob("*") if x.is_file()):
148
+ h.update(str(p.relative_to(path).as_posix()).encode() + b"\0" + sha256_file(p).encode() + b"\n")
149
+ return h.hexdigest()
150
+
151
+
152
+ def special_files(path: Path) -> list[str]:
153
+ """Entries that are not plain files or directories (symlinks, sockets, devices), found without following links.
154
+ Outputs must have none: publishing a symlink would copy whatever it points at on the host."""
155
+ import os
156
+ import stat
157
+ bad = []
158
+ st = os.lstat(path)
159
+ if stat.S_ISLNK(st.st_mode):
160
+ return [f"{path.name} (symlink)"]
161
+ if stat.S_ISDIR(st.st_mode):
162
+ for root, dirs, files in os.walk(path, followlinks=False):
163
+ for name in dirs + files:
164
+ mode = os.lstat(os.path.join(root, name)).st_mode
165
+ if stat.S_ISLNK(mode):
166
+ bad.append(f"{os.path.relpath(os.path.join(root, name), path)} (symlink)")
167
+ elif not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
168
+ bad.append(f"{os.path.relpath(os.path.join(root, name), path)} (not a regular file)")
169
+ elif not stat.S_ISREG(st.st_mode):
170
+ bad.append(f"{path.name} (not a regular file)")
171
+ return bad
172
+
173
+
174
+ def content_hash(path: Path) -> str:
175
+ return sha256_tree(path) if path.is_dir() else sha256_file(path)
176
+
177
+
178
+ def canonical(value) -> str:
179
+ """JSON with sorted keys and no whitespace: equal values hash equal."""
180
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
File without changes
@@ -0,0 +1,179 @@
1
+ """What every execution backend provides. The graph never names a provider; placement picks a backend whose runner
2
+ fits the node, and the backend turns the file protocol (request.json in, out/ + response.json back) into a process,
3
+ a remote job or an API call.
4
+
5
+ probe_capabilities() what this backend can run here (platform, accelerators, memory, tools)
6
+ runner_for(manifest) the manifest runner this backend would use, or why none fits
7
+ identity(manifest, r) what makes this runner's results this runner's (env lock, code, SDK): cache key
8
+ estimate(...) cost and time, before anything is reserved
9
+ stage_inputs(...) make the inputs readable where the node runs; returns the request's input entries
10
+ submit(...) start it; returns a JSON-able handle (kept in the run state, so a restarted coordinator
11
+ can find the job again)
12
+ poll(handle) running | exited
13
+ cancel(handle)
14
+ collect(attempt_dir) the node's response.json, or None if it never wrote one (a crash)
15
+ cleanup(attempt_dir)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import platform
23
+ import re
24
+ import stat
25
+ from abc import ABC, abstractmethod
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+
29
+ from ..manifests import Manifest, Runner
30
+ from ..store import Artifact
31
+
32
+
33
+ @dataclass
34
+ class Capabilities:
35
+ platform: str # darwin/arm64, linux/amd64, windows/amd64...
36
+ cpu_cores: int
37
+ memory_gb: float
38
+ accelerators: set[str] # cpu, mps, cuda
39
+ vram_gb: float = 0 # CUDA: total; Apple GPU: unified memory (memory_gb)
40
+ tools: dict[str, str | None] = field(default_factory=dict) # uv, ffmpeg, ffprobe -> path or None
41
+
42
+
43
+ @dataclass
44
+ class Estimate:
45
+ cost_usd: float # 0 for local runs; a range's upper end for billable ones
46
+ seconds: float | None = None # None: not known before a first run
47
+
48
+
49
+ def this_platform() -> str:
50
+ machine = platform.machine().lower()
51
+ arch = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"}.get(machine, machine)
52
+ system = {"Darwin": "darwin", "Linux": "linux", "Windows": "windows"}.get(platform.system(), platform.system().lower())
53
+ return f"{system}/{arch}"
54
+
55
+
56
+ class Backend(ABC):
57
+ kind: str # names the backend in a place (`kind:accelerator`): unique among a coordinator's backends
58
+ runner_kind: str = "" # the manifest runners it runs (default: its kind); a remote machine runs `native` ones
59
+ remote = False # runs elsewhere: a node's inputs leave this machine to reach it
60
+ title = "This machine" # where it is, for people: "gpu1 (SSH)", "Cloud GPU (AWS L4)"
61
+
62
+ @property
63
+ def machine(self) -> str | None:
64
+ """Which machine's CPU, memory and GPU a node placed here shares with other nodes; None when every node gets a
65
+ machine of its own (a cloud machine started for it)."""
66
+ return "local"
67
+
68
+ def sends(self, manifest: Manifest) -> list[str]:
69
+ """The kinds of data that leave this machine when the node runs here: its inputs', for a remote machine (every
70
+ kind, for an input that could hold anything)."""
71
+ if not self.remote:
72
+ return []
73
+ from ..artifacts import get
74
+ from ..ir import UPLOAD_KINDS
75
+ kinds = [get(t).kinds for t in manifest.inputs.values()]
76
+ return sorted(UPLOAD_KINDS) if None in kinds else sorted({k for ks in kinds for k in ks})
77
+
78
+ def remote_refusal(self, manifest: Manifest) -> str:
79
+ """Why a node may not run on this remote machine at all, or "" (local backends: never)."""
80
+ return ""
81
+
82
+ @abstractmethod
83
+ def probe_capabilities(self) -> Capabilities: ...
84
+
85
+ def runner_for(self, manifest: Manifest) -> tuple[Runner | None, str]:
86
+ """The first of the manifest's runners this backend can use here, preferring an accelerated one."""
87
+ runners, why = self.runners_for(manifest)
88
+ return (runners[0], "") if runners else (None, why)
89
+
90
+ def runners_for(self, manifest: Manifest) -> tuple[list[Runner], str]:
91
+ """Every runner of the manifest this backend can use here, accelerated ones first; why none, when none."""
92
+ caps = self.probe_capabilities()
93
+ reasons, usable = [], []
94
+ kind = self.runner_kind or self.kind
95
+ mine = [r for r in manifest.runners if r.kind == kind]
96
+ if not mine:
97
+ return [], f"no {kind} runner"
98
+ if refusal := self.remote_refusal(manifest):
99
+ return [], refusal
100
+ for r in sorted(mine, key=lambda r: r.accelerator == "cpu"):
101
+ if caps.platform not in r.platforms:
102
+ reasons.append(f"{r.accelerator} runner is for {', '.join(r.platforms)}, not {caps.platform}")
103
+ elif r.accelerator not in caps.accelerators:
104
+ reasons.append(f"{r.accelerator} runner needs {r.accelerator}, not available here")
105
+ elif r.minimum_vram_gb > caps.vram_gb:
106
+ reasons.append(f"{r.accelerator} runner needs {r.minimum_vram_gb} GB VRAM, {caps.vram_gb:.0f} here")
107
+ else:
108
+ missing = self.missing_tools(r)
109
+ if missing:
110
+ reasons.append(f"{', '.join(missing)} not installed")
111
+ else:
112
+ usable.append(r)
113
+ return usable, "" if usable else "; ".join(reasons)
114
+
115
+ def missing_tools(self, runner: Runner) -> list[str]:
116
+ return []
117
+
118
+ @abstractmethod
119
+ def identity(self, manifest: Manifest, runner: Runner) -> str: ...
120
+
121
+ def estimate(self, manifest: Manifest, runner: Runner, inputs: dict[str, Artifact], video_s: float | None = None) -> Estimate:
122
+ """`video_s`: the video's length when the caller knows it (a plan, before decode has run)."""
123
+ return Estimate(0.0)
124
+
125
+ @abstractmethod
126
+ def stage_inputs(self, attempt_dir: Path, inputs: dict[str, Artifact]) -> dict[str, dict]: ...
127
+
128
+ @abstractmethod
129
+ def submit(self, manifest: Manifest, runner: Runner, attempt_dir: Path, env: dict[str, str]) -> dict: ...
130
+
131
+ @abstractmethod
132
+ def poll(self, handle: dict) -> str: ...
133
+
134
+ @abstractmethod
135
+ def cancel(self, handle: dict) -> None: ...
136
+
137
+ def work_dir(self, attempt_dir: Path) -> Path:
138
+ """Where the node itself reads and writes (its request, out/, response.json)."""
139
+ return attempt_dir
140
+
141
+ def output_dir(self, attempt_dir: Path) -> Path:
142
+ return self.work_dir(attempt_dir) / "out"
143
+
144
+ def collect(self, attempt_dir: Path) -> dict | None:
145
+ """The node's response, read only if it is a regular file: the node controls this directory, and a symlink
146
+ here could point anywhere."""
147
+ path = self.work_dir(attempt_dir) / "response.json"
148
+ try:
149
+ st = os.lstat(path)
150
+ except FileNotFoundError:
151
+ return None
152
+ if not stat.S_ISREG(st.st_mode):
153
+ return None
154
+ return json.loads(path.read_text())
155
+
156
+ def cleanup(self, attempt_dir: Path) -> None:
157
+ return None
158
+
159
+ def recorded_handle(self, attempt_dir: Path) -> dict | None:
160
+ """The handle `submit` recorded in the attempt's folder as soon as the node started, if it did: a coordinator that
161
+ died before recording the task running leaves that node to be adopted, not started a second time."""
162
+ return None
163
+
164
+
165
+ # --- the child's environment: no credentials it did not ask for -------------------------------------------------
166
+
167
+ SECRET_NAME = re.compile(r"(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)", re.I)
168
+ # the coordinator's own interpreter settings: a node runs in its own environment, never the coordinator's
169
+ INTERPRETER_VARS = {"VIRTUAL_ENV", "PYTHONPATH", "PYTHONHOME", "UV_PROJECT_ENVIRONMENT", "CONDA_PREFIX"}
170
+
171
+
172
+ def child_env(requested: list[str], secrets: dict[str, str]) -> dict[str, str]:
173
+ """The parent's environment without anything that looks like a credential, plus the secrets the node's manifest
174
+ names. A node never sees a key it did not declare."""
175
+ env = {k: v for k, v in os.environ.items() if not SECRET_NAME.search(k) and k not in INTERPRETER_VARS}
176
+ for name in requested:
177
+ if name in secrets:
178
+ env[name] = secrets[name]
179
+ return env