hyperstudy-perception 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. hyperstudy_perception-0.1.0.dist-info/METADATA +53 -0
  2. hyperstudy_perception-0.1.0.dist-info/RECORD +82 -0
  3. hyperstudy_perception-0.1.0.dist-info/WHEEL +4 -0
  4. hyperstudy_perception-0.1.0.dist-info/entry_points.txt +2 -0
  5. hyperstudy_perception-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. perception/__init__.py +0 -0
  7. perception/_pipeline/RELEASE.json +59 -0
  8. perception/_pipeline/cloud/runpod/guard.sh +37 -0
  9. perception/_pipeline/cloud/runpod/image/Dockerfile +39 -0
  10. perception/_pipeline/cloud/runpod/image/start.sh +11 -0
  11. perception/_pipeline/cloud/runpod/image/tag.py +40 -0
  12. perception/_pipeline/cloud/skypilot/gpu-faces.yaml +39 -0
  13. perception/_pipeline/cloud/skypilot/gpu-tune.yaml +55 -0
  14. perception/_pipeline/cloud/skypilot/guard.sh +20 -0
  15. perception/_pipeline/cloud/skypilot/iam-perception-node.json +18 -0
  16. perception/_pipeline/cloud/skypilot/launch.sh +63 -0
  17. perception/_pipeline/cloud/skypilot/node-gpu.yaml +25 -0
  18. perception/_pipeline/cloud/skypilot/node-setup.sh +22 -0
  19. perception/_pipeline/cloud/skypilot/sky.yaml +7 -0
  20. perception/_pipeline/cloud/skypilot/smoke.yaml +28 -0
  21. perception/_pipeline/envs/api/pyproject.toml +12 -0
  22. perception/_pipeline/envs/api/uv.lock +276 -0
  23. perception/_pipeline/envs/audio/pyproject.toml +12 -0
  24. perception/_pipeline/envs/audio/uv.lock +575 -0
  25. perception/_pipeline/envs/core/pyproject.toml +12 -0
  26. perception/_pipeline/envs/core/uv.lock +157 -0
  27. perception/_pipeline/envs/faces/pyproject.toml +12 -0
  28. perception/_pipeline/envs/faces/uv.lock +1180 -0
  29. perception/_pipeline/graphs/decode.yaml +11 -0
  30. perception/_pipeline/graphs/faces.yaml +14 -0
  31. perception/_pipeline/graphs/full.yaml +31 -0
  32. perception/_pipeline/graphs/mvp-captions.yaml +27 -0
  33. perception/_pipeline/graphs/mvp.yaml +17 -0
  34. perception/_pipeline/nodes/audio/asr/manifest.yaml +20 -0
  35. perception/_pipeline/nodes/audio/asr/node.py +74 -0
  36. perception/_pipeline/nodes/audio/asr/prepare.py +12 -0
  37. perception/_pipeline/nodes/audio/asr/test_wav.py +38 -0
  38. perception/_pipeline/nodes/audio/asr/wav.py +19 -0
  39. perception/_pipeline/nodes/conftest.py +64 -0
  40. perception/_pipeline/nodes/core/decode/manifest.yaml +19 -0
  41. perception/_pipeline/nodes/core/decode/media.py +104 -0
  42. perception/_pipeline/nodes/core/decode/node.py +56 -0
  43. perception/_pipeline/nodes/core/decode/test_media.py +65 -0
  44. perception/_pipeline/nodes/testdata/README.md +10 -0
  45. perception/_pipeline/nodes/testdata/rotated-90.mp4 +0 -0
  46. perception/_pipeline/nodes/vision/face-track/frames.py +88 -0
  47. perception/_pipeline/nodes/vision/face-track/loader.py +18 -0
  48. perception/_pipeline/nodes/vision/face-track/manifest.yaml +30 -0
  49. perception/_pipeline/nodes/vision/face-track/node.py +117 -0
  50. perception/_pipeline/nodes/vision/face-track/prepare.py +8 -0
  51. perception/_pipeline/nodes/vision/face-track/test_frames.py +46 -0
  52. perception/_pipeline/nodes/vision/face-track/test_loader.py +16 -0
  53. perception/_pipeline/nodes/vision/face-track/test_thumbnails.py +66 -0
  54. perception/_pipeline/nodes/vision/face-track/thumbnails.py +68 -0
  55. perception/_pipeline/nodes/vision/face-track/tracking.py +150 -0
  56. perception/_pipeline/nodes/vlm/shot-captions/manifest.yaml +29 -0
  57. perception/_pipeline/nodes/vlm/shot-captions/node.py +185 -0
  58. perception/_pipeline/schema/README.md +54 -0
  59. perception/_pipeline/schema/bundle-0.1.schema.json +371 -0
  60. perception/_pipeline/sdk/pyproject.toml +13 -0
  61. perception/_pipeline/sdk/src/perception_node/__init__.py +157 -0
  62. perception/artifacts.py +180 -0
  63. perception/backends/__init__.py +0 -0
  64. perception/backends/base.py +179 -0
  65. perception/backends/cloud.py +214 -0
  66. perception/backends/native.py +140 -0
  67. perception/backends/probe.py +44 -0
  68. perception/backends/remote.py +140 -0
  69. perception/cli.py +780 -0
  70. perception/estimates.py +68 -0
  71. perception/export.py +100 -0
  72. perception/hosts.py +201 -0
  73. perception/ir.py +245 -0
  74. perception/keys.py +259 -0
  75. perception/manifests.py +169 -0
  76. perception/plan.py +170 -0
  77. perception/release.py +61 -0
  78. perception/remote_driver.py +990 -0
  79. perception/runpod.py +100 -0
  80. perception/scheduler.py +552 -0
  81. perception/setup.py +72 -0
  82. perception/store.py +219 -0
@@ -0,0 +1,68 @@
1
+ """How long a node will take, from how long it took here: seconds per minute of video, per node and place, measured
2
+ from this store's finished runs. A node that has not run on this machine in that place gets no estimate, not a guess.
3
+
4
+ The video's length comes from a run's media.v1 artifact (decode writes it), or for a run being planned, from its
5
+ cached decode or the file itself, read with PyAV as decode reads it (no ffprobe on the machine: an app started from
6
+ the Finder has none on its PATH).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import statistics
13
+ from pathlib import Path
14
+
15
+ from .store import Artifact, Store
16
+
17
+
18
+ def media_seconds(outputs: dict) -> float | None:
19
+ """The video length in a media.v1 output, if these outputs hold one."""
20
+ for a in outputs.values():
21
+ art = Artifact.from_json(a)
22
+ if art.type == "media.v1" and art.path.is_file():
23
+ duration = json.loads(art.path.read_text()).get("duration")
24
+ if duration:
25
+ return float(duration)
26
+ return None
27
+
28
+
29
+ def run_seconds(store: Store, run_id: str) -> float | None:
30
+ for t in store.db.execute("SELECT outputs FROM tasks WHERE run_id=? AND outputs IS NOT NULL", (run_id,)):
31
+ seconds = media_seconds(json.loads(t["outputs"]))
32
+ if seconds:
33
+ return seconds
34
+ return None
35
+
36
+
37
+ def probe_seconds(video: Path) -> float | None:
38
+ """A video's length, as decode's media.v1 will say it (the container's duration), when no decode output says it
39
+ yet; None for a file that is not a video."""
40
+ import av
41
+
42
+ if not video.is_file():
43
+ return None
44
+ try:
45
+ with av.open(str(video)) as container:
46
+ return container.duration / av.time_base if container.duration else None
47
+ except av.FFmpegError:
48
+ return None
49
+
50
+
51
+ def estimate(store: Store, ref: str, place: str, video_seconds: float | None) -> tuple[float | None, str]:
52
+ """Seconds this node should take here on a video this long, and what that rests on."""
53
+ rows = store.db.execute(
54
+ """SELECT t.run_id, t.usage FROM tasks t JOIN cache c ON c.key = t.cache_key
55
+ WHERE t.state = 'succeeded' AND c.node = ? AND t.runner = ?""", (ref, place)).fetchall()
56
+ rates = []
57
+ for r in rows:
58
+ # the seconds the node itself ran (the SDK reports them), not the coordinator's clock: a node that finished
59
+ # while no coordinator watched would otherwise count that time too
60
+ ran = json.loads(r["usage"] or "{}").get("seconds")
61
+ seconds = run_seconds(store, r["run_id"])
62
+ if ran and seconds:
63
+ rates.append(ran / (seconds / 60))
64
+ if not rates:
65
+ return None, f"no runs of {ref} on {place} here yet"
66
+ if not video_seconds:
67
+ return None, f"the video's length is not known yet ({len(rates)} runs of {ref} on {place} here)"
68
+ return round(statistics.median(rates) * video_seconds / 60, 1), f"median of {len(rates)} runs of {ref} on {place} here"
perception/export.py ADDED
@@ -0,0 +1,100 @@
1
+ """Publishing a run as a track bundle (schema/bundle-0.1): the view the annotator and HyperStudy read.
2
+
3
+ A bundle is not a second copy of the truth. Its layers are the run's committed `bundle.<layer>@0.1` artifacts, copied
4
+ byte for byte, and its manifest records their checksums, the video (from the run's `media.v1` artifact) and each
5
+ layer's licence mode: the strictest of its node's models and of every node upstream of it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ import time
14
+ from pathlib import Path
15
+
16
+ from . import artifacts
17
+ from .release import release
18
+ from .scheduler import Coordinator, LICENCE_ORDER
19
+
20
+ SCHEMA_VERSION = "0.1.0"
21
+
22
+
23
+ def _git_commit() -> str | None:
24
+ if r := release():
25
+ return r["commit"][:7]
26
+ out = subprocess.run(["git", "-C", str(Path(__file__).parent), "rev-parse", "--short", "HEAD"], capture_output=True, text=True)
27
+ return out.stdout.strip() or None
28
+
29
+
30
+ def bundle(coord: Coordinator, run_id: str, out: Path) -> Path:
31
+ graph = coord.graph_of(run_id)
32
+ tasks = coord._tasks(run_id)
33
+ not_done = [i for i, t in tasks.items() if t["state"] not in ("succeeded", "cached")]
34
+ if not_done:
35
+ raise SystemExit(f"run {run_id} has unfinished nodes: {', '.join(not_done)}")
36
+ if (out / "manifest.json").exists():
37
+ raise SystemExit(f"{out} already holds a bundle")
38
+
39
+ own = {i: max([x.licence_mode for x in n.manifest.models] or ["commercial-ok"], key=LICENCE_ORDER.index) for i, n in graph.nodes.items()}
40
+ effective: dict[str, str] = {}
41
+
42
+ def licence(i: str) -> str:
43
+ if i not in effective:
44
+ effective[i] = max([own[i], *(licence(u) for u in graph.nodes[i].upstream)], key=LICENCE_ORDER.index)
45
+ return effective[i]
46
+
47
+ media, layers, asset_trees, writers = None, {}, [], {}
48
+ for node_id, t in tasks.items():
49
+ for name, a in json.loads(t["outputs"]).items():
50
+ kind = artifacts.get(a["type"])
51
+ if a["type"] == "assets.v1":
52
+ asset_trees.append(Path(a["path"]))
53
+ elif a["type"] == "media.v1":
54
+ media = json.loads(Path(a["path"]).read_text())
55
+ elif kind.layer:
56
+ writers.setdefault(kind.layer, []).append((node_id, Path(a["path"])))
57
+ for layer_name, ws in writers.items():
58
+ # a node that refines an upstream node's layer (captions on the shots layer) supersedes it; two unrelated
59
+ # writers of one layer are a graph error
60
+ last = [w for w in ws if not any(o[0] in graph.descendants(w[0]) for o in ws)] # no other writer downstream
61
+ if len(last) != 1 or any(o is not last[0] and last[0][0] not in graph.descendants(o[0]) for o in ws):
62
+ raise SystemExit(f"the {layer_name} layer is written by unrelated nodes: {', '.join(w[0] for w in ws)}")
63
+ layers[layer_name] = last[0]
64
+ if media is None:
65
+ raise SystemExit("the run has no media.v1 artifact (a decode node): the bundle cannot name its video")
66
+
67
+ (out / "layers").mkdir(parents=True)
68
+ entries = {}
69
+ for name, (node_id, path) in layers.items():
70
+ dest = out / "layers" / f"{name}.json"
71
+ # stored layers carry no run time or timings (they would defeat the cache); the bundle gets them from the run
72
+ # that produced the layer, which for a cache hit is the original run
73
+ usage = json.loads(tasks[node_id]["usage"] or "{}")
74
+ doc = json.loads(path.read_text())
75
+ doc["producer"]["run_at"] = usage.get("finished_at")
76
+ doc["producer"]["timings"] = {**usage.get("timings", {}), "seconds": usage.get("seconds")}
77
+ dest.write_text(json.dumps(doc, separators=(",", ":")))
78
+ entries[name] = {"file": f"layers/{name}.json", "sha256": artifacts.sha256_file(dest), "bytes": dest.stat().st_size,
79
+ "licence_mode": licence(node_id), "stage": graph.nodes[node_id].manifest.stage or graph.nodes[node_id].manifest.id}
80
+ assets = []
81
+ for tree in asset_trees: # each node's assets tree merges into assets/; layers refer to "assets/<path>"
82
+ for src in sorted(p for p in tree.rglob("*") if p.is_file()):
83
+ rel = src.relative_to(tree).as_posix()
84
+ dest = out / "assets" / rel
85
+ if dest.exists():
86
+ raise SystemExit(f"two nodes write assets/{rel}")
87
+ dest.parent.mkdir(parents=True, exist_ok=True)
88
+ shutil.copyfile(src, dest)
89
+ dest.chmod(0o644)
90
+ assets.append({"file": f"assets/{rel}", "sha256": artifacts.sha256_file(dest), "bytes": dest.stat().st_size})
91
+ manifest = {
92
+ "format": "hyperstudy-perception-bundle", "schema_version": SCHEMA_VERSION,
93
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime()),
94
+ "video": {k: media[k] for k in ("sha256", "duration", "fps", "width", "height")} | ({"filename": media["filename"]} if media.get("filename") else {}),
95
+ "pipeline": {"name": "hyperstudy-perception", "version": "0.1.0", "git_commit": _git_commit(), "source": f"perception run {run_id}"},
96
+ "layers": entries,
97
+ "assets": assets,
98
+ }
99
+ (out / "manifest.json").write_text(json.dumps(manifest, indent=1))
100
+ return out
perception/hosts.py ADDED
@@ -0,0 +1,201 @@
1
+ """Other machines nodes can run on: lab servers reached over SSH (`perception hosts add NAME DEST`).
2
+
3
+ A host is probed when it is added (and by `hosts check`): its platform, cores, memory, GPU and tools are recorded in
4
+ $PERCEPTION_HOME/hosts.json, so planning never waits on the network. SSH runs in batch mode with the user's own keys
5
+ and ~/.ssh/config: nothing here asks for or stores a password. Connections are shared (ControlMaster), so the many short
6
+ commands a remote node needs cost one handshake.
7
+
8
+ On the host everything lives under its root (default ~/.cache/hyperstudy-perception), which is private: created 0700, and
9
+ refused if someone else owns it, so the code, inputs and markers kept there can be trusted and are not readable by others:
10
+ code/<hash>/ the files git tracks for the node, its uv project and the node SDK, by content
11
+ objects/<sha256>/data inputs, by content: the video is sent once per host (each attempt links it under its name)
12
+ work/<run>/<node>/<attempt>/ the request, out/, response.json, log.txt and pid of one attempt: removed after it,
13
+ or by the host's watchdog when no driver has asked about it for a while (a finished node's
14
+ results wait a day for its driver)
15
+ An attempt's unfinished uploads (objects/.incoming-*, code/.incoming-*) are removed when it ends.
16
+ `perception hosts clean NAME` removes the root's inputs and code (what stays on the host between runs), unless a node
17
+ is running there or the root is not marked as perception's. A host with a node running there is not removed either: that node could no longer be stopped.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import re
25
+ import subprocess
26
+ import time
27
+ from dataclasses import asdict, dataclass
28
+ from pathlib import Path
29
+
30
+ from .backends.base import Capabilities
31
+
32
+ NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}$")
33
+ # an ssh destination: user@host, a host, or a ~/.ssh/config alias; never an option
34
+ DEST = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.@%+:\[\]-]*$")
35
+ # a path on the host: plain characters, no `..`; relative ones are under the host's home
36
+ ROOT = re.compile(r"^[A-Za-z0-9_./~-]+$")
37
+ DEFAULT_ROOT = ".cache/hyperstudy-perception"
38
+ RESERVED = {"native", "self", "cloud", "runpod", "local"}
39
+
40
+ # What the host has, as key=value lines. POSIX sh: Linux servers and Macs, no Python needed. Tools are looked for on the
41
+ # PATH a login shell would add too (~/.local/bin is where uv installs itself), since ssh commands get a minimal PATH.
42
+ PROBE = r"""
43
+ PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
44
+ echo "home=$HOME"
45
+ echo "system=$(uname -s)"
46
+ echo "machine=$(uname -m)"
47
+ echo "cores=$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu)"
48
+ if [ -r /proc/meminfo ]; then echo "memory_kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo)"
49
+ else echo "memory_kb=$(( $(sysctl -n hw.memsize) / 1024 ))"; fi
50
+ for t in uv ffmpeg ffprobe; do echo "tool_$t=$(command -v $t || true)"; done
51
+ if command -v nvidia-smi >/dev/null 2>&1; then
52
+ echo "vram_mib=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | sort -n | tail -1)"
53
+ fi
54
+ """
55
+
56
+
57
+ class HostError(RuntimeError):
58
+ pass
59
+
60
+
61
+ @dataclass
62
+ class Host:
63
+ name: str
64
+ dest: str
65
+ root: str # absolute, on the host
66
+ caps: Capabilities
67
+ probed_at: float
68
+
69
+ def to_json(self) -> dict:
70
+ d = asdict(self)
71
+ d["caps"]["accelerators"] = sorted(self.caps.accelerators)
72
+ return d
73
+
74
+ @classmethod
75
+ def from_json(cls, d: dict) -> "Host":
76
+ caps = Capabilities(**{**d["caps"], "accelerators": set(d["caps"]["accelerators"])})
77
+ return cls(d["name"], d["dest"], d["root"], caps, d["probed_at"])
78
+
79
+
80
+ def ssh_command() -> list[str]:
81
+ """ssh with the options every remote command uses. PERCEPTION_SSH replaces the ssh program (the tests use a
82
+ stand-in that runs commands on this machine)."""
83
+ control = Path(f"/tmp/perception-ssh-{os.getuid()}") # short: a socket path must fit in 104 bytes on macOS
84
+ control.mkdir(mode=0o700, exist_ok=True)
85
+ st = control.stat()
86
+ if st.st_uid != os.getuid() or st.st_mode & 0o077:
87
+ raise HostError(f"{control} must be a directory only you can use")
88
+ return [os.environ.get("PERCEPTION_SSH", "ssh"), "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
89
+ "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4",
90
+ "-o", "ControlMaster=auto", "-o", f"ControlPath={control}/%C", "-o", "ControlPersist=120"]
91
+
92
+
93
+ def check_names(name: str, dest: str, root: str):
94
+ if not NAME.match(name) or name in RESERVED:
95
+ raise HostError(f"a host's name is lower-case letters, digits and -, and not {', '.join(sorted(RESERVED))}: {name!r}")
96
+ if not DEST.match(dest):
97
+ raise HostError(f"not an ssh destination: {dest!r}")
98
+ parts = [x for x in root.removeprefix("~/").split("/") if x not in ("", ".")]
99
+ if not ROOT.match(root) or ".." in parts or "~" in root.removeprefix("~/") or not parts:
100
+ raise HostError(f"a host's root is a plain path below / or ~ (its own folder: `hosts clean` empties it): {root!r}")
101
+
102
+
103
+ # The root is perception's own and private. It is used only if it is missing, empty or already marked as perception's
104
+ # (MARKER): a folder of the user's own given by mistake is never changed or cleaned. It is created 0700, and refused if
105
+ # someone else owns it: only the user can then write the code, inputs and completion markers kept there, so they are
106
+ # trusted across runs, and others on a shared server cannot read the inputs.
107
+ MARKER = ".hyperstudy-perception"
108
+ PRIVATE_ROOT = r"""
109
+ umask 077
110
+ # the contents first, then the marker: perception writes there only after the marker exists, and never removes it, so
111
+ # a root that another attempt is setting up at this moment is never taken for someone else's
112
+ if [ -d "$root" ] && [ -n "$(ls -A "$root")" ] && [ ! -e "$root/.hyperstudy-perception" ]; then
113
+ echo "$root already holds other files: give perception a folder of its own (--root)" >&2; exit 3
114
+ fi
115
+ mkdir -p "$root"
116
+ if [ "$(ls -ldn "$root" | awk '{print $3}')" != "$(id -u)" ]; then echo "$root belongs to someone else" >&2; exit 3; fi
117
+ chmod 700 "$root"
118
+ touch "$root/.hyperstudy-perception"
119
+ """
120
+
121
+
122
+ def private_root_script(root: str) -> str:
123
+ """sh that makes `root` (absolute, or under the home) the user's own private folder, or fails."""
124
+ import shlex
125
+ path = shlex.quote(root) if root.startswith("/") else '"$HOME"/' + shlex.quote(root.removeprefix("~/"))
126
+ return f"root={path}\n" + PRIVATE_ROOT
127
+
128
+
129
+ def probe(dest: str, root: str = DEFAULT_ROOT, ssh: list[str] | None = None) -> tuple[Capabilities, str]:
130
+ """What the host has, and its root as an absolute path (made private there, or refused: PRIVATE_ROOT). `ssh`: the
131
+ command to reach it, when it needs options of its own (a RunPod pod's key and port)."""
132
+ out = subprocess.run([*(ssh or ssh_command()), dest, "sh -s"], input=private_root_script(root) + PROBE, capture_output=True,
133
+ text=True, timeout=120)
134
+ if out.returncode:
135
+ raise HostError(f"could not reach {dest} (ssh exit {out.returncode}): {out.stderr.strip()[-500:]}")
136
+ facts = dict(line.split("=", 1) for line in out.stdout.splitlines() if "=" in line)
137
+ try:
138
+ system = {"Linux": "linux", "Darwin": "darwin"}[facts["system"]]
139
+ arch = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"}[facts["machine"]]
140
+ memory = int(facts["memory_kb"]) / 2**20
141
+ cores = int(facts["cores"])
142
+ except (KeyError, ValueError) as e:
143
+ raise HostError(f"{dest} answered, but not as expected ({e}): {out.stdout[-500:]}")
144
+ accelerators, vram = {"cpu"}, 0.0
145
+ if facts.get("vram_mib", "").strip().isdigit():
146
+ accelerators.add("cuda")
147
+ vram = int(facts["vram_mib"]) / 1024
148
+ if (system, arch) == ("darwin", "arm64"):
149
+ accelerators.add("mps")
150
+ vram = vram or memory # unified memory
151
+ caps = Capabilities(platform=f"{system}/{arch}", cpu_cores=cores, memory_gb=round(memory, 1), accelerators=accelerators,
152
+ vram_gb=round(vram, 1), tools={t: facts.get(f"tool_{t}") or None for t in ("uv", "ffmpeg", "ffprobe")})
153
+ home = facts["home"]
154
+ absolute = (root if root.startswith("/") else f"{home}/{root.removeprefix('~/')}").rstrip("/")
155
+ # plain characters in the whole path, the host's home too: rsync passes remote paths through the host's shell
156
+ if not ROOT.match(absolute):
157
+ raise HostError(f"{dest}'s folder for perception would be {absolute!r}, which is not a plain path (spaces?): give --root /a/plain/path")
158
+ return caps, absolute
159
+
160
+
161
+ class Hosts:
162
+ """hosts.json in the store's home."""
163
+
164
+ def __init__(self, home: Path):
165
+ self.path = home / "hosts.json"
166
+
167
+ def load(self) -> dict[str, Host]:
168
+ if not self.path.exists():
169
+ return {}
170
+ return {name: Host.from_json(d) for name, d in json.loads(self.path.read_text()).items()}
171
+
172
+ def save(self, hosts: dict[str, Host]):
173
+ self.path.parent.mkdir(parents=True, exist_ok=True)
174
+ tmp = self.path.with_suffix(".tmp")
175
+ tmp.write_text(json.dumps({n: h.to_json() for n, h in sorted(hosts.items())}, indent=1))
176
+ os.replace(tmp, self.path)
177
+
178
+ def add(self, name: str, dest: str, root: str = DEFAULT_ROOT) -> Host:
179
+ check_names(name, dest, root)
180
+ caps, absolute = probe(dest, root)
181
+ host = Host(name, dest, absolute, caps, time.time())
182
+ hosts = self.load()
183
+ hosts[name] = host
184
+ self.save(hosts)
185
+ return host
186
+
187
+ def check(self, name: str) -> Host:
188
+ hosts = self.load()
189
+ if name not in hosts:
190
+ raise HostError(f"no host {name!r} (perception hosts list)")
191
+ old = hosts[name]
192
+ caps, _ = probe(old.dest, old.root)
193
+ hosts[name] = Host(name, old.dest, old.root, caps, time.time())
194
+ self.save(hosts)
195
+ return hosts[name]
196
+
197
+ def remove(self, name: str):
198
+ hosts = self.load()
199
+ if hosts.pop(name, None) is None:
200
+ raise HostError(f"no host {name!r}")
201
+ self.save(hosts)
perception/ir.py ADDED
@@ -0,0 +1,245 @@
1
+ """Graph files: parse, check against the node manifests, and compile into an ordered graph.
2
+
3
+ schema_version: 1
4
+ pipeline: perception-mvp
5
+ inputs:
6
+ video: {type: video.v1, uri: 'file:///data/clip.mp4'}
7
+ policy: {privacy: local-only, max_cost_usd: 0, retries: 2}
8
+ nodes:
9
+ - id: decode
10
+ uses: core.decode@1.0.0
11
+ inputs: {video: $inputs.video}
12
+ - id: faces
13
+ uses: vision.face-track@1.0.0
14
+ inputs: {video: $inputs.video, shots: $nodes.decode.shots}
15
+ config: {fps: 8}
16
+
17
+ References are `$inputs.<name>` or `$nodes.<node id>.<output name>`. Compilation checks that every reference
18
+ exists, that its artifact type is the one the consuming node's manifest declares, that every declared input is
19
+ supplied, that config keys are known and valid, and that the graph has no cycles. The privacy policy is checked here
20
+ too, so a graph that would upload what it may not never starts.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from typing import Literal
29
+ from urllib.parse import unquote, urlparse
30
+
31
+ import yaml
32
+ from jsonschema import Draft202012Validator
33
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
34
+
35
+ from . import artifacts
36
+ from .manifests import Manifest, Registry
37
+
38
+ REF = re.compile(r"^\$(inputs)\.([A-Za-z0-9_-]+)$|^\$nodes\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$")
39
+
40
+
41
+ class GraphError(Exception):
42
+ def __init__(self, problems: list[str]):
43
+ super().__init__("\n".join(problems))
44
+ self.problems = problems
45
+
46
+
47
+ class Strict(BaseModel):
48
+ model_config = ConfigDict(extra="forbid")
49
+
50
+
51
+ class InputSpec(Strict):
52
+ type: str
53
+ uri: str
54
+
55
+
56
+ UploadKind = Literal["video", "audio", "frames", "faces", "transcript", "embeddings"]
57
+ UPLOAD_KINDS = UploadKind.__args__
58
+
59
+
60
+ class Policy(Strict):
61
+ placement: Literal["local", "hybrid", "remote"] = "local"
62
+ # local-only: nothing leaves the machine. Otherwise a node may send out only the kinds of data allow_upload names
63
+ # (spec: video, audio, frames, faces, transcripts and embeddings are permitted separately).
64
+ privacy: Literal["local-only", "local-preferred", "allow-external"] = "local-only"
65
+ allow_upload: list[UploadKind] = Field(default_factory=list)
66
+ max_cost_usd: float = 0.0
67
+ retries: int = Field(default=2, ge=0, le=10)
68
+
69
+
70
+ class NodeSpec(Strict):
71
+ id: str = Field(pattern=r"^[A-Za-z0-9_-]+$")
72
+ uses: str = Field(pattern=r"^[a-z0-9_.-]+@\d+\.\d+\.\d+$")
73
+ inputs: dict[str, str] = Field(default_factory=dict)
74
+ config: dict = Field(default_factory=dict)
75
+ backend: Literal["auto", "native", "self"] = "auto"
76
+ # where it runs, among the places it can run (`perception plan` lists them): native:mps, native:cpu, ssh-gpu1:cuda...
77
+ place: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9-]*:[a-z]+$")
78
+
79
+
80
+ class GraphSpec(Strict):
81
+ schema_version: Literal[1]
82
+ pipeline: str
83
+ inputs: dict[str, InputSpec]
84
+ outputs: dict[str, str] = Field(default_factory=dict)
85
+ policy: Policy = Field(default_factory=Policy)
86
+ nodes: list[NodeSpec]
87
+
88
+
89
+ @dataclass
90
+ class Ref:
91
+ node: str | None # None: a graph input
92
+ name: str
93
+
94
+ def __str__(self):
95
+ return f"$inputs.{self.name}" if self.node is None else f"$nodes.{self.node}.{self.name}"
96
+
97
+
98
+ @dataclass
99
+ class CompiledNode:
100
+ spec: NodeSpec
101
+ manifest: Manifest
102
+ inputs: dict[str, Ref]
103
+ config: dict # manifest defaults + the graph's values
104
+ upstream: set[str] = field(default_factory=set)
105
+
106
+ @property
107
+ def id(self) -> str:
108
+ return self.spec.id
109
+
110
+
111
+ @dataclass
112
+ class Graph:
113
+ spec: GraphSpec
114
+ nodes: dict[str, CompiledNode] # in topological order
115
+ source: Path | None = None
116
+
117
+ def input_path(self, name: str) -> Path:
118
+ uri = self.spec.inputs[name].uri
119
+ parsed = urlparse(uri)
120
+ if parsed.scheme not in ("file", ""):
121
+ raise GraphError([f"input {name}: only file:// inputs are supported locally ({uri})"])
122
+ path = Path(unquote(parsed.path)) if parsed.scheme else Path(uri)
123
+ if not path.is_absolute() and self.source:
124
+ path = self.source.parent / path
125
+ return path
126
+
127
+ def descendants(self, node_id: str) -> set[str]:
128
+ out, frontier = set(), {node_id}
129
+ while frontier:
130
+ frontier = {n.id for n in self.nodes.values() if n.upstream & frontier} - out
131
+ out |= frontier
132
+ return out
133
+
134
+
135
+ def parse_ref(text: str) -> Ref | None:
136
+ m = REF.match(text)
137
+ if not m:
138
+ return None
139
+ return Ref(None, m.group(2)) if m.group(1) else Ref(m.group(3), m.group(4))
140
+
141
+
142
+ def load(path: Path, registry: Registry) -> Graph:
143
+ doc = yaml.safe_load(path.read_text())
144
+ graph = compile_graph(doc, registry)
145
+ graph.source = path.resolve()
146
+ return graph
147
+
148
+
149
+ def compile_graph(doc: dict, registry: Registry) -> Graph:
150
+ try:
151
+ spec = GraphSpec.model_validate(doc)
152
+ except ValidationError as e:
153
+ raise GraphError([f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors()]) from None
154
+
155
+ problems: list[str] = []
156
+ for name, inp in spec.inputs.items():
157
+ if inp.type not in artifacts.TYPES:
158
+ problems.append(f"input {name}: unknown artifact type {inp.type}")
159
+ ids = [n.id for n in spec.nodes]
160
+ for dup in {i for i in ids if ids.count(i) > 1}:
161
+ problems.append(f"node id {dup} is used twice")
162
+
163
+ manifests: dict[str, Manifest] = {}
164
+ for n in spec.nodes:
165
+ try:
166
+ manifests[n.id] = registry.get(n.uses)
167
+ except KeyError as e:
168
+ problems.append(f"node {n.id}: {e.args[0]}")
169
+
170
+ compiled: dict[str, CompiledNode] = {}
171
+ for n in spec.nodes:
172
+ m = manifests.get(n.id)
173
+ if m is None:
174
+ continue
175
+ refs: dict[str, Ref] = {}
176
+ for name in n.inputs.keys() - m.inputs.keys():
177
+ problems.append(f"node {n.id}: {m.ref} has no input {name!r} (inputs: {', '.join(m.inputs) or 'none'})")
178
+ for name, want in m.inputs.items():
179
+ text = n.inputs.get(name)
180
+ if text is None:
181
+ problems.append(f"node {n.id}: input {name!r} ({want}) is not connected")
182
+ continue
183
+ ref = parse_ref(text)
184
+ if ref is None:
185
+ problems.append(f"node {n.id}: input {name}: {text!r} is not $inputs.<name> or $nodes.<id>.<output>")
186
+ continue
187
+ if ref.node is None:
188
+ got = spec.inputs[ref.name].type if ref.name in spec.inputs else None
189
+ if got is None:
190
+ problems.append(f"node {n.id}: input {name}: graph has no input {ref.name!r}")
191
+ else:
192
+ src = manifests.get(ref.node)
193
+ if ref.node not in ids:
194
+ problems.append(f"node {n.id}: input {name}: no node {ref.node!r}")
195
+ continue
196
+ if src is None:
197
+ continue
198
+ got = src.outputs.get(ref.name)
199
+ if got is None:
200
+ problems.append(f"node {n.id}: input {name}: {src.ref} has no output {ref.name!r} (outputs: {', '.join(src.outputs)})")
201
+ if got is not None and got != want:
202
+ problems.append(f"node {n.id}: input {name} wants {want} but {text} is {got}")
203
+ refs[name] = ref
204
+
205
+ for key in n.config.keys() - m.config.keys():
206
+ problems.append(f"node {n.id}: {m.ref} has no config {key!r} (config: {', '.join(m.config) or 'none'})")
207
+ config = {**m.defaults(), **{k: v for k, v in n.config.items() if k in m.config}}
208
+ for key, value in config.items():
209
+ for err in Draft202012Validator(m.config[key]).iter_errors(value):
210
+ problems.append(f"node {n.id}: config {key}: {err.message}")
211
+
212
+ if spec.policy.privacy == "local-only" and m.privacy.external_upload:
213
+ problems.append(f"node {n.id}: {m.ref} uploads {', '.join(m.privacy.external_upload)}, but the graph's privacy policy is local-only")
214
+ elif not_allowed := [k for k in m.privacy.external_upload if k not in spec.policy.allow_upload]:
215
+ problems.append(f"node {n.id}: {m.ref} uploads {', '.join(not_allowed)}, which policy.allow_upload does not permit")
216
+
217
+ compiled[n.id] = CompiledNode(n, m, refs, config, {r.node for r in refs.values() if r.node})
218
+
219
+ order = _topological(compiled, problems)
220
+ if problems:
221
+ raise GraphError(problems)
222
+ return Graph(spec, {i: compiled[i] for i in order})
223
+
224
+
225
+ def _topological(nodes: dict[str, CompiledNode], problems: list[str]) -> list[str]:
226
+ order, done, visiting = [], set(), []
227
+
228
+ def visit(i: str):
229
+ if i in done:
230
+ return
231
+ if i in visiting:
232
+ cycle = visiting[visiting.index(i):] + [i]
233
+ problems.append(f"cycle: {' -> '.join(cycle)}")
234
+ return
235
+ visiting.append(i)
236
+ for up in sorted(nodes[i].upstream):
237
+ if up in nodes:
238
+ visit(up)
239
+ visiting.pop()
240
+ done.add(i)
241
+ order.append(i)
242
+
243
+ for i in nodes:
244
+ visit(i)
245
+ return order