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.
- hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
- hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
- hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
- hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
- hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
- perception/__init__.py +0 -0
- perception/_pipeline/RELEASE.json +59 -0
- perception/_pipeline/cloud/runpod/guard.sh +37 -0
- perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
- perception/_pipeline/cloud/runpod/image/start.sh +11 -0
- perception/_pipeline/cloud/runpod/image/tag.py +40 -0
- perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
- perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
- perception/_pipeline/cloud/skypilot/guard.sh +20 -0
- perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
- perception/_pipeline/cloud/skypilot/launch.sh +63 -0
- perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
- perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
- perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
- perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
- perception/_pipeline/envs/api/pyproject.toml +12 -0
- perception/_pipeline/envs/api/uv.lock +276 -0
- perception/_pipeline/envs/audio/pyproject.toml +12 -0
- perception/_pipeline/envs/audio/uv.lock +575 -0
- perception/_pipeline/envs/core/pyproject.toml +12 -0
- perception/_pipeline/envs/core/uv.lock +157 -0
- perception/_pipeline/envs/faces/pyproject.toml +12 -0
- perception/_pipeline/envs/faces/uv.lock +1180 -0
- perception/_pipeline/graphs/decode.yaml +11 -0
- perception/_pipeline/graphs/faces.yaml +14 -0
- perception/_pipeline/graphs/full.yaml +31 -0
- perception/_pipeline/graphs/mvp-captions.yaml +27 -0
- perception/_pipeline/graphs/mvp.yaml +17 -0
- perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
- perception/_pipeline/nodes/audio/asr/node.py +74 -0
- perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
- perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
- perception/_pipeline/nodes/audio/asr/wav.py +19 -0
- perception/_pipeline/nodes/conftest.py +64 -0
- perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
- perception/_pipeline/nodes/core/decode/media.py +104 -0
- perception/_pipeline/nodes/core/decode/node.py +56 -0
- perception/_pipeline/nodes/core/decode/test_media.py +65 -0
- perception/_pipeline/nodes/testdata/README.md +10 -0
- perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
- perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
- perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
- perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
- perception/_pipeline/nodes/vision/face-track/node.py +117 -0
- perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
- perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
- perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
- perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
- perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
- perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
- perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
- perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
- perception/_pipeline/schema/README.md +54 -0
- perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
- perception/_pipeline/sdk/pyproject.toml +13 -0
- perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
- perception/artifacts.py +180 -0
- perception/backends/__init__.py +0 -0
- perception/backends/base.py +179 -0
- perception/backends/cloud.py +214 -0
- perception/backends/native.py +140 -0
- perception/backends/probe.py +44 -0
- perception/backends/remote.py +140 -0
- perception/cli.py +780 -0
- perception/estimates.py +68 -0
- perception/export.py +100 -0
- perception/hosts.py +201 -0
- perception/ir.py +245 -0
- perception/keys.py +259 -0
- perception/manifests.py +169 -0
- perception/plan.py +170 -0
- perception/release.py +61 -0
- perception/remote_driver.py +990 -0
- perception/runpod.py +100 -0
- perception/scheduler.py +552 -0
- perception/setup.py +72 -0
- perception/store.py +219 -0
perception/keys.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The user's keys (API keys, tokens) for nodes and cloud places, found in this order:
|
|
2
|
+
|
|
3
|
+
1. the environment (a name already known below, or any *_KEY / *_TOKEN);
|
|
4
|
+
2. the system keychain (macOS Keychain, Linux Secret Service, Windows Credential Manager), where `perception keys set`
|
|
5
|
+
stores them, under the service "hyperstudy-perception";
|
|
6
|
+
3. NAME=value lines in ~/.config/hyperstudy/*.env, only from files no one else can read or write (0600, the user's own).
|
|
7
|
+
A file others can read is refused, not read, and said so: a key in it may already have been read by someone else.
|
|
8
|
+
|
|
9
|
+
Values are never printed, logged or written to run files. The keychain cannot list what it holds, so the names stored
|
|
10
|
+
there are kept in ~/.config/hyperstudy/keys.json (names only)."""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import errno
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
SERVICE = "hyperstudy-perception"
|
|
26
|
+
NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def conf_dir() -> Path:
|
|
30
|
+
return Path.home() / ".config" / "hyperstudy"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Found:
|
|
35
|
+
values: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
sources: dict[str, str] = field(default_factory=dict) # name -> "environment", "keychain" or the file's path
|
|
37
|
+
problems: list[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
POSIX = os.name != "nt" # Windows has no POSIX modes (every file reads 0o666): a user's profile folder is their own there
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def folder_problem(conf: Path) -> str | None:
|
|
44
|
+
"""Why nothing in the config folder is used, or None: it must be a real folder (not a link), the user's own, and one
|
|
45
|
+
no one else can write to (else a file could be swapped in after it was checked)."""
|
|
46
|
+
try:
|
|
47
|
+
st = conf.lstat()
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
return None
|
|
50
|
+
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
|
51
|
+
return f"{conf} is not a folder (a link?): its key files are not used"
|
|
52
|
+
if POSIX and st.st_uid != os.getuid():
|
|
53
|
+
return f"{conf} is not yours: its key files are not used"
|
|
54
|
+
if POSIX and st.st_mode & 0o022:
|
|
55
|
+
return f"{conf} is a folder others can write to (mode {oct(stat.S_IMODE(st.st_mode))}): its key files are not used; chmod 700 it"
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def file_problem(path: Path, st: os.stat_result) -> str | None:
|
|
60
|
+
"""Why a key file is not used, or None: a regular file, the user's own, readable and writable by no one else."""
|
|
61
|
+
if not stat.S_ISREG(st.st_mode):
|
|
62
|
+
return f"{path} is not a regular file: its keys are not used"
|
|
63
|
+
if POSIX and st.st_uid != os.getuid():
|
|
64
|
+
return f"{path} is not yours: its keys are not used"
|
|
65
|
+
if POSIX and st.st_mode & 0o077:
|
|
66
|
+
return f"{path} can be read by others (mode {oct(stat.S_IMODE(st.st_mode))}): its keys are not used; chmod 600 it"
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_env(text: str) -> dict[str, str]:
|
|
71
|
+
out = {}
|
|
72
|
+
for line in text.splitlines():
|
|
73
|
+
if "=" in line and not line.lstrip().startswith("#"):
|
|
74
|
+
k, v = line.split("=", 1)
|
|
75
|
+
out[k.strip()] = v.strip()
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def read_private(path: Path) -> tuple[dict[str, str], str | None]:
|
|
80
|
+
"""A key file's keys, or why it is refused: opened without following a link, and checked on that open file, so what
|
|
81
|
+
is checked is what is read."""
|
|
82
|
+
try:
|
|
83
|
+
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
84
|
+
except OSError as e:
|
|
85
|
+
if e.errno == errno.ELOOP:
|
|
86
|
+
return {}, f"{path} is not a regular file (a link): its keys are not used"
|
|
87
|
+
return {}, f"{path} could not be read ({e.strerror})"
|
|
88
|
+
with os.fdopen(fd) as f:
|
|
89
|
+
problem = file_problem(path, os.fstat(f.fileno()))
|
|
90
|
+
return ({}, problem) if problem else (parse_env(f.read()), None)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def keyring_module():
|
|
94
|
+
"""The keyring library, or None where no keychain can be used (a headless server, or PERCEPTION_NO_KEYCHAIN)."""
|
|
95
|
+
if os.environ.get("PERCEPTION_NO_KEYCHAIN"):
|
|
96
|
+
return None
|
|
97
|
+
import keyring
|
|
98
|
+
from keyring.backends import fail
|
|
99
|
+
return None if isinstance(keyring.get_keyring(), fail.Keyring) else keyring
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Keys:
|
|
103
|
+
def __init__(self, conf: Path | None = None):
|
|
104
|
+
self.conf = conf or conf_dir()
|
|
105
|
+
self.index = self.conf / "keys.json"
|
|
106
|
+
|
|
107
|
+
def stored_names(self) -> list[str]:
|
|
108
|
+
"""Names `keys set` stored in the keychain (keys.json: names only)."""
|
|
109
|
+
try:
|
|
110
|
+
return [n for n in json.loads(self.index.read_text()) if isinstance(n, str) and NAME.match(n)]
|
|
111
|
+
except (OSError, ValueError, TypeError):
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
def _private_folder(self):
|
|
115
|
+
"""The config folder, made if missing and made private if it is not (0700): refused if it is a link or not the
|
|
116
|
+
user's (folder_problem)."""
|
|
117
|
+
self.conf.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
118
|
+
st = self.conf.lstat()
|
|
119
|
+
if POSIX and not stat.S_ISLNK(st.st_mode) and st.st_uid == os.getuid() and st.st_mode & 0o077:
|
|
120
|
+
os.chmod(self.conf, 0o700)
|
|
121
|
+
problem = folder_problem(self.conf)
|
|
122
|
+
if problem:
|
|
123
|
+
raise ValueError(problem)
|
|
124
|
+
|
|
125
|
+
def _write(self, path: Path, text: str):
|
|
126
|
+
"""Write a file in one step through a new temporary file of its own (0600 from its first byte, never one left
|
|
127
|
+
over), in the private config folder."""
|
|
128
|
+
self._private_folder()
|
|
129
|
+
fd, tmp = tempfile.mkstemp(dir=self.conf, prefix=f".{path.name}.")
|
|
130
|
+
try:
|
|
131
|
+
with os.fdopen(fd, "w") as f:
|
|
132
|
+
f.write(text)
|
|
133
|
+
os.replace(tmp, path)
|
|
134
|
+
except BaseException:
|
|
135
|
+
with contextlib.suppress(OSError):
|
|
136
|
+
os.unlink(tmp)
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
@contextlib.contextmanager
|
|
140
|
+
def _locked(self):
|
|
141
|
+
"""One change to keys.json and keys.env at a time (two `keys set` at once would drop a name otherwise)."""
|
|
142
|
+
self._private_folder()
|
|
143
|
+
try:
|
|
144
|
+
import fcntl
|
|
145
|
+
except ImportError: # Windows: no flock; a single user's own commands
|
|
146
|
+
yield
|
|
147
|
+
return
|
|
148
|
+
fd = os.open(self.conf / ".keys.lock", os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
149
|
+
try:
|
|
150
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
151
|
+
yield
|
|
152
|
+
finally:
|
|
153
|
+
os.close(fd)
|
|
154
|
+
|
|
155
|
+
def _write_index(self, names: list[str]):
|
|
156
|
+
self._write(self.index, json.dumps(sorted(set(names))))
|
|
157
|
+
|
|
158
|
+
def _file_keys(self) -> dict[str, str]:
|
|
159
|
+
"""What keys.env holds (the file `set` writes where there is no keychain), refusing one others can read."""
|
|
160
|
+
path = self.conf / "keys.env"
|
|
161
|
+
if not path.exists():
|
|
162
|
+
return {}
|
|
163
|
+
values, problem = read_private(path)
|
|
164
|
+
if problem:
|
|
165
|
+
raise ValueError(problem)
|
|
166
|
+
return values
|
|
167
|
+
|
|
168
|
+
def _write_file_keys(self, keys: dict[str, str]):
|
|
169
|
+
self._write(self.conf / "keys.env", "".join(f"{k}={v}\n" for k, v in sorted(keys.items())))
|
|
170
|
+
|
|
171
|
+
def load(self) -> Found:
|
|
172
|
+
found = Found()
|
|
173
|
+
# 3. private files, lowest: what a keychain or the environment holds wins
|
|
174
|
+
problem = folder_problem(self.conf)
|
|
175
|
+
if problem:
|
|
176
|
+
found.problems.append(problem)
|
|
177
|
+
for path in sorted(self.conf.glob("*.env")) if self.conf.is_dir() and not problem else []:
|
|
178
|
+
values, refused = read_private(path)
|
|
179
|
+
if refused:
|
|
180
|
+
found.problems.append(refused)
|
|
181
|
+
for k, v in values.items():
|
|
182
|
+
found.values[k], found.sources[k] = v, str(path)
|
|
183
|
+
# 2. the keychain
|
|
184
|
+
kr = keyring_module()
|
|
185
|
+
if kr is not None:
|
|
186
|
+
for name in self.stored_names():
|
|
187
|
+
try:
|
|
188
|
+
value = kr.get_password(SERVICE, name)
|
|
189
|
+
except Exception as e: # a locked or refused keychain: said, not fatal (other sources may have it)
|
|
190
|
+
found.problems.append(f"the keychain did not give {name} ({type(e).__name__})")
|
|
191
|
+
continue
|
|
192
|
+
if value:
|
|
193
|
+
found.values[name], found.sources[name] = value, "keychain"
|
|
194
|
+
# 1. the environment
|
|
195
|
+
for k, v in os.environ.items():
|
|
196
|
+
if v and (k in found.values or k.endswith(("_KEY", "_TOKEN"))):
|
|
197
|
+
found.values[k], found.sources[k] = v, "environment"
|
|
198
|
+
return found
|
|
199
|
+
|
|
200
|
+
def set(self, name: str, value: str) -> str:
|
|
201
|
+
"""Store a key: in the keychain, or (no keychain here) in ~/.config/hyperstudy/keys.env, 0600. Where it went."""
|
|
202
|
+
if not NAME.match(name):
|
|
203
|
+
raise ValueError(f"a key's name is upper-case letters, digits and _ (like RUNPOD_API_KEY): {name!r}")
|
|
204
|
+
value = value.strip()
|
|
205
|
+
if not value or value.splitlines() != [value]: # any line break (\r, \x85, \u2028...) would add a line to keys.env
|
|
206
|
+
raise ValueError("a key is one non-empty line")
|
|
207
|
+
kr = keyring_module()
|
|
208
|
+
with self._locked():
|
|
209
|
+
if kr is not None:
|
|
210
|
+
from keyring.errors import KeyringError
|
|
211
|
+
try:
|
|
212
|
+
kr.set_password(SERVICE, name, value)
|
|
213
|
+
except KeyringError as e: # locked, or access refused: nothing stored, said plainly
|
|
214
|
+
raise ValueError(f"the keychain did not store {name} ({e or type(e).__name__}): unlock it and try again") from None
|
|
215
|
+
self._write_index([*self.stored_names(), name])
|
|
216
|
+
return "keychain"
|
|
217
|
+
keys = self._file_keys()
|
|
218
|
+
keys[name] = value
|
|
219
|
+
self._write_file_keys(keys)
|
|
220
|
+
return str(self.conf / "keys.env")
|
|
221
|
+
|
|
222
|
+
def remove(self, name: str) -> list[str]:
|
|
223
|
+
"""Remove a key from the keychain and from keys.env (what `set` wrote); where it was. The user's own .env files
|
|
224
|
+
are theirs to edit."""
|
|
225
|
+
with self._locked():
|
|
226
|
+
return self._remove(name)
|
|
227
|
+
|
|
228
|
+
def _remove(self, name: str) -> list[str]:
|
|
229
|
+
removed = []
|
|
230
|
+
kr = keyring_module()
|
|
231
|
+
if kr is not None and name in self.stored_names():
|
|
232
|
+
from keyring.errors import KeyringError, PasswordDeleteError
|
|
233
|
+
try:
|
|
234
|
+
kr.delete_password(SERVICE, name)
|
|
235
|
+
removed.append("keychain")
|
|
236
|
+
except PasswordDeleteError:
|
|
237
|
+
pass # already gone from the keychain (removed there by hand)
|
|
238
|
+
except KeyringError as e: # locked, or access refused: still stored, and still in the index
|
|
239
|
+
raise ValueError(f"the keychain did not remove {name} ({e or type(e).__name__}): unlock it and try again") from None
|
|
240
|
+
self._write_index([n for n in self.stored_names() if n != name])
|
|
241
|
+
keys = self._file_keys()
|
|
242
|
+
if keys.pop(name, None) is not None:
|
|
243
|
+
self._write_file_keys(keys)
|
|
244
|
+
removed.append(str(self.conf / "keys.env"))
|
|
245
|
+
return removed
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_warned = False
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_secrets() -> dict[str, str]:
|
|
252
|
+
"""Every key found (Keys.load), saying once on stderr which files were refused and why."""
|
|
253
|
+
global _warned
|
|
254
|
+
found = Keys().load()
|
|
255
|
+
if found.problems and not _warned:
|
|
256
|
+
_warned = True
|
|
257
|
+
for p in found.problems:
|
|
258
|
+
print(f"perception: {p}", file=sys.stderr)
|
|
259
|
+
return found.values
|
perception/manifests.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Node manifests: what a node needs, produces and runs on. One `manifest.yaml` per node directory under `nodes/`.
|
|
2
|
+
|
|
3
|
+
A manifest is the only thing the coordinator knows about a node. The graph refers to it as `uses: <id>@<version>`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
14
|
+
|
|
15
|
+
DATA_KINDS = ("video", "audio", "frames", "faces", "transcript", "embeddings")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Strict(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Runner(Strict):
|
|
23
|
+
"""One way to run the node. `native`: a script in a uv environment (on this machine, a cloud machine or a lab
|
|
24
|
+
server). `self`: the coordinator's own interpreter (tests and trivial nodes only). No containers (2026-10-24):
|
|
25
|
+
every node installs with uv, and Docker on macOS cannot reach the Apple GPU."""
|
|
26
|
+
kind: Literal["native", "self"]
|
|
27
|
+
entry: str | None = None # native/self: script path, relative to the node directory
|
|
28
|
+
env: str | None = None # native: uv project, relative to the repository root
|
|
29
|
+
platforms: list[str] = Field(default_factory=lambda: ["darwin/arm64", "linux/amd64", "linux/arm64", "windows/amd64"])
|
|
30
|
+
accelerator: Literal["cpu", "mps", "cuda"] = "cpu"
|
|
31
|
+
minimum_vram_gb: float = 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Resources(Strict):
|
|
35
|
+
cpu_cores: float = 1
|
|
36
|
+
memory_gb: float = 1
|
|
37
|
+
# exclusive: one such node at a time on a device (large models; measured: two at once swapped a 128 GB Mac)
|
|
38
|
+
gpu: Literal["none", "shared", "exclusive"] = "none"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Execution(Strict):
|
|
42
|
+
partitionable: bool = False
|
|
43
|
+
deterministic: bool = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Privacy(Strict):
|
|
47
|
+
external_upload: list[Literal["video", "audio", "frames", "faces", "transcript", "embeddings"]] = Field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Cost(Strict):
|
|
51
|
+
"""A billable node's estimate, reserved against the run's budget before it starts; what it actually spent comes
|
|
52
|
+
back in its response (usage.usd). `per` counts units in one of the node's inputs. When the price depends on a
|
|
53
|
+
config value (the model), `usd` is a table keyed by that value and `by` names the config key."""
|
|
54
|
+
usd: float | dict[str, float] # per unit, an upper estimate
|
|
55
|
+
by: str | None = None # the config key `usd` is keyed by
|
|
56
|
+
per: Literal["shot", "minute", "run"]
|
|
57
|
+
input: str | None = None # the input holding the units (shots layer; media for minutes)
|
|
58
|
+
|
|
59
|
+
def unit_usd(self, config: dict) -> float:
|
|
60
|
+
return self.usd[config[self.by]] if isinstance(self.usd, dict) else self.usd
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ModelRef(Strict):
|
|
64
|
+
"""A model the node uses: where its weights come from and under what terms."""
|
|
65
|
+
id: str
|
|
66
|
+
source: str | None = None # hf://repo@revision[/file], or a URL
|
|
67
|
+
revision: str | None = None
|
|
68
|
+
licence: str | None = None
|
|
69
|
+
licence_mode: Literal["academic-only", "commercial-ok", "needs-review", "api-terms"]
|
|
70
|
+
size_gb: float | None = None
|
|
71
|
+
gated: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Manifest(Strict):
|
|
75
|
+
id: str
|
|
76
|
+
version: str
|
|
77
|
+
title: str = "" # what it gives, in a few words: the app's feature list ("Face tracks")
|
|
78
|
+
service: str | None = None # the hosted API whose machines do the work ("Anthropic API"): it runs there, not here
|
|
79
|
+
description: str = ""
|
|
80
|
+
stage: str | None = None # the bundle producer's stage name (default: the node id)
|
|
81
|
+
inputs: dict[str, str] = Field(default_factory=dict) # name -> artifact type
|
|
82
|
+
outputs: dict[str, str] # name -> artifact type
|
|
83
|
+
config: dict[str, dict] = Field(default_factory=dict) # name -> JSON Schema (with default)
|
|
84
|
+
runners: list[Runner]
|
|
85
|
+
resources: Resources = Field(default_factory=Resources)
|
|
86
|
+
execution: Execution = Field(default_factory=Execution)
|
|
87
|
+
privacy: Privacy = Field(default_factory=Privacy)
|
|
88
|
+
models: list[ModelRef] = Field(default_factory=list)
|
|
89
|
+
# a script in the node's directory, run in its environment with its settings (JSON) before it is first needed, that
|
|
90
|
+
# fetches what the node would otherwise download on first use (its weights): `perception setup`, a remote run
|
|
91
|
+
prepare: str | None = None
|
|
92
|
+
secrets: list[str] = Field(default_factory=list)
|
|
93
|
+
licence: dict = Field(default_factory=dict)
|
|
94
|
+
cost: Cost | None = None
|
|
95
|
+
|
|
96
|
+
directory: Path = Field(default=Path("."), exclude=True)
|
|
97
|
+
|
|
98
|
+
@model_validator(mode="after")
|
|
99
|
+
def _cost_counts_an_input(self):
|
|
100
|
+
if self.cost and self.cost.per != "run":
|
|
101
|
+
if self.cost.input not in self.inputs:
|
|
102
|
+
raise ValueError(f"cost: per {self.cost.per} counts units in an input, but cost.input "
|
|
103
|
+
f"{self.cost.input!r} is not one of this node's inputs ({', '.join(self.inputs) or 'none'})")
|
|
104
|
+
# what manifest_cost reads: the shots of a shots layer, or the duration of media.v1
|
|
105
|
+
kind = self.inputs[self.cost.input]
|
|
106
|
+
if (self.cost.per == "shot") != kind.startswith("bundle.shots@") or (self.cost.per == "minute") != (kind == "media.v1"):
|
|
107
|
+
raise ValueError(f"cost: per {self.cost.per} cannot be counted in {self.cost.input} ({kind}); "
|
|
108
|
+
"shots come from a bundle.shots layer, minutes from media.v1")
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
@model_validator(mode="after")
|
|
112
|
+
def _cost_covers_its_config(self):
|
|
113
|
+
"""A price table must price every value the config allows, or a new model would be reserved at nothing."""
|
|
114
|
+
if self.cost is None or not (isinstance(self.cost.usd, dict) or self.cost.by):
|
|
115
|
+
return self
|
|
116
|
+
if not (isinstance(self.cost.usd, dict) and self.cost.by):
|
|
117
|
+
raise ValueError("cost: a price table (usd: {value: price}) and `by` go together")
|
|
118
|
+
if self.cost.by not in self.config or "enum" not in self.config[self.cost.by]:
|
|
119
|
+
raise ValueError(f"cost.by: {self.cost.by!r} is not a config key with an enum")
|
|
120
|
+
missing = [v for v in self.config[self.cost.by]["enum"] if v not in self.cost.usd]
|
|
121
|
+
if missing:
|
|
122
|
+
raise ValueError(f"cost: no price for {self.cost.by} {', '.join(map(repr, missing))}")
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def ref(self) -> str:
|
|
127
|
+
return f"{self.id}@{self.version}"
|
|
128
|
+
|
|
129
|
+
def defaults(self) -> dict:
|
|
130
|
+
return {k: s["default"] for k, s in self.config.items() if "default" in s}
|
|
131
|
+
|
|
132
|
+
def code_hash(self) -> str:
|
|
133
|
+
"""Hash of every file in the node's directory: changing node code invalidates its cache."""
|
|
134
|
+
h = hashlib.sha256()
|
|
135
|
+
for p in sorted(self.directory.rglob("*")):
|
|
136
|
+
if p.is_file() and "__pycache__" not in p.parts:
|
|
137
|
+
h.update(str(p.relative_to(self.directory)).encode())
|
|
138
|
+
h.update(p.read_bytes())
|
|
139
|
+
return h.hexdigest()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Registry:
|
|
143
|
+
def __init__(self, manifests: list[Manifest]):
|
|
144
|
+
self.by_ref: dict[str, Manifest] = {}
|
|
145
|
+
for m in manifests:
|
|
146
|
+
if m.ref in self.by_ref:
|
|
147
|
+
raise ValueError(f"two manifests define {m.ref}: {self.by_ref[m.ref].directory} and {m.directory}")
|
|
148
|
+
self.by_ref[m.ref] = m
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def load(cls, *roots: Path) -> "Registry":
|
|
152
|
+
manifests = []
|
|
153
|
+
for root in roots:
|
|
154
|
+
for path in sorted(root.rglob("manifest.yaml")):
|
|
155
|
+
doc = yaml.safe_load(path.read_text())
|
|
156
|
+
m = Manifest.model_validate(doc)
|
|
157
|
+
m.directory = path.parent.resolve()
|
|
158
|
+
manifests.append(m)
|
|
159
|
+
return cls(manifests)
|
|
160
|
+
|
|
161
|
+
def get(self, ref: str) -> Manifest:
|
|
162
|
+
if ref not in self.by_ref:
|
|
163
|
+
same_id = [r for r in self.by_ref if r.split("@")[0] == ref.split("@")[0]]
|
|
164
|
+
hint = f" (available: {', '.join(same_id)})" if same_id else ""
|
|
165
|
+
raise KeyError(f"no node {ref}{hint}")
|
|
166
|
+
return self.by_ref[ref]
|
|
167
|
+
|
|
168
|
+
def __iter__(self):
|
|
169
|
+
return iter(self.by_ref.values())
|
perception/plan.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Dry run: what `perception run` would do, without doing it (spec acceptance criterion 6).
|
|
2
|
+
|
|
3
|
+
For each node: where it would run (or why it cannot), whether its result is already cached, which model weights are
|
|
4
|
+
not on this machine yet, which secrets are missing, and the estimated spend. Privacy conflicts are graph errors
|
|
5
|
+
(ir.py), so a graph that reaches planning has none.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .estimates import estimate, media_seconds, probe_seconds
|
|
16
|
+
from .ir import Graph
|
|
17
|
+
from .manifests import ModelRef
|
|
18
|
+
from .scheduler import Coordinator, manifest_cost
|
|
19
|
+
from .store import Artifact
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class NodePlan:
|
|
24
|
+
id: str
|
|
25
|
+
uses: str
|
|
26
|
+
where: str | None
|
|
27
|
+
why_not: str = ""
|
|
28
|
+
cached: bool = False
|
|
29
|
+
downloads: list[ModelRef] = field(default_factory=list)
|
|
30
|
+
missing_secrets: list[str] = field(default_factory=list)
|
|
31
|
+
cost_usd: float = 0.0
|
|
32
|
+
cost_note: str = ""
|
|
33
|
+
uploads: list[str] = field(default_factory=list) # data kinds that leave the machine where it would run: to its API, or its inputs to another machine
|
|
34
|
+
upload_to: str | None = None # where they go: the API's service, or the other machine
|
|
35
|
+
place: str | None = None # where it would run (backend:accelerator)
|
|
36
|
+
options: list[str] = field(default_factory=list) # every place it can run here, best first
|
|
37
|
+
estimate_s: float | None = None # seconds, from its measured runs here (estimates.py)
|
|
38
|
+
estimate_basis: str = ""
|
|
39
|
+
needs: list[str] = field(default_factory=list) # the nodes whose outputs it takes (a stage picker's edges)
|
|
40
|
+
estimates: dict[str, dict] = field(default_factory=dict) # every option -> {estimate_s, estimate_basis, cost_usd, uploads, upload_to}: to compare places
|
|
41
|
+
title: str = ""
|
|
42
|
+
service: str | None = None # the hosted API that does its work, if it runs elsewhere
|
|
43
|
+
description: str = ""
|
|
44
|
+
outputs: dict[str, str] = field(default_factory=dict) # name -> artifact type
|
|
45
|
+
models: list[ModelRef] = field(default_factory=list) # every model it uses (downloads: the ones not here yet)
|
|
46
|
+
settings: list[dict] = field(default_factory=list) # its fixed-choice settings: {key, description, value, choices, prices, per}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Plan:
|
|
51
|
+
nodes: list[NodePlan]
|
|
52
|
+
places: dict[str, dict] = field(default_factory=dict) # every place a node could go -> {title, remote, accelerator}
|
|
53
|
+
max_cost_usd: float = 0.0 # the run's cap, as the graph sets it (--max-cost-usd replaces it)
|
|
54
|
+
allow_downloads: bool = False
|
|
55
|
+
allow_uploads: bool = False # the run's consent; the graph's policy.allow_upload is the most it may ever send
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self) -> bool:
|
|
59
|
+
return all(n.where and not n.missing_secrets and (self.allow_downloads or not n.downloads)
|
|
60
|
+
and (self.allow_uploads or n.cached or not n.upload_to) for n in self.nodes)
|
|
61
|
+
|
|
62
|
+
def render(self, explain: bool = False) -> str:
|
|
63
|
+
lines = []
|
|
64
|
+
for n in self.nodes:
|
|
65
|
+
status = "cached" if n.cached else (n.where or "CANNOT RUN")
|
|
66
|
+
lines.append(f"{n.id:16s} {n.uses:28s} {status}")
|
|
67
|
+
if n.why_not:
|
|
68
|
+
lines.append(f"{'':16s} {n.why_not}")
|
|
69
|
+
for m in n.downloads:
|
|
70
|
+
size = f"{m.size_gb:.1f} GB" if m.size_gb else "size unknown"
|
|
71
|
+
lines.append(f"{'':16s} needs download: {m.id} ({size}{', gated' if m.gated else ''}, {m.licence_mode}) from {m.source}")
|
|
72
|
+
if n.upload_to and not n.cached:
|
|
73
|
+
lines.append(f"{'':16s} sends {', '.join(n.uploads) or 'its inputs'} to {n.upload_to}")
|
|
74
|
+
for s in n.missing_secrets:
|
|
75
|
+
lines.append(f"{'':16s} missing secret: {s}")
|
|
76
|
+
if n.cost_usd or n.cost_note:
|
|
77
|
+
lines.append(f"{'':16s} estimated ${n.cost_usd:.2f}" + (f" ({n.cost_note})" if n.cost_note else ""))
|
|
78
|
+
total = sum(n.cost_usd for n in self.nodes if not n.cached)
|
|
79
|
+
lines.append(f"estimated spend: ${total:.2f}")
|
|
80
|
+
downloads = [m for n in self.nodes for m in n.downloads]
|
|
81
|
+
if downloads and not self.allow_downloads:
|
|
82
|
+
lines.append("weights are missing: fetch them first (perception models pull) or run with --allow-downloads")
|
|
83
|
+
uploading = [n for n in self.nodes if n.upload_to and not n.cached]
|
|
84
|
+
if uploading and not self.allow_uploads:
|
|
85
|
+
kinds = sorted({k for n in uploading for k in n.uploads})
|
|
86
|
+
lines.append(f"{', '.join(n.id for n in uploading)} send {', '.join(kinds) or 'their inputs'} off this machine: run with --allow-uploads, or leave them out (--only)")
|
|
87
|
+
return "\n".join(lines)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def hf_cached(source: str) -> bool:
|
|
91
|
+
"""Is hf://org/name[@revision] in the local Hugging Face cache?"""
|
|
92
|
+
repo = source.removeprefix("hf://").split("@")[0]
|
|
93
|
+
parts = repo.split("/")
|
|
94
|
+
if len(parts) < 2:
|
|
95
|
+
return False
|
|
96
|
+
hub = Path(os.environ.get("HF_HUB_CACHE") or Path(os.environ.get("HF_HOME", Path.home() / ".cache" / "huggingface")) / "hub")
|
|
97
|
+
return (hub / f"models--{parts[0]}--{parts[1]}").is_dir()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def settings(m, config: dict) -> list[dict]:
|
|
101
|
+
"""A node's settings that are a fixed list of choices (a model, an effort), with the price of each when the node's
|
|
102
|
+
cost is keyed by that setting: what the app offers as menus. Free-form settings stay at their tuned defaults."""
|
|
103
|
+
out = []
|
|
104
|
+
for key, schema in m.config.items():
|
|
105
|
+
if "enum" not in schema:
|
|
106
|
+
continue
|
|
107
|
+
priced = m.cost and m.cost.by == key and isinstance(m.cost.usd, dict)
|
|
108
|
+
out.append({"key": key, "description": schema.get("description", ""), "value": config.get(key), "choices": list(schema["enum"]),
|
|
109
|
+
"prices": dict(m.cost.usd) if priced else None, "per": m.cost.per if priced else None})
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def plan(coord: Coordinator, graph: Graph, allow_downloads: bool = False, allow_uploads: bool = False) -> Plan:
|
|
114
|
+
known: dict[str, dict[str, Artifact]] = {} # node -> outputs, when its result is cached
|
|
115
|
+
graph_inputs = {name: coord._graph_input(graph, name) for name in graph.spec.inputs}
|
|
116
|
+
out, places = [], {}
|
|
117
|
+
video_s = None # the video's length: from a cached decode below, else ffprobe on the graph input
|
|
118
|
+
for name, spec in graph.spec.inputs.items():
|
|
119
|
+
if spec.type == "video.v1" and not video_s:
|
|
120
|
+
video_s = probe_seconds(graph.input_path(name))
|
|
121
|
+
for node in graph.nodes.values():
|
|
122
|
+
m = node.manifest
|
|
123
|
+
policy = graph.spec.policy
|
|
124
|
+
placement, why = coord.place(m, node.spec.backend, node.spec.place, policy)
|
|
125
|
+
np = NodePlan(node.id, m.ref, f"{placement.backend.kind} ({placement.runner.accelerator})" if placement else None, why)
|
|
126
|
+
options = coord.placements(m, node.spec.backend, policy)[0]
|
|
127
|
+
np.options = [p.label for p in options]
|
|
128
|
+
for p in options:
|
|
129
|
+
places[p.label] = {"title": p.backend.title, "remote": p.backend.remote, "accelerator": p.runner.accelerator}
|
|
130
|
+
np.needs = sorted(node.upstream)
|
|
131
|
+
np.place = placement.label if placement else None
|
|
132
|
+
# weights not in this machine's cache; on another machine its cache is not known, so every weight is a download
|
|
133
|
+
# there (a run never downloads silently: --allow-downloads)
|
|
134
|
+
np.downloads = [x for x in m.models if x.source and x.source.startswith("hf://")
|
|
135
|
+
and ((placement and placement.backend.remote) or not hf_cached(x.source))]
|
|
136
|
+
np.missing_secrets = [s for s in m.secrets if s not in coord.secrets]
|
|
137
|
+
np.uploads, np.upload_to = coord.uploads(m, placement) if placement else (list(m.privacy.external_upload), m.service)
|
|
138
|
+
np.title, np.service, np.description, np.outputs, np.models = m.title, m.service, m.description, dict(m.outputs), list(m.models)
|
|
139
|
+
np.settings = settings(m, node.config)
|
|
140
|
+
if placement:
|
|
141
|
+
inputs = {}
|
|
142
|
+
for name, ref in node.inputs.items():
|
|
143
|
+
if ref.node is None:
|
|
144
|
+
inputs[name] = graph_inputs[ref.name]
|
|
145
|
+
elif ref.node in known:
|
|
146
|
+
inputs[name] = known[ref.node][ref.name]
|
|
147
|
+
if len(inputs) == len(node.inputs):
|
|
148
|
+
key = coord.cache_key(node, placement, inputs)
|
|
149
|
+
hit = coord.store.db.execute("SELECT outputs FROM cache WHERE key=?", (key,)).fetchone()
|
|
150
|
+
if hit:
|
|
151
|
+
outs = {k: Artifact.from_json(v) for k, v in json.loads(hit["outputs"]).items()}
|
|
152
|
+
if all(coord.store.has(a) for a in outs.values()):
|
|
153
|
+
np.cached = True
|
|
154
|
+
np.downloads = []
|
|
155
|
+
known[node.id] = outs
|
|
156
|
+
video_s = media_seconds(json.loads(hit["outputs"])) or video_s
|
|
157
|
+
np.cost_usd = placement.backend.estimate(m, placement.runner, {}, video_s).cost_usd
|
|
158
|
+
if m.cost: # units are known once the input exists (from the cache, or a graph input)
|
|
159
|
+
np.cost_usd += manifest_cost(m, node.config, inputs) if m.cost.per == "run" or m.cost.input in inputs else 0.0
|
|
160
|
+
np.cost_note = "" if m.cost.per == "run" or m.cost.input in inputs else f"${m.cost.unit_usd(node.config)} per {m.cost.per}, units known after {node.inputs[m.cost.input].node}"
|
|
161
|
+
for p in options: # cached or not: moving a node elsewhere runs it again
|
|
162
|
+
seconds, basis = estimate(coord.store, m.ref, p.label, video_s)
|
|
163
|
+
sent, to = coord.uploads(m, p)
|
|
164
|
+
np.estimates[p.label] = {"estimate_s": seconds, "estimate_basis": basis,
|
|
165
|
+
"cost_usd": p.backend.estimate(m, p.runner, {}, video_s).cost_usd, "uploads": sent, "upload_to": to}
|
|
166
|
+
if placement and not np.cached:
|
|
167
|
+
chosen = np.estimates[placement.label]
|
|
168
|
+
np.estimate_s, np.estimate_basis = chosen["estimate_s"], chosen["estimate_basis"]
|
|
169
|
+
out.append(np)
|
|
170
|
+
return Plan(out, places, graph.spec.policy.max_cost_usd, allow_downloads, allow_uploads)
|
perception/release.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Where the pipeline's files are: a checkout of this repository, or the package installed from PyPI, which carries
|
|
2
|
+
them in perception/_pipeline (scripts/build-dist.sh: nodes, environments, the node SDK, graphs, schemas and cloud
|
|
3
|
+
scripts). The package has no .git, so what git says in a checkout (which commit this is, which files are tracked) is in
|
|
4
|
+
its RELEASE.json instead. Read only where the file is: a checkout never has one (.gitignore), and the package never
|
|
5
|
+
asks git (on a Mac without the developer tools, running git opens a dialog offering to install them)."""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from functools import cache
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
PACKAGED = Path(__file__).resolve().parent / "_pipeline"
|
|
15
|
+
REPO = PACKAGED if PACKAGED.is_dir() else Path(__file__).resolve().parents[3]
|
|
16
|
+
RELEASE = REPO / "RELEASE.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def home() -> Path:
|
|
20
|
+
"""The store: artifacts, run state and (for the package) node environments."""
|
|
21
|
+
return Path(os.environ.get("PERCEPTION_HOME", Path.home() / ".hyperstudy-perception"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@cache
|
|
25
|
+
def release() -> dict | None:
|
|
26
|
+
"""{"commit": the full hash, "files": every file it carries, relative to REPO}; None in a checkout."""
|
|
27
|
+
if not RELEASE.exists():
|
|
28
|
+
return None
|
|
29
|
+
data = json.loads(RELEASE.read_text())
|
|
30
|
+
if not isinstance(data.get("commit"), str) or not isinstance(data.get("files"), list):
|
|
31
|
+
raise RuntimeError(f"{RELEASE} is not a release file (commit and files)")
|
|
32
|
+
return data
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def files_in(dirs: list[str]) -> list[str]:
|
|
36
|
+
"""The package's files under these directories, as `git ls-files -- dirs` lists them in a checkout."""
|
|
37
|
+
return sorted(f for f in release()["files"] if any(f.startswith(d.rstrip("/") + "/") for d in dirs))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def env_dir(env: str) -> Path | None:
|
|
41
|
+
"""Where a node environment (envs/NAME) is made: in a checkout, uv's own place (envs/NAME/.venv, None here); for
|
|
42
|
+
the package, under the store, by the lock, the commit and the package version (the package's folder may not be
|
|
43
|
+
writable, and an upgrade replaces it)."""
|
|
44
|
+
if release() is None:
|
|
45
|
+
return None
|
|
46
|
+
lock = REPO / env / "uv.lock"
|
|
47
|
+
h = hashlib.sha256(lock.read_bytes() if lock.exists() else b"no lock")
|
|
48
|
+
# the commit too: the SDK is installed editable from this copy, so two builds of one version (PyPI's and a local
|
|
49
|
+
# one) must not share an environment and point its SDK at each other's files
|
|
50
|
+
h.update(release()["commit"].encode())
|
|
51
|
+
try:
|
|
52
|
+
h.update(version("hyperstudy-perception").encode())
|
|
53
|
+
except PackageNotFoundError: # the files without the package's metadata (a copy run in place)
|
|
54
|
+
pass
|
|
55
|
+
return home() / "envs" / f"{Path(env).name}-{h.hexdigest()[:12]}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def env_vars(env: str) -> dict[str, str]:
|
|
59
|
+
"""What uv is told so a node environment is made where env_dir says (nothing in a checkout)."""
|
|
60
|
+
d = env_dir(env)
|
|
61
|
+
return {} if d is None else {"UV_PROJECT_ENVIRONMENT": str(d)}
|