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/setup.py ADDED
@@ -0,0 +1,72 @@
1
+ """`perception setup`: everything a node needs on this machine, ready before a run rather than fetched in the middle of
2
+ one. For each node that would run here: its uv environment synced from its lock (`uv sync --frozen`, what `uv run`
3
+ would do on first use), then its `prepare` script run in that environment (its weights downloaded into the cache it
4
+ reads). Nodes call no tools of the machine's (they read video with PyAV). Each step is timed and reported; a failure is said, with the
5
+ end of its output, and the rest still runs. Doing it again finds everything in place and takes seconds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ from .backends.base import child_env
19
+ from .release import REPO, env_vars
20
+ from .manifests import Manifest, Runner
21
+
22
+
23
+ @dataclass
24
+ class Step:
25
+ node: str # the node's ref, or "" for the machine's tools
26
+ what: str # environment | models | tools
27
+ ok: bool
28
+ seconds: float
29
+ message: str = ""
30
+
31
+
32
+ @dataclass
33
+ class Setup:
34
+ steps: list[Step] = field(default_factory=list)
35
+
36
+ @property
37
+ def ok(self) -> bool:
38
+ return all(s.ok for s in self.steps)
39
+
40
+
41
+ def _run(cmd: list[str], cwd: Path, env: dict[str, str]) -> tuple[bool, float, str]:
42
+ """Run a step in the node's own environment; what it said last, or the end of what went wrong."""
43
+ start = time.monotonic()
44
+ out = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, stdin=subprocess.DEVNULL, env=env)
45
+ said = (out.stdout.strip().splitlines() or [""])[-1] if out.returncode == 0 else \
46
+ " / ".join((out.stdout + out.stderr).strip().splitlines()[-3:])
47
+ return out.returncode == 0, round(time.monotonic() - start, 1), said
48
+
49
+
50
+ def setup_node(m: Manifest, runner: Runner, uv: str | None = None, config: dict | None = None,
51
+ secrets: dict[str, str] | None = None) -> list[Step]:
52
+ """Sync the node's environment (native runners) and run its prepare script (its weights) with its settings: a graph's
53
+ for the node when there is one (a setting can choose the model), else its defaults. With the environment a run gives
54
+ the node (child_env): none of this interpreter's settings, and no credential but the secrets it declares (a gated
55
+ model's token)."""
56
+ uv = uv or shutil.which("uv") or "uv"
57
+ env = child_env(m.secrets, secrets or {})
58
+ if runner.kind == "native" and runner.env:
59
+ env = {**env, **env_vars(runner.env)} # where a run makes it (the package's: in the store)
60
+ steps: list[Step] = []
61
+ if runner.kind == "native" and runner.env:
62
+ ok, seconds, tail = _run([uv, "sync", "--project", str(REPO / runner.env), "--frozen", "--no-dev"], REPO, env)
63
+ steps.append(Step(m.ref, "environment", ok, seconds, "" if ok else tail))
64
+ if not ok:
65
+ return steps # its prepare would only fail the same way
66
+ if m.prepare:
67
+ python = ([uv, "run", "--project", str(REPO / runner.env), "--frozen", "--no-dev", "python"]
68
+ if runner.kind == "native" and runner.env else [sys.executable])
69
+ ok, seconds, tail = _run([*python, str(m.directory / m.prepare), json.dumps(m.defaults() if config is None else config)], m.directory, env)
70
+ steps.append(Step(m.ref, "models", ok, seconds, tail))
71
+ return steps
72
+
perception/store.py ADDED
@@ -0,0 +1,219 @@
1
+ """The artifact store and run state, under one root directory:
2
+
3
+ <root>/perception.db run state, artifacts, cache (SQLite, WAL)
4
+ <root>/objects/ab/<sha256>/<name> published files, read-only, keeping their file name (tools read extensions)
5
+ <root>/trees/ab/<sha256>/... published directories, read-only
6
+ <root>/work/<run>/<node>/<attempt>/ attempt directories (request.json, out/, response.json, log.txt)
7
+ <root>/tmp/ publication staging (same filesystem, so publication is a rename)
8
+
9
+ Publication is effectively-once: an output is copied into tmp/, hashed, and renamed into objects/ or trees/ under its
10
+ hash. A second publication of the same content finds the object already there. Nothing reads an object before its
11
+ row is committed, and a crash leaves at most an orphan file in tmp/. Staged files are named for their process, and
12
+ opening the store clears those of processes that are gone (never a running coordinator's: any command opens the store).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import sqlite3
21
+ import stat
22
+ import time
23
+ import uuid
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from . import artifacts
28
+
29
+ MIGRATIONS = [
30
+ """
31
+ CREATE TABLE artifacts (
32
+ hash TEXT PRIMARY KEY, shape TEXT NOT NULL, type TEXT NOT NULL, bytes INTEGER NOT NULL, created_at REAL NOT NULL
33
+ );
34
+ -- a source file (the video) hashed once per (path, size, mtime): films are large
35
+ CREATE TABLE sources (
36
+ path TEXT NOT NULL, size INTEGER NOT NULL, mtime_ns INTEGER NOT NULL, sha256 TEXT NOT NULL,
37
+ PRIMARY KEY (path, size, mtime_ns)
38
+ );
39
+ CREATE TABLE runs (
40
+ id TEXT PRIMARY KEY, pipeline TEXT NOT NULL, graph TEXT NOT NULL, graph_path TEXT, inputs TEXT NOT NULL,
41
+ status TEXT NOT NULL, created_at REAL NOT NULL, finished_at REAL, max_cost_usd REAL NOT NULL, spent_usd REAL NOT NULL DEFAULT 0
42
+ );
43
+ CREATE TABLE tasks (
44
+ run_id TEXT NOT NULL REFERENCES runs(id), node_id TEXT NOT NULL,
45
+ state TEXT NOT NULL CHECK (state IN ('pending','ready','leased','running','succeeded','cached','failed','canceled')),
46
+ attempt INTEGER NOT NULL DEFAULT 0, -- increases for ever: each attempt gets its own directory
47
+ cache_key TEXT, runner TEXT,
48
+ lease_owner TEXT, lease_expires REAL, handle TEXT,
49
+ not_before REAL, started_at REAL, finished_at REAL, error_code TEXT, error TEXT,
50
+ cost_usd REAL NOT NULL DEFAULT 0, usage TEXT, outputs TEXT,
51
+ skip_cache INTEGER NOT NULL DEFAULT 0, -- rerun --no-cache: run even if the cache has the result
52
+ PRIMARY KEY (run_id, node_id)
53
+ );
54
+ CREATE TABLE cache (key TEXT PRIMARY KEY, node TEXT NOT NULL, outputs TEXT NOT NULL, usage TEXT, created_at REAL NOT NULL);
55
+ CREATE TABLE events (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT, run_id TEXT NOT NULL, node_id TEXT, at REAL NOT NULL, kind TEXT NOT NULL, message TEXT
57
+ );
58
+ CREATE INDEX events_by_run ON events(run_id, id);
59
+ """,
60
+ # 2: retries count from the first attempt since the last rerun; attempt numbers (directories) keep increasing
61
+ """
62
+ ALTER TABLE tasks ADD COLUMN first_attempt INTEGER NOT NULL DEFAULT 1;
63
+ """,
64
+ # 3: whether the run may send data off the machine (--allow-uploads), held with the run so that resume and rerun
65
+ # keep to it; runs from before it was recorded may not (rerun --allow-uploads gives it)
66
+ """
67
+ ALTER TABLE runs ADD COLUMN allow_uploads INTEGER NOT NULL DEFAULT 0;
68
+ """,
69
+ ]
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Artifact:
74
+ hash: str
75
+ shape: str # file | tree | source
76
+ type: str
77
+ path: Path # where it can be read
78
+
79
+ def to_json(self) -> dict:
80
+ return {"hash": self.hash, "shape": self.shape, "type": self.type, "path": str(self.path)}
81
+
82
+ @classmethod
83
+ def from_json(cls, d: dict) -> "Artifact":
84
+ return cls(d["hash"], d["shape"], d["type"], Path(d["path"]))
85
+
86
+
87
+ def _staged_by_live_process(name: str) -> bool:
88
+ """Whether a tmp/ entry is being staged by a process that is still running (its name starts with that pid). Any
89
+ command opens the store (status, plan, nodes, which the app polls), and must not remove a running coordinator's
90
+ publication under it."""
91
+ pid = name.split("-", 1)[0]
92
+ if not pid.isdigit():
93
+ return False # named before staging carried a pid: a leftover
94
+ try:
95
+ os.kill(int(pid), 0)
96
+ except ProcessLookupError:
97
+ return False
98
+ except (PermissionError, OverflowError):
99
+ return True # another user's live process (or a pid we cannot check): left alone
100
+ return True
101
+
102
+
103
+ class Store:
104
+ def __init__(self, root: Path):
105
+ self.root = root.resolve()
106
+ for sub in ("objects", "trees", "work", "tmp"):
107
+ (self.root / sub).mkdir(parents=True, exist_ok=True)
108
+ for leftover in (self.root / "tmp").iterdir(): # staging from a crashed publication, never a live one
109
+ if not _staged_by_live_process(leftover.name):
110
+ shutil.rmtree(leftover) if leftover.is_dir() else leftover.unlink()
111
+ self.db = sqlite3.connect(self.root / "perception.db", isolation_level=None, timeout=30)
112
+ self.db.row_factory = sqlite3.Row
113
+ self.db.execute("PRAGMA journal_mode=WAL")
114
+ self.db.execute("PRAGMA foreign_keys=ON")
115
+ self._migrate()
116
+
117
+ def _migrate(self):
118
+ current = self.db.execute("PRAGMA user_version").fetchone()[0]
119
+ for version, sql in enumerate(MIGRATIONS, start=1):
120
+ if version > current:
121
+ with self.tx(): # executescript would commit on its own; one statement at a time stays in the transaction
122
+ for statement in _statements(sql):
123
+ self.db.execute(statement)
124
+ self.db.execute(f"PRAGMA user_version = {version}")
125
+
126
+ def tx(self):
127
+ return _Tx(self.db)
128
+
129
+ def close(self):
130
+ self.db.close()
131
+
132
+ # --- sources and objects --------------------------------------------------------------------------------
133
+
134
+ def source(self, path: Path, type_id: str) -> Artifact:
135
+ """A file that stays where it is (the video), identified by its sha256."""
136
+ st = path.stat()
137
+ row = self.db.execute("SELECT sha256 FROM sources WHERE path=? AND size=? AND mtime_ns=?",
138
+ (str(path), st.st_size, st.st_mtime_ns)).fetchone()
139
+ sha = row["sha256"] if row else artifacts.sha256_file(path)
140
+ if not row:
141
+ self.db.execute("INSERT OR REPLACE INTO sources VALUES (?,?,?,?)", (str(path), st.st_size, st.st_mtime_ns, sha))
142
+ return Artifact(sha, "source", type_id, path)
143
+
144
+ def object_dir(self, shape: str, h: str) -> Path:
145
+ return self.root / ("trees" if shape == "tree" else "objects") / h[:2] / h
146
+
147
+ def publish(self, path: Path, type_id: str) -> Artifact:
148
+ """Copy a node's output into the store under its content hash. Idempotent: the same content published again
149
+ (under any name) resolves to the object already there."""
150
+ bad = artifacts.special_files(path)
151
+ if bad: # never follow a link out of a node's output: it would publish a host file
152
+ raise ValueError(f"refusing to publish {path.name}: contains a symlink or special file: {', '.join(bad[:5])}")
153
+ shape = "tree" if path.is_dir() else "file"
154
+ staged = self.root / "tmp" / f"{os.getpid()}-{uuid.uuid4().hex}" # named for its process: see Store.__init__
155
+ if shape == "tree":
156
+ shutil.copytree(path, staged, symlinks=True)
157
+ h = artifacts.sha256_tree(staged)
158
+ else:
159
+ staged.mkdir()
160
+ shutil.copyfile(path, staged / path.name)
161
+ h = artifacts.sha256_file(staged / path.name)
162
+ final = self.object_dir(shape, h)
163
+ if final.exists():
164
+ shutil.rmtree(staged)
165
+ else:
166
+ final.parent.mkdir(parents=True, exist_ok=True)
167
+ _fsync(staged)
168
+ _read_only(staged)
169
+ os.replace(staged, final) # atomic: the object appears whole or not at all
170
+ readable = final if shape == "tree" else next(final.iterdir())
171
+ size = sum(p.stat().st_size for p in final.rglob("*") if p.is_file())
172
+ self.db.execute("INSERT OR IGNORE INTO artifacts VALUES (?,?,?,?,?)", (h, shape, type_id, size, time.time()))
173
+ return Artifact(h, shape, type_id, readable)
174
+
175
+ def has(self, a: Artifact) -> bool:
176
+ return a.path.exists()
177
+
178
+ # --- events ---------------------------------------------------------------------------------------------
179
+
180
+ def event(self, run_id: str, node_id: str | None, kind: str, message: str = ""):
181
+ self.db.execute("INSERT INTO events (run_id, node_id, at, kind, message) VALUES (?,?,?,?,?)",
182
+ (run_id, node_id, time.time(), kind, message))
183
+
184
+ def events(self, run_id: str, after: int = 0) -> list[sqlite3.Row]:
185
+ return self.db.execute("SELECT * FROM events WHERE run_id=? AND id>? ORDER BY id", (run_id, after)).fetchall()
186
+
187
+
188
+ class _Tx:
189
+ def __init__(self, db):
190
+ self.db = db
191
+
192
+ def __enter__(self):
193
+ self.db.execute("BEGIN IMMEDIATE")
194
+ return self.db
195
+
196
+ def __exit__(self, exc_type, exc, tb):
197
+ self.db.execute("ROLLBACK" if exc_type else "COMMIT")
198
+ return False
199
+
200
+
201
+ def _statements(sql: str) -> list[str]:
202
+ return [s.strip() for s in sql.split(";") if s.strip() and not all(l.strip().startswith("--") for l in s.strip().splitlines())]
203
+
204
+
205
+ def _fsync(path: Path):
206
+ files = [p for p in path.rglob("*") if p.is_file()] if path.is_dir() else [path]
207
+ for p in files:
208
+ with open(p, "rb") as f:
209
+ os.fsync(f.fileno())
210
+
211
+
212
+ def _read_only(path: Path):
213
+ files = [p for p in path.rglob("*") if p.is_file()] if path.is_dir() else [path]
214
+ for p in files:
215
+ p.chmod(p.stat().st_mode & ~(stat.S_IWUSR | stat.S_IWGRP | stat.S_IWOTH))
216
+
217
+
218
+ def dumps(value) -> str:
219
+ return json.dumps(value, separators=(",", ":"))