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,214 @@
1
+ """Cloud runs: a node on a cloud GPU machine started for it and deleted after it (`perception cloud on`).
2
+
3
+ The machine is an SSH host for one attempt: the same driver (remote_driver.py) runs the node there, after first
4
+ starting the machine through SkyPilot with this repository's project config (cloud/skypilot/sky.yaml: no laptop
5
+ credentials on the machine, and only the perception-node role, which can end perception-* machines and nothing else).
6
+ Before anything is sent the driver checks the machine (guard.sh: that role, no AWS credentials, and a shutdown
7
+ deadline), and whatever happens it deletes the machine (`sky down`). Backstops if the laptop never comes back: the
8
+ machine's shutdown deadline (an OS shutdown terminates it), and SkyPilot's autodown once idle (no jobs, no SSH).
9
+
10
+ A cloud place costs money: reserved before it starts at the hourly price for the whole deadline (the most it can run),
11
+ and charged for the time the machine existed. The deadline is 20 minutes (starting the machine, installing the node's
12
+ environment) plus the video's length, at least 30 minutes: a GPU node slower than real time on an L4 is not expected
13
+ (the face node ran at ~4x). Only CUDA runners are offered: the machine is a GPU machine.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ import shutil
24
+ from pathlib import Path
25
+
26
+ from ..hosts import Host
27
+ from ..manifests import Manifest, Runner
28
+ from ..store import Artifact
29
+ from .base import Capabilities, Estimate
30
+ from .native import REPO
31
+ from .remote import SshBackend
32
+
33
+ SKYPILOT = REPO / "cloud" / "skypilot"
34
+
35
+
36
+ class CloudBackend(SshBackend):
37
+ """One machine type (an L4 with 8 vCPUs: the face node's fastest measured setup, perception #4) in one region."""
38
+ task = SKYPILOT / "node-gpu.yaml"
39
+ project_config = SKYPILOT / "sky.yaml"
40
+ guard = SKYPILOT / "guard.sh"
41
+ setup = SKYPILOT / "node-setup.sh"
42
+ region = "us-east-1"
43
+ machine_title = "AWS g6.2xlarge, NVIDIA L4"
44
+ # on-demand, us-east-1, from SkyPilot's AWS catalogue on 2026-10-23 (spot had no L4 capacity there then)
45
+ hourly_usd = 0.9776
46
+ # what placement checks runners against; the driver probes the real machine (its tools come from node-gpu.yaml's setup)
47
+ caps = Capabilities(platform="linux/amd64", cpu_cores=8, memory_gb=32, accelerators={"cpu", "cuda"}, vram_gb=22)
48
+
49
+ def __init__(self, deadline_s: float | None = None, runner_kind: str = "native", python: str | None = None,
50
+ launch_s: float = 15 * 60, down_s: float = 15 * 60, aws_s: float = 120, **paths):
51
+ """`deadline_s` fixes the deadline whatever the video (tests); by default it follows the video's length.
52
+ `launch_s`: how long starting the machine (and installing ffmpeg and uv on it) may take: within the deadline's
53
+ 20 minutes for starting, which also cover installing the node's environment."""
54
+ self.launch_s, self.down_s, self.aws_s = launch_s, down_s, aws_s # how long sky down, and an EC2 call, may take
55
+ host = Host("cloud", "(started for each node)", "", self.caps, 0)
56
+ super().__init__(host, runner_kind=runner_kind, python=python)
57
+ self.kind = "cloud"
58
+ self.title = "Cloud GPU (AWS L4)" # short: it names the place in menus (`cloud status` gives the machine)
59
+ self.deadline_s = deadline_s
60
+ for name, value in paths.items(): # tests: stand-in task, guard
61
+ setattr(self, name, value)
62
+
63
+ @property
64
+ def machine(self) -> str | None:
65
+ return None # a machine of its own for every node
66
+
67
+ def runners_for(self, manifest: Manifest) -> tuple[list[Runner], str]:
68
+ runners, why = super().runners_for(manifest)
69
+ gpu = [r for r in runners if r.accelerator == "cuda"]
70
+ return gpu, "" if gpu else why or "it has no CUDA runner (a cloud machine is started only for GPU work)"
71
+
72
+ def missing_tools(self, runner: Runner) -> list[str]:
73
+ return [] # the machine's setup installs them
74
+
75
+ def deadline(self, video_s: float | None) -> float:
76
+ if self.deadline_s is not None:
77
+ return self.deadline_s
78
+ if video_s is None:
79
+ return 60 * 60
80
+ # the plan (estimates.probe_seconds) and the run (decode's media.v1) read the same length, the container's
81
+ # duration through PyAV, so they reserve the same; whole 5 minutes keep the price a round figure
82
+ return max(30 * 60, math.ceil((20 * 60 + video_s) / 300) * 300)
83
+
84
+ def estimate(self, manifest: Manifest, runner: Runner, inputs: dict[str, Artifact], video_s: float | None = None) -> Estimate:
85
+ # the most it can cost: the machine runs at most until its deadline
86
+ video_s = video_s or video_seconds((a.type, a.path) for a in inputs.values())
87
+ return Estimate(round(self.hourly_usd * self.deadline(video_s) / 3600, 4))
88
+
89
+ def driver_spec(self, attempt_dir: Path) -> dict:
90
+ request = json.loads((attempt_dir / "request.json").read_text())
91
+ return {"host": "the cloud machine", "cloud": {
92
+ "cluster": cluster_name(request["run"], request["node"], request["attempt"]),
93
+ "task": str(self.task), "project_config": str(self.project_config), "guard": str(self.guard), "setup": str(self.setup),
94
+ "region": self.region, "hourly_usd": self.hourly_usd, "launch_s": self.launch_s, "down_s": self.down_s, "aws_s": self.aws_s,
95
+ "deadline_s": self.deadline(video_seconds((e["type"], Path(e["path"])) for e in request["inputs"].values())),
96
+ "sky": os.environ.get("PERCEPTION_SKY") or shutil.which("sky") or "sky",
97
+ "aws": os.environ.get("PERCEPTION_AWS") or shutil.which("aws") or "aws"}}
98
+
99
+ def submit(self, manifest: Manifest, runner: Runner, attempt_dir: Path, env: dict[str, str]) -> dict:
100
+ # SkyPilot and the AWS CLI need the user's cloud credentials, which child_env keeps from nodes: the driver is
101
+ # this package's code, and passes them to no node (the node runs on the machine, with sshd's environment)
102
+ env = {**env, **{k: v for k, v in os.environ.items() if k.startswith(("AWS_", "SKYPILOT_"))}}
103
+ return super().submit(manifest, runner, attempt_dir, env)
104
+
105
+
106
+ def video_seconds(inputs) -> float | None:
107
+ """The video's length, from a node's inputs as (type, path): its media.v1 (decode's), else the video itself."""
108
+ inputs = list(inputs)
109
+ for type_id, path in inputs:
110
+ if type_id == "media.v1":
111
+ return float(json.loads(Path(path).read_text())["duration"])
112
+ for type_id, path in inputs:
113
+ if type_id == "video.v1":
114
+ from ..estimates import probe_seconds
115
+ return probe_seconds(Path(path))
116
+ return None
117
+
118
+
119
+ def cluster_name(run_id: str, node: str, attempt: int) -> str:
120
+ """perception-<run>-<node>-<hash>-<attempt>: the perception-node role can end only perception-* machines, and
121
+ SkyPilot names are lower-case letters, digits and hyphens. The hash of the whole node id keeps two nodes whose
122
+ shortened names agree (face_track_a, face-track-a) on machines of their own."""
123
+ short = re.sub(r"[^a-z0-9-]", "-", node.lower()).strip("-")[:10] or "node"
124
+ return f"perception-{run_id.rsplit('-', 1)[-1]}-{short}-{hashlib.sha256(node.encode()).hexdigest()[:6]}-{attempt}"
125
+
126
+
127
+ class RunPodBackend(CloudBackend):
128
+ """A RunPod pod (Secure Cloud, in the US, Canada or Europe) for each attempt: an account, credit and an API key are all it needs.
129
+
130
+ Any of several GPUs with at least 20 GB, the cheapest available first: RunPod's capacity for any one type comes and
131
+ goes. A pod is reached over SSH with a key made for its attempt, holds the node's inputs on its own disk only (no
132
+ network volume: nothing stays after it is deleted), and is deleted through the API by the driver. Its backstops if
133
+ this laptop never comes back: the pod removes itself at its deadline (its start command sets that before anything
134
+ else runs; guard.sh checks it), and the next RunPod launch deletes any perception pod past its deadline (the
135
+ deadline is in its name)."""
136
+ guard = REPO / "cloud" / "runpod" / "guard.sh"
137
+ # perception's own image (cloud/runpod/image): RunPod's cached PyTorch image with ffmpeg, rsync, uv and runpodctl 3
138
+ # added; software only. Its tag is tag.py's, from what goes into it (a test checks they agree)
139
+ image_tag = "e92ef7a71b30"
140
+ # and the digest of the build that was measured: a rebuild under the same tag (apt's versions move) is never pulled
141
+ image_digest = "sha256:09e6eb03f0ab44353e13b198e9f2b114b07c29af5613b33da42e2e122b7a74c4"
142
+ # under the repository's owner, where .github/workflows/runpod-image.yml pushes it (a test checks)
143
+ image = f"ghcr.io/hyperstudyio/perception-runpod:{image_tag}@{image_digest}"
144
+ start = "/perception-start.sh" # its own start (host keys, PUBLIC_KEY, sshd), run after the pod's removal is set
145
+ # Secure Cloud prices on 2026-10-24 (RunPod's gpuTypes securePrice), the order RunPod tries them in: the cheap ones
146
+ # first, the dearer ones when none of those is free (capacity for any one type comes and goes). Ampere, Ada and
147
+ # Blackwell (the nodes' torch is built for CUDA 13, which has Blackwell's kernels); not MIG slices of one.
148
+ gpus = {"NVIDIA RTX A4500": 0.25, "NVIDIA RTX A5000": 0.27, "NVIDIA RTX 4000 Ada Generation": 0.28,
149
+ "NVIDIA GeForce RTX 3090": 0.50, "NVIDIA RTX PRO 6000 Blackwell Max-Q Workstation Edition": 0.50,
150
+ "NVIDIA RTX PRO 4000 Blackwell": 0.57, "NVIDIA A40": 0.59, "NVIDIA L4": 0.59, "NVIDIA RTX A6000": 0.59,
151
+ "NVIDIA RTX PRO 4500 Blackwell": 0.72, "NVIDIA L40": 0.82,
152
+ "NVIDIA GeForce RTX 4090": 0.89, "NVIDIA RTX PRO 5000 Blackwell": 0.96, "NVIDIA RTX 6000 Ada Generation": 0.99,
153
+ "NVIDIA L40S": 1.09, "NVIDIA GeForce RTX 5090": 1.19}
154
+ # datacenters in the US, Canada and Europe, whichever has a GPU free: those RunPod's REST API accepts (its schema's
155
+ # enum, 2026-10-25; gpus above are in its enum too)
156
+ datacenters = ["US-CA-2", "US-DE-1", "US-GA-1", "US-GA-2", "US-IL-1", "US-KS-2", "US-KS-3", "US-MD-1", "US-MO-1", "US-MO-2",
157
+ "US-NC-1", "US-TX-1", "US-TX-3", "US-TX-4", "US-WA-1", "CA-MTL-1", "CA-MTL-2", "CA-MTL-3",
158
+ "EU-CZ-1", "EU-FR-1", "EU-NL-1", "EU-RO-1", "EU-SE-1", "EUR-IS-1", "EUR-IS-2", "EUR-IS-3", "EUR-NO-1"]
159
+ disk_gb = 40 # the container's disk: the node's environment, weights and inputs
160
+ # the nodes' torch is PyPI's CUDA 13 build (envs/faces/uv.lock): an NVIDIA driver of 580 or later (guard.sh)
161
+ min_driver = 580
162
+ region = "US, Canada, Europe (Secure Cloud)"
163
+ machine_title = "RunPod, one of " + ", ".join(g.removeprefix("NVIDIA ").removeprefix("GeForce ") for g in gpus)
164
+ hourly_usd = max(gpus.values()) # reserved at the dearest one it may get; charged at the price of the one it got
165
+ caps = Capabilities(platform="linux/amd64", cpu_cores=8, memory_gb=30, accelerators={"cpu", "cuda"}, vram_gb=20)
166
+
167
+ def __init__(self, api_key: str | None = None, api_s: float = 60, unknown_s: float = 180, **kw):
168
+ """`api_s`: how long one API call may take. `unknown_s`: how long a pod whose create got no clear answer is
169
+ looked for by its name before it is taken as never made (within down_s)."""
170
+ super().__init__(**kw)
171
+ self.api_s, self.unknown_s = api_s, unknown_s
172
+ self.kind = "runpod"
173
+ self.title = "Cloud GPU (RunPod)"
174
+ self.api_key = api_key
175
+
176
+ def driver_spec(self, attempt_dir: Path) -> dict:
177
+ from ..runpod import api_base
178
+ request = json.loads((attempt_dir / "request.json").read_text())
179
+ return {"host": "the RunPod pod", "cloud": {
180
+ "provider": "runpod", "name": cluster_name(request["run"], request["node"], request["attempt"]),
181
+ "api": api_base(), "image": self.image, "start": self.start, "gpus": list(self.gpus), "datacenters": self.datacenters,
182
+ "disk_gb": self.disk_gb, "min_driver": self.min_driver, "guard": str(self.guard), "setup": str(self.setup), "hourly_usd": self.hourly_usd,
183
+ "launch_s": self.launch_s, "down_s": self.down_s, "api_s": self.api_s, "unknown_s": self.unknown_s,
184
+ "deadline_s": self.deadline(video_seconds((e["type"], Path(e["path"])) for e in request["inputs"].values()))}}
185
+
186
+ def submit(self, manifest: Manifest, runner: Runner, attempt_dir: Path, env: dict[str, str]) -> dict:
187
+ # the account key for the driver alone (this package's code), in its environment, never in remote.json: it
188
+ # creates and deletes the pod from here, and sends the key nowhere
189
+ return SshBackend.submit(self, manifest, runner, attempt_dir, {**env, "RUNPOD_API_KEY": self.api_key or ""})
190
+
191
+
192
+ class Cloud:
193
+ """cloud.json in the store's home: which cloud places are offered (`perception cloud on [aws|runpod]`)."""
194
+ providers = ("aws", "runpod")
195
+
196
+ def __init__(self, home: Path):
197
+ self.path = home / "cloud.json"
198
+
199
+ def read(self) -> dict:
200
+ return json.loads(self.path.read_text()) if self.path.exists() else {}
201
+
202
+ @property
203
+ def enabled(self) -> bool: # AWS (the first cloud place: its key is "enabled")
204
+ return self.read().get("enabled", False)
205
+
206
+ @property
207
+ def runpod(self) -> bool:
208
+ return self.read().get("runpod", False)
209
+
210
+ def set(self, enabled: bool, provider: str = "aws"):
211
+ doc = self.read()
212
+ doc["enabled" if provider == "aws" else provider] = enabled
213
+ self.path.parent.mkdir(parents=True, exist_ok=True)
214
+ self.path.write_text(json.dumps(doc))
@@ -0,0 +1,140 @@
1
+ """Native runs: the node's script in its uv environment on this machine (kind `native`), or in the coordinator's own
2
+ interpreter (kind `self`, for tests and trivial nodes). This is the backend that reaches the Apple GPU: containers on
3
+ macOS cannot.
4
+
5
+ Each node runs in its own process group with its output going to the attempt's log.txt. The handle records the pid
6
+ and the request path, so a coordinator that restarts can tell whether an attempt is still running (and wait for it)
7
+ instead of starting a second copy.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import shutil
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ..manifests import Manifest, Runner
22
+ from ..store import Artifact
23
+ from .base import Backend, Capabilities
24
+ from .probe import probe
25
+
26
+ from ..release import REPO, env_vars
27
+
28
+
29
+ def sdk_hash() -> str:
30
+ h = hashlib.sha256()
31
+ for p in sorted((REPO / "sdk" / "src").rglob("*.py")):
32
+ h.update(p.read_bytes())
33
+ return h.hexdigest()
34
+
35
+
36
+ class NativeBackend(Backend):
37
+ kind = "native"
38
+
39
+ def __init__(self):
40
+ self._children: dict[int, subprocess.Popen] = {}
41
+ self._caps: Capabilities | None = None
42
+
43
+ def probe_capabilities(self) -> Capabilities:
44
+ if self._caps is None:
45
+ self._caps = probe()
46
+ return self._caps
47
+
48
+ def missing_tools(self, runner: Runner) -> list[str]:
49
+ return [] if self.probe_capabilities().tools.get("uv") else ["uv"]
50
+
51
+ def identity(self, manifest: Manifest, runner: Runner) -> str:
52
+ lock = REPO / runner.env / "uv.lock" if runner.env else None
53
+ h = hashlib.sha256(manifest.code_hash().encode())
54
+ h.update(sdk_hash().encode()) # the node SDK shapes what a node writes; it is installed editable, outside the lock
55
+ h.update(lock.read_bytes() if lock and lock.exists() else b"no lock")
56
+ h.update(runner.accelerator.encode())
57
+ return f"native:{h.hexdigest()[:16]}"
58
+
59
+ def stage_inputs(self, attempt_dir: Path, inputs: dict[str, Artifact]) -> dict[str, dict]:
60
+ # same machine: the node reads published objects in place (they are read-only)
61
+ return {name: {"type": a.type, "sha256": a.hash, "path": str(a.path)} for name, a in inputs.items()}
62
+
63
+ def command(self, manifest: Manifest, runner: Runner, request: Path) -> list[str]:
64
+ entry = str(manifest.directory / runner.entry)
65
+ if self.kind == "self":
66
+ return [sys.executable, entry, "--request", str(request)]
67
+ if not runner.env:
68
+ raise ValueError(f"{manifest.ref}: a native runner needs env (a uv project)")
69
+ return [shutil.which("uv") or "uv", "run", "--project", str(REPO / runner.env), "--frozen", "--no-dev",
70
+ "python", entry, "--request", str(request)]
71
+
72
+ def submit(self, manifest: Manifest, runner: Runner, attempt_dir: Path, env: dict[str, str]) -> dict:
73
+ request = attempt_dir / "request.json"
74
+ log = open(attempt_dir / "log.txt", "ab")
75
+ env = {**env, **(env_vars(runner.env) if self.kind == "native" and runner.env else {})} # the package's: in the store
76
+ proc = subprocess.Popen(self.command(manifest, runner, request), cwd=manifest.directory, env=env,
77
+ stdout=log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
78
+ start_new_session=True) # its own process group: cancel stops the whole tree
79
+ log.close()
80
+ self._children[proc.pid] = proc
81
+ handle = {"pid": proc.pid, "request": str(request)}
82
+ tmp = attempt_dir / "handle.json.tmp" # at once, before the coordinator records it: see recorded_handle
83
+ tmp.write_text(json.dumps(handle))
84
+ os.replace(tmp, attempt_dir / "handle.json")
85
+ return handle
86
+
87
+ def recorded_handle(self, attempt_dir: Path) -> dict | None:
88
+ """Trusted only as a handle for this attempt: the node can write in its folder, so a handle naming another request,
89
+ or a pid that is not one, steers no kill or adoption."""
90
+ path = attempt_dir / "handle.json"
91
+ if not path.is_file() or path.is_symlink():
92
+ return None
93
+ try:
94
+ h = json.loads(path.read_text(encoding="utf-8"))
95
+ except (json.JSONDecodeError, UnicodeDecodeError):
96
+ return None
97
+ # a pid in the range any OS gives (Linux's PID_MAX_LIMIT is 2^22): larger ones would overflow os.kill
98
+ ok = (isinstance(h, dict) and set(h) == {"pid", "request"} and type(h["pid"]) is int and 1 < h["pid"] <= 2 ** 22
99
+ and h["request"] == str(attempt_dir / "request.json"))
100
+ return h if ok else None
101
+
102
+ def poll(self, handle: dict) -> str:
103
+ pid = handle["pid"]
104
+ proc = self._children.get(pid)
105
+ if proc is not None: # our own child: poll() also reaps it
106
+ return "running" if proc.poll() is None else "exited"
107
+ return "running" if _alive(pid, handle["request"]) else "exited" # an attempt a previous coordinator started
108
+
109
+ def cancel(self, handle: dict) -> None:
110
+ pid = handle["pid"]
111
+ if self.poll(handle) == "running":
112
+ try:
113
+ os.killpg(pid, signal.SIGTERM)
114
+ except (ProcessLookupError, PermissionError):
115
+ pass # already gone, or exiting: macOS reports EPERM for a group whose leader is a zombie
116
+
117
+
118
+ class SelfBackend(NativeBackend):
119
+ kind = "self"
120
+
121
+ def missing_tools(self, runner: Runner) -> list[str]:
122
+ return []
123
+
124
+ def identity(self, manifest: Manifest, runner: Runner) -> str:
125
+ # the accelerator too: a place is a choice, and another place's result must not be served from the cache
126
+ return f"self:{hashlib.sha256((manifest.code_hash() + sdk_hash() + runner.accelerator).encode()).hexdigest()[:16]}"
127
+
128
+
129
+ def _alive(pid: int, request: str) -> bool:
130
+ """Is `pid` still the node process for this request? (pids are reused, so check its command line too.)"""
131
+ try:
132
+ os.kill(pid, 0)
133
+ except ProcessLookupError:
134
+ return False
135
+ except PermissionError:
136
+ return False
137
+ # -ww: unlimited width (procps truncates columns to 80 characters when not writing to a terminal, and a long request
138
+ # path then made a live node look dead)
139
+ out = subprocess.run(["ps", "-ww", "-p", str(pid), "-o", "command="], capture_output=True, text=True)
140
+ return request in out.stdout
@@ -0,0 +1,44 @@
1
+ """What this machine has: platform, cores, memory, accelerators, tools. Used by `perception doctor` and placement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ from .base import Capabilities, this_platform
11
+
12
+
13
+ def _memory_gb() -> float:
14
+ if sys.platform == "darwin":
15
+ out = subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True)
16
+ return int(out.stdout.strip()) / 2**30 if out.returncode == 0 else 0.0
17
+ if hasattr(os, "sysconf") and "SC_PHYS_PAGES" in os.sysconf_names:
18
+ return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") / 2**30
19
+ return 0.0
20
+
21
+
22
+ def _cuda_vram_gb() -> float:
23
+ smi = shutil.which("nvidia-smi")
24
+ if not smi:
25
+ return 0.0
26
+ out = subprocess.run([smi, "--query-gpu=memory.total", "--format=csv,noheader,nounits"], capture_output=True, text=True)
27
+ if out.returncode:
28
+ return 0.0
29
+ return max((float(x) / 1024 for x in out.stdout.split() if x.strip()), default=0.0)
30
+
31
+
32
+ def probe() -> Capabilities:
33
+ plat = this_platform()
34
+ memory = _memory_gb()
35
+ accelerators = {"cpu"}
36
+ vram = _cuda_vram_gb()
37
+ if vram:
38
+ accelerators.add("cuda")
39
+ if plat == "darwin/arm64":
40
+ accelerators.add("mps")
41
+ vram = vram or memory # unified memory
42
+ return Capabilities(platform=plat, cpu_cores=os.cpu_count() or 1, memory_gb=round(memory, 1), accelerators=accelerators,
43
+ vram_gb=round(vram, 1), tools={"uv": shutil.which("uv"), "ffmpeg": shutil.which("ffmpeg"),
44
+ "ffprobe": shutil.which("ffprobe")})
@@ -0,0 +1,140 @@
1
+ """Remote runs: a node on another machine, reached over SSH (a lab server, `perception hosts add`).
2
+
3
+ The node runs there exactly as a native node runs here (its script in its uv environment, the same file protocol), but
4
+ the coordinator never talks to the machine itself. `submit` starts a local driver process (`python -m
5
+ perception.remote`, remote_driver.py) that copies the node's code and inputs over, starts it, streams its log back,
6
+ and brings out/ and response.json back into the attempt's folder. To the coordinator the driver is a local process
7
+ like any native node: poll, cancel (the driver stops the remote node), adoption after a coordinator restart and
8
+ collect all work unchanged.
9
+
10
+ What reaches the other machine: the files git tracks for the node, its uv project and the node SDK, and the node's
11
+ inputs. Those inputs leave this machine, so a remote place is offered only when the graph's policy allows each kind of
12
+ data in them (policy.allow_upload), and runs only with the run's consent (--allow-uploads), as a cloud API's node does.
13
+ Nodes that send data out themselves (to an API) or need secrets are not run remotely: their data would go to two
14
+ places, and secrets stay here.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from ..hosts import Host, ssh_command
26
+ from ..manifests import Manifest, Runner
27
+ from ..release import files_in, release
28
+ from .base import Capabilities
29
+ from .native import REPO, NativeBackend
30
+
31
+
32
+ def code_files(manifest: Manifest, runner: Runner) -> list[str]:
33
+ """What a node needs on the other machine, relative to the repository: the files git tracks in its directory, its
34
+ uv project and the node SDK. Only tracked files: a working copy can hold images, crops and bundles that must not
35
+ leave the machine (as launch.sh stages for whole-graph cloud runs)."""
36
+ if not manifest.directory.resolve().is_relative_to(REPO):
37
+ raise RuntimeError(f"{manifest.ref} is not in the pipeline's repository ({manifest.directory}): only files it tracks are sent")
38
+ dirs = [manifest.directory.resolve().relative_to(REPO).as_posix(), "sdk"]
39
+ if runner.env:
40
+ dirs.append(runner.env)
41
+ entry_dir = (manifest.directory / runner.entry).resolve().parent.relative_to(REPO).as_posix()
42
+ if not (entry_dir + "/").startswith(dirs[0] + "/"):
43
+ dirs.append(entry_dir) # a script shared with another node (../work/node.py)
44
+ if release():
45
+ files = files_in(dirs) # a copy shipped with an app: every file in it was tracked where it was made
46
+ else:
47
+ out = subprocess.run(["git", "-C", str(REPO), "ls-files", "-z", "--", *dirs], capture_output=True, text=True)
48
+ if out.returncode:
49
+ raise RuntimeError(f"{REPO} is not a git checkout: only files git tracks are sent to other machines ({out.stderr.strip()})")
50
+ files = sorted(f for f in out.stdout.split("\0") if f)
51
+ refuse_links(files)
52
+ if not any(f.startswith(dirs[0] + "/") for f in files):
53
+ raise RuntimeError(f"git tracks no files in {dirs[0]}: commit the node before running it on another machine")
54
+ return files
55
+
56
+
57
+ def refuse_links(files: list[str], root: Path = REPO):
58
+ """A link git tracks would be followed when the code is copied and hashed, and what it points at (anything on this
59
+ machine) would go to the host: only content git tracks leaves the machine, so a node with one is not sent."""
60
+ links = [f for f in files if (root / f).is_symlink()]
61
+ if links:
62
+ raise RuntimeError(f"git tracks links among the code to send ({', '.join(links[:5])}): links are not sent to other machines")
63
+
64
+
65
+ def code_hash(files: list[str], root: Path = REPO) -> str:
66
+ """The code's content, as the name of its folder on the other machine: the same code is sent once."""
67
+ h = hashlib.sha256()
68
+ for f in files:
69
+ h.update(f.encode() + b"\0" + hashlib.sha256((root / f).read_bytes()).digest())
70
+ return h.hexdigest()[:20]
71
+
72
+
73
+ class SshBackend(NativeBackend):
74
+ runner_kind = "native"
75
+ remote = True
76
+
77
+ def __init__(self, host: Host, runner_kind: str = "native", python: str | None = None, lost_after_s: float = 600):
78
+ """`runner_kind="self"` with `python` (an interpreter on the other machine that has the node SDK) is for tests."""
79
+ super().__init__()
80
+ self.host = host
81
+ self.kind = f"ssh-{host.name}"
82
+ self.title = f"{host.name} (SSH)"
83
+ self.runner_kind = runner_kind
84
+ self.python = python
85
+ self.lost_after_s = lost_after_s
86
+
87
+ @property
88
+ def machine(self) -> str | None:
89
+ return self.kind
90
+
91
+ def probe_capabilities(self) -> Capabilities:
92
+ return self.host.caps # as recorded by `hosts add` / `hosts check`: planning never waits on the network
93
+
94
+ def missing_tools(self, runner: Runner) -> list[str]:
95
+ if self.runner_kind == "self":
96
+ return []
97
+ return [] if self.host.caps.tools.get("uv") else [f"uv (on {self.host.name})"]
98
+
99
+ def remote_refusal(self, manifest: Manifest) -> str:
100
+ if manifest.privacy.external_upload:
101
+ return f"it sends {', '.join(manifest.privacy.external_upload)} to {manifest.service or 'a service'} itself, so it runs on this machine"
102
+ if manifest.secrets:
103
+ return f"it needs {', '.join(manifest.secrets)}, and secrets are not sent to other machines"
104
+ if not manifest.directory.resolve().is_relative_to(REPO):
105
+ return "it is not in the pipeline's repository, and only files the repository tracks are sent to other machines"
106
+ return ""
107
+
108
+ def identity(self, manifest: Manifest, runner: Runner) -> str:
109
+ """Exactly the code that runs there (what code_files sends: the node's tracked files, a shared entry script, its
110
+ uv project with its lock, the SDK), the accelerator and the host's platform."""
111
+ try:
112
+ code = code_hash(code_files(manifest, runner))
113
+ except (RuntimeError, OSError) as e: # nothing could be sent (not a git checkout, a tracked file deleted): submit says so; no cache entry is shared
114
+ code = f"unsendable {e}"
115
+ h = hashlib.sha256(f"{code} {runner.accelerator} {self.host.caps.platform}".encode())
116
+ return f"{self.kind}:{h.hexdigest()[:16]}"
117
+
118
+ def driver_spec(self, attempt_dir: Path) -> dict:
119
+ """What else the driver needs (a cloud machine to start first)."""
120
+ return {}
121
+
122
+ def command(self, manifest: Manifest, runner: Runner, request: Path) -> list[str]:
123
+ # the request path on the command line: how a restarted coordinator recognises the driver (native._alive)
124
+ return [sys.executable, "-m", "perception.remote_driver", "--request", str(request)]
125
+
126
+ def submit(self, manifest: Manifest, runner: Runner, attempt_dir: Path, env: dict[str, str]) -> dict:
127
+ files = code_files(manifest, runner)
128
+ caps = self.host.caps
129
+ spec = {
130
+ "dest": self.host.dest, "root": self.host.root, "host": self.host.name, "ssh": ssh_command(),
131
+ "code": code_hash(files), "files": files,
132
+ "node_dir": manifest.directory.resolve().relative_to(REPO).as_posix(), "entry": runner.entry, "env": runner.env,
133
+ "runner_kind": self.runner_kind, "python": self.python, "uv": caps.tools.get("uv"), "prepare": manifest.prepare,
134
+ # the folders of the tools it found: ssh commands get a minimal PATH (ffmpeg in /opt/homebrew/bin on a Mac)
135
+ "path": sorted({str(Path(p).parent) for p in caps.tools.values() if p}),
136
+ "lost_after_s": self.lost_after_s,
137
+ **self.driver_spec(attempt_dir),
138
+ }
139
+ (attempt_dir / "remote.json").write_text(json.dumps(spec, indent=1))
140
+ return super().submit(manifest, runner, attempt_dir, env)