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
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
@@ -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)}