inline-core 1.1.1__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 (77) hide show
  1. inline_core/__init__.py +7 -0
  2. inline_core/components/__init__.py +1 -0
  3. inline_core/components/conditioning.py +20 -0
  4. inline_core/components/interfaces.py +91 -0
  5. inline_core/config.py +33 -0
  6. inline_core/device/__init__.py +1 -0
  7. inline_core/device/auto.py +35 -0
  8. inline_core/device/detect.py +41 -0
  9. inline_core/device/memory.py +168 -0
  10. inline_core/device/policy.py +82 -0
  11. inline_core/device/types.py +31 -0
  12. inline_core/errors.py +42 -0
  13. inline_core/graph/__init__.py +1 -0
  14. inline_core/graph/cache.py +83 -0
  15. inline_core/graph/descriptor.py +81 -0
  16. inline_core/graph/executor.py +102 -0
  17. inline_core/graph/primitives.py +137 -0
  18. inline_core/graph/registry.py +61 -0
  19. inline_core/graph/runners.py +72 -0
  20. inline_core/graph/schema.py +136 -0
  21. inline_core/graph/topo.py +49 -0
  22. inline_core/graph/validate.py +56 -0
  23. inline_core/importer/__init__.py +1 -0
  24. inline_core/importer/comfy.py +162 -0
  25. inline_core/media.py +11 -0
  26. inline_core/models/__init__.py +8 -0
  27. inline_core/models/catalog.py +98 -0
  28. inline_core/models/zimage/__init__.py +11 -0
  29. inline_core/models/zimage/requirements.py +180 -0
  30. inline_core/models/zimage/runner.py +386 -0
  31. inline_core/parallel/__init__.py +13 -0
  32. inline_core/parallel/config.py +50 -0
  33. inline_core/parallel/group.py +136 -0
  34. inline_core/parallel/launch.py +44 -0
  35. inline_core/parallel/protocol.py +53 -0
  36. inline_core/parallel/registry.py +31 -0
  37. inline_core/parallel/worker.py +75 -0
  38. inline_core/runtime/__init__.py +1 -0
  39. inline_core/runtime/context.py +31 -0
  40. inline_core/runtime/file_store.py +51 -0
  41. inline_core/runtime/progress.py +83 -0
  42. inline_core/runtime/run.py +113 -0
  43. inline_core/runtime/store.py +18 -0
  44. inline_core/sampling/__init__.py +1 -0
  45. inline_core/sampling/batch.py +116 -0
  46. inline_core/server/__init__.py +1 -0
  47. inline_core/server/__main__.py +61 -0
  48. inline_core/server/app.py +327 -0
  49. inline_core/server/assets.py +47 -0
  50. inline_core/server/bootstrap.py +21 -0
  51. inline_core/server/frontend.py +37 -0
  52. inline_core/server/manager.py +195 -0
  53. inline_core/server/rpc.py +60 -0
  54. inline_core/server/run_store.py +155 -0
  55. inline_core/server/serialize.py +144 -0
  56. inline_core/studio/__init__.py +7 -0
  57. inline_core/studio/assets.py +194 -0
  58. inline_core/studio/config.py +29 -0
  59. inline_core/studio/fal.py +237 -0
  60. inline_core/studio/frames.py +552 -0
  61. inline_core/studio/generation.py +136 -0
  62. inline_core/studio/graph_build.py +109 -0
  63. inline_core/studio/handlers.py +236 -0
  64. inline_core/studio/models.py +173 -0
  65. inline_core/studio/moodboard.py +400 -0
  66. inline_core/studio/schema.py +278 -0
  67. inline_core/studio/store.py +291 -0
  68. inline_core/studio/timeline/__init__.py +6 -0
  69. inline_core/studio/timeline/compose.py +130 -0
  70. inline_core/studio/timeline/ffmpeg.py +76 -0
  71. inline_core/studio/timeline/render.py +120 -0
  72. inline_core/studio/timeline/resolve.py +189 -0
  73. inline_core/takes.py +31 -0
  74. inline_core-1.1.1.dist-info/METADATA +35 -0
  75. inline_core-1.1.1.dist-info/RECORD +77 -0
  76. inline_core-1.1.1.dist-info/WHEEL +4 -0
  77. inline_core-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,7 @@
1
+ """Inline Core: the generation engine behind Inline.
2
+
3
+ Takes a typed node graph and returns immutable takes. See PLAN.md for the architecture and
4
+ docs/contract.md for the Storyline API.
5
+ """
6
+
7
+ __version__ = "0.0.0"
@@ -0,0 +1 @@
1
+ """Swappable model components, each behind a narrow interface and registered by name."""
@@ -0,0 +1,20 @@
1
+ """Opaque conditioning and latents. Conditioning is family-defined; the graph never inspects it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ import torch
10
+
11
+
12
+ class Conditioning:
13
+ """Opaque output of a TextEncoder, consumed by a Denoiser. No fixed tensor shape."""
14
+
15
+
16
+ @dataclass
17
+ class Latents:
18
+ """A latent tensor. Its device and dtype live on the tensor, set by the policy at load."""
19
+
20
+ tensor: torch.Tensor
@@ -0,0 +1,91 @@
1
+ """The five component interfaces. Device- and dtype-agnostic; placement comes from the context.
2
+
3
+ The Sampler carries an on_step callback from day one so progress streams without retrofitting.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from abc import ABC, abstractmethod
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from .conditioning import Conditioning, Latents
14
+
15
+ if TYPE_CHECKING:
16
+ import torch
17
+
18
+ from ..runtime.context import ExecutionContext
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class StepInfo:
23
+ """Reported once per denoise step so the executor can stream sample progress."""
24
+
25
+ step: int
26
+ total: int
27
+
28
+
29
+ StepCallback = Callable[[StepInfo], None]
30
+
31
+
32
+ class TextEncoder(ABC):
33
+ """prompt -> opaque Conditioning."""
34
+
35
+ @abstractmethod
36
+ def encode(
37
+ self, prompt: str, params: dict[str, Any], ctx: ExecutionContext
38
+ ) -> Conditioning: ...
39
+
40
+
41
+ class Scheduler(ABC):
42
+ """Owns the timesteps and the prediction parameterization."""
43
+
44
+ @abstractmethod
45
+ def timesteps(self, steps: int) -> Sequence[torch.Tensor]: ...
46
+
47
+ @abstractmethod
48
+ def scale_model_input(self, latents: Latents, t: torch.Tensor) -> Latents: ...
49
+
50
+ @abstractmethod
51
+ def step(self, model_output: torch.Tensor, t: torch.Tensor, latents: Latents) -> Latents: ...
52
+
53
+
54
+ class Denoiser(ABC):
55
+ """(latents, timestep, conditioning) -> predicted output. The heavy model."""
56
+
57
+ @abstractmethod
58
+ def predict(
59
+ self,
60
+ latents: Latents,
61
+ timestep: torch.Tensor,
62
+ conditioning: Conditioning,
63
+ ctx: ExecutionContext,
64
+ ) -> torch.Tensor: ...
65
+
66
+
67
+ class Sampler(ABC):
68
+ """The stepping loop, independent of the denoiser. Reports each step via on_step."""
69
+
70
+ @abstractmethod
71
+ def sample(
72
+ self,
73
+ denoiser: Denoiser,
74
+ scheduler: Scheduler,
75
+ latents: Latents,
76
+ conditioning: Conditioning,
77
+ *,
78
+ steps: int,
79
+ ctx: ExecutionContext,
80
+ on_step: StepCallback | None = None,
81
+ ) -> Latents: ...
82
+
83
+
84
+ class VAE(ABC):
85
+ """Pixel and latent conversion, both directions."""
86
+
87
+ @abstractmethod
88
+ def encode(self, image: torch.Tensor, ctx: ExecutionContext) -> Latents: ...
89
+
90
+ @abstractmethod
91
+ def decode(self, latents: Latents, ctx: ExecutionContext) -> torch.Tensor: ...
inline_core/config.py ADDED
@@ -0,0 +1,33 @@
1
+ """Engine configuration from the environment. Small and explicit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def models_dir() -> Path:
10
+ """The models root scanned on start (category subfolders inside). `INLINE_MODELS_DIR`, else
11
+ `./models`. Users drop their own weight files here; nothing is downloaded."""
12
+ env = os.environ.get("INLINE_MODELS_DIR")
13
+ return Path(env).expanduser() if env else Path("models")
14
+
15
+
16
+ def data_dir() -> Path:
17
+ """Engine-owned working data (the run DB, takes). `INLINE_DATA_DIR`, else `./.inline`."""
18
+ env = os.environ.get("INLINE_DATA_DIR")
19
+ return Path(env).expanduser() if env else Path(".inline")
20
+
21
+
22
+ def server_host() -> str:
23
+ """Address the /v1 server binds. `INLINE_HOST`, else loopback (`127.0.0.1`)."""
24
+ return os.environ.get("INLINE_HOST", "127.0.0.1")
25
+
26
+
27
+ def server_port() -> int:
28
+ """Port the /v1 server binds. `INLINE_PORT`, else 8848; a non-numeric value falls back."""
29
+ raw = os.environ.get("INLINE_PORT", "")
30
+ try:
31
+ return int(raw)
32
+ except ValueError:
33
+ return 8848
@@ -0,0 +1 @@
1
+ """Device and memory policy: the single owner of placement, dtype, offload, and attention."""
@@ -0,0 +1,35 @@
1
+ """A minimal default policy: whole graph on the best device, fp16 on cuda, fp32 elsewhere, SDPA.
2
+
3
+ This is the always-works path (PLAN.md). Phase 3 replaces it with real budgets, offload, profiles.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .detect import available_device
9
+ from .policy import AttentionBackend, DevicePolicy, Placement, Profile
10
+ from .types import Device, DeviceKind, DType
11
+
12
+ _PROFILE = {
13
+ DeviceKind.CUDA: Profile.GPU_MAX,
14
+ DeviceKind.MPS: Profile.GPU_MAX,
15
+ DeviceKind.CPU: Profile.CPU,
16
+ }
17
+
18
+
19
+ class AutoDevicePolicy(DevicePolicy):
20
+ def __init__(self, device: Device | None = None) -> None:
21
+ self._device = device or available_device()
22
+
23
+ @property
24
+ def profile(self) -> Profile:
25
+ return _PROFILE[self._device.kind]
26
+
27
+ def placement(self, role: str) -> Placement:
28
+ dtype = DType.FP16 if self._device.kind is DeviceKind.CUDA else DType.FP32
29
+ return Placement(self._device, dtype)
30
+
31
+ def attention_backend(self) -> AttentionBackend:
32
+ return AttentionBackend.SDPA
33
+
34
+ def vae_tiling(self) -> bool:
35
+ return self._device.kind is DeviceKind.CPU
@@ -0,0 +1,41 @@
1
+ """Best-available device detection. Torch is imported lazily so the core imports without it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .types import Device, DeviceKind
6
+
7
+
8
+ def available_device() -> Device:
9
+ """The single best device: cuda:0, else mps, else cpu."""
10
+ return available_devices()[0]
11
+
12
+
13
+ def available_devices() -> tuple[Device, ...]:
14
+ """Every usable device: all CUDA GPUs, else one MPS, else one CPU."""
15
+ try:
16
+ import torch
17
+ except ModuleNotFoundError:
18
+ return (Device(DeviceKind.CPU),)
19
+ if torch.cuda.is_available():
20
+ return tuple(Device(DeviceKind.CUDA, i) for i in range(torch.cuda.device_count()))
21
+ if torch.backends.mps.is_available():
22
+ return (Device(DeviceKind.MPS),)
23
+ return (Device(DeviceKind.CPU),)
24
+
25
+
26
+ def has_nvlink() -> bool:
27
+ """Best-effort: True only when NVLink is confirmed between two GPUs. Conservative (False) on any
28
+ uncertainty, so the policy defaults to the PCIe-friendly PipeFusion split."""
29
+ try:
30
+ import pynvml
31
+
32
+ pynvml.nvmlInit()
33
+ try:
34
+ if pynvml.nvmlDeviceGetCount() < 2:
35
+ return False
36
+ handle = pynvml.nvmlDeviceGetHandleByIndex(0)
37
+ return bool(pynvml.nvmlDeviceGetNvLinkState(handle, 0))
38
+ finally:
39
+ pynvml.nvmlShutdown()
40
+ except Exception:
41
+ return False
@@ -0,0 +1,168 @@
1
+ """The memory-aware device policy: measure RAM/VRAM, pick a profile, own dtype/offload/quant/tiling.
2
+
3
+ Profiles: gpu-max (ample VRAM), lowvram (tight VRAM), cpu (fp32, tiling, int8 to fit RAM). We always
4
+ prefer the GPU: even under lowvram, weights stay resident on the GPU (tiling + attention slicing +
5
+ int8 do the memory saving) and we do NOT auto-offload to CPU — offloading is slow and defeats "use
6
+ the GPU we have". Set INLINE_ALLOW_CPU_OFFLOAD=1 to opt back into streaming modules on/off the GPU
7
+ for extreme cases. Override the profile/budget with INLINE_PROFILE and INLINE_VRAM_BUDGET_GB.
8
+ Detection is lazy so the core imports without torch or psutil; an unavailable measurement keeps the
9
+ policy conservative.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ from .detect import available_devices, has_nvlink
17
+ from .policy import AttentionBackend, DevicePolicy, Parallel, Placement, Profile, Quantization
18
+ from .types import Device, DeviceKind, DType
19
+
20
+ _GPU_MAX_MIN_VRAM_GB = 16.0 # at or above -> gpu-max, else lowvram
21
+ _QUANT_VRAM_GB = 10.0 # lowvram below this -> int8
22
+ _QUANT_RAM_GB = 48.0 # cpu below this -> int8
23
+
24
+
25
+ def _system_ram_gb() -> float | None:
26
+ try:
27
+ return os.sysconf("SC_PHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") / 1e9
28
+ except (ValueError, OSError, AttributeError):
29
+ pass
30
+ try:
31
+ import psutil
32
+
33
+ return psutil.virtual_memory().total / 1e9
34
+ except ModuleNotFoundError:
35
+ return None
36
+
37
+
38
+ def _vram_gb(device: Device) -> float | None:
39
+ if device.kind is not DeviceKind.CUDA:
40
+ return None
41
+ try:
42
+ import torch
43
+
44
+ return torch.cuda.mem_get_info(device.index)[1] / 1e9
45
+ except Exception:
46
+ return None
47
+
48
+
49
+ def _env_profile() -> Profile | None:
50
+ value = os.environ.get("INLINE_PROFILE", "").strip().lower()
51
+ return next((p for p in Profile if p.value == value), None)
52
+
53
+
54
+ def _env_budget() -> float | None:
55
+ value = os.environ.get("INLINE_VRAM_BUDGET_GB", "").strip()
56
+ try:
57
+ return float(value) if value else None
58
+ except ValueError:
59
+ return None
60
+
61
+
62
+ def _env_allow_offload() -> bool:
63
+ """Opt back into CPU offload under lowvram. Off by default: we keep weights on the GPU."""
64
+ value = os.environ.get("INLINE_ALLOW_CPU_OFFLOAD", "").strip().lower()
65
+ return value in ("1", "true", "yes", "on")
66
+
67
+
68
+ def _env_parallel() -> Parallel | None:
69
+ """Parse INLINE_PARALLEL like `pipefusion=2,ulysses=2` into a degree spec."""
70
+ value = os.environ.get("INLINE_PARALLEL", "").strip()
71
+ if not value:
72
+ return None
73
+ valid = {"pipefusion", "ulysses", "ring", "cfg", "tensor"}
74
+ degrees: dict[str, int] = {}
75
+ for part in value.split(","):
76
+ key, _, raw = part.partition("=")
77
+ if key.strip() in valid:
78
+ try:
79
+ degrees[key.strip()] = int(raw)
80
+ except ValueError:
81
+ continue
82
+ return Parallel(**degrees) if degrees else None
83
+
84
+
85
+ class MemoryPolicy(DevicePolicy):
86
+ def __init__(
87
+ self,
88
+ device: Device | None = None,
89
+ *,
90
+ ram_gb: float | None = None,
91
+ vram_gb: float | None = None,
92
+ profile: Profile | None = None,
93
+ devices: tuple[Device, ...] | None = None,
94
+ nvlink: bool | None = None,
95
+ parallel: Parallel | None = None,
96
+ allow_offload: bool | None = None,
97
+ ) -> None:
98
+ self._devices = devices if devices is not None else available_devices()
99
+ self._device = device or self._devices[0]
100
+ self._ram_gb = ram_gb if ram_gb is not None else _system_ram_gb()
101
+ self._vram_gb = vram_gb if vram_gb is not None else _vram_gb(self._device)
102
+ self._profile = profile or _env_profile() or self._choose_profile()
103
+ self._nvlink = nvlink if nvlink is not None else has_nvlink()
104
+ self._parallel = parallel if parallel is not None else _env_parallel()
105
+ self._allow_offload = allow_offload if allow_offload is not None else _env_allow_offload()
106
+
107
+ def _choose_profile(self) -> Profile:
108
+ if self._device.kind is DeviceKind.CPU:
109
+ return Profile.CPU
110
+ if self._device.kind is DeviceKind.MPS:
111
+ return Profile.GPU_MAX # unified memory; offload semantics differ
112
+ budget = _env_budget() or self._vram_gb
113
+ if budget is not None and budget < _GPU_MAX_MIN_VRAM_GB:
114
+ return Profile.LOWVRAM
115
+ return Profile.GPU_MAX
116
+
117
+ @property
118
+ def profile(self) -> Profile:
119
+ return self._profile
120
+
121
+ def placement(self, role: str) -> Placement:
122
+ if self._profile is Profile.CPU:
123
+ return Placement(self._device, DType.FP32)
124
+ # Always prefer the GPU: even under lowvram, keep weights resident (tiling/slicing/int8 save
125
+ # memory instead). Only stream modules to CPU when explicitly opted in via env.
126
+ offload = self._profile is Profile.LOWVRAM and self._allow_offload
127
+ if role == "denoiser":
128
+ parallel = self._denoiser_parallel()
129
+ if parallel is not None:
130
+ cuda = tuple(d for d in self._devices if d.kind is DeviceKind.CUDA)
131
+ return Placement(
132
+ self._device,
133
+ DType.BF16,
134
+ offload=offload,
135
+ devices=cuda[: parallel.world_size],
136
+ parallel=parallel,
137
+ )
138
+ return Placement(self._device, DType.BF16, offload=offload)
139
+
140
+ def _denoiser_parallel(self) -> Parallel | None:
141
+ """Split the denoiser across GPUs when there are 2+. An explicit override wins; else auto:
142
+ PipeFusion on PCIe (no NVLink), sequence-parallel (Ulysses) on NVLink."""
143
+ if self._parallel is not None:
144
+ return self._parallel if self._parallel.world_size > 1 else None
145
+ cuda = [d for d in self._devices if d.kind is DeviceKind.CUDA]
146
+ if len(cuda) < 2:
147
+ return None
148
+ return Parallel(ulysses=len(cuda)) if self._nvlink else Parallel(pipefusion=len(cuda))
149
+
150
+ def attention_backend(self) -> AttentionBackend:
151
+ return AttentionBackend.SDPA
152
+
153
+ def vae_tiling(self) -> bool:
154
+ return self._profile in (Profile.LOWVRAM, Profile.CPU)
155
+
156
+ def attention_slicing(self) -> bool:
157
+ return self._profile in (Profile.LOWVRAM, Profile.CPU)
158
+
159
+ def quantization(self) -> Quantization:
160
+ if self._profile is Profile.LOWVRAM and _below(self._vram_gb, _QUANT_VRAM_GB):
161
+ return Quantization.INT8
162
+ if self._profile is Profile.CPU and _below(self._ram_gb, _QUANT_RAM_GB):
163
+ return Quantization.INT8
164
+ return Quantization.NONE
165
+
166
+
167
+ def _below(measured: float | None, threshold: float) -> bool:
168
+ return measured is not None and measured < threshold
@@ -0,0 +1,82 @@
1
+ """The device and memory policy interface. Components never self-assign a device; they ask here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+
9
+ from .types import Device, DType
10
+
11
+
12
+ class Profile(str, Enum):
13
+ GPU_MAX = "gpu-max"
14
+ LOWVRAM = "lowvram"
15
+ CPU = "cpu"
16
+
17
+
18
+ class AttentionBackend(str, Enum):
19
+ FLASH = "flash"
20
+ XFORMERS = "xformers"
21
+ SDPA = "sdpa"
22
+
23
+
24
+ class Quantization(str, Enum):
25
+ NONE = "none"
26
+ INT8 = "int8" # torch-native weight-only, portable
27
+ NF4 = "nf4" # bitsandbytes, cuda-only
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Parallel:
32
+ """How the denoiser is split across GPUs (xDiT degrees). Product of degrees = world size (GPUs).
33
+ PipeFusion is PCIe-friendly; Ulysses/Ring want NVLink; CFG applies only to guided models."""
34
+
35
+ pipefusion: int = 1
36
+ ulysses: int = 1
37
+ ring: int = 1
38
+ cfg: int = 1
39
+ tensor: int = 1
40
+
41
+ @property
42
+ def world_size(self) -> int:
43
+ return self.pipefusion * self.ulysses * self.ring * self.cfg * self.tensor
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Placement:
48
+ """Where and how a component runs. Chosen by the policy, never by the component."""
49
+
50
+ device: Device
51
+ dtype: DType
52
+ offload: bool = False
53
+ # Multi-GPU (denoiser only): the device group + how it is split. Empty/None = single device.
54
+ devices: tuple[Device, ...] = ()
55
+ parallel: Parallel | None = None
56
+
57
+
58
+ class DevicePolicy(ABC):
59
+ """Owns dtype, device, offload, attention backend, and tiling for a worker."""
60
+
61
+ @property
62
+ @abstractmethod
63
+ def profile(self) -> Profile: ...
64
+
65
+ @abstractmethod
66
+ def placement(self, role: str) -> Placement:
67
+ """Placement for a component role: text_encoder, denoiser, vae, and so on."""
68
+
69
+ @abstractmethod
70
+ def attention_backend(self) -> AttentionBackend: ...
71
+
72
+ @abstractmethod
73
+ def vae_tiling(self) -> bool:
74
+ """Whether to tile VAE decode to cap peak memory."""
75
+
76
+ def attention_slicing(self) -> bool:
77
+ """Whether to slice attention to cap peak memory. Default off."""
78
+ return False
79
+
80
+ def quantization(self) -> Quantization:
81
+ """Weight quantization to fit low memory. Default none."""
82
+ return Quantization.NONE
@@ -0,0 +1,31 @@
1
+ """Device and dtype abstractions. Kept independent of torch so nothing load-bearing needs it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+
8
+
9
+ class DeviceKind(str, Enum):
10
+ CUDA = "cuda"
11
+ MPS = "mps"
12
+ CPU = "cpu"
13
+
14
+
15
+ class DType(str, Enum):
16
+ FP32 = "fp32"
17
+ FP16 = "fp16"
18
+ BF16 = "bf16"
19
+ FP8 = "fp8"
20
+ INT8 = "int8"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Device:
25
+ kind: DeviceKind
26
+ index: int = 0
27
+
28
+ def __str__(self) -> str:
29
+ if self.kind is DeviceKind.CPU:
30
+ return self.kind.value
31
+ return f"{self.kind.value}:{self.index}"
inline_core/errors.py ADDED
@@ -0,0 +1,42 @@
1
+ """Typed engine errors. Fail loudly with a clear message; never catch and hide."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class InlineCoreError(Exception):
7
+ """Base for all engine errors."""
8
+
9
+
10
+ class GraphValidationError(InlineCoreError):
11
+ """A graph failed validation before execution. Carries the offending node and port."""
12
+
13
+ def __init__(
14
+ self, message: str, *, node_id: str | None = None, port: str | None = None
15
+ ) -> None:
16
+ super().__init__(message)
17
+ self.node_id = node_id
18
+ self.port = port
19
+
20
+
21
+ class UnknownNodeType(GraphValidationError):
22
+ """A node references a type absent from the registry."""
23
+
24
+
25
+ class PortTypeError(GraphValidationError):
26
+ """An edge connects incompatible port kinds, or a required port is unwired."""
27
+
28
+
29
+ class CycleError(GraphValidationError):
30
+ """The graph is not acyclic."""
31
+
32
+
33
+ class DeviceError(InlineCoreError):
34
+ """A device or placement could not be satisfied."""
35
+
36
+
37
+ class ComponentError(InlineCoreError):
38
+ """A component failed to load or execute."""
39
+
40
+
41
+ class CancelledError(InlineCoreError):
42
+ """A run was cancelled cooperatively via its cancel token."""
@@ -0,0 +1 @@
1
+ """The graph engine: parse JSON to a typed DAG, validate, cache by content, and execute lazily."""
@@ -0,0 +1,83 @@
1
+ """The content-hash node cache. Identity = (type, canonical params, upstream keys, asset content).
2
+
3
+ Determinism rules from docs/contract.md section 4, including seed-based cache eligibility.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from abc import ABC, abstractmethod
11
+ from typing import Any
12
+
13
+ from ..takes import Take
14
+ from .registry import Registry
15
+ from .schema import Graph, Node
16
+
17
+
18
+ class NodeCache(ABC):
19
+ @abstractmethod
20
+ def get(self, key: str) -> list[Take] | None: ...
21
+
22
+ @abstractmethod
23
+ def put(self, key: str, takes: list[Take]) -> None: ...
24
+
25
+
26
+ class InMemoryCache(NodeCache):
27
+ def __init__(self) -> None:
28
+ self._store: dict[str, list[Take]] = {}
29
+
30
+ def get(self, key: str) -> list[Take] | None:
31
+ return self._store.get(key)
32
+
33
+ def put(self, key: str, takes: list[Take]) -> None:
34
+ self._store[key] = list(takes)
35
+
36
+
37
+ def _canonical_params(node: Node, registry: Registry) -> dict[str, Any]:
38
+ merged = {**registry.get(node.type).defaults(), **node.params}
39
+ return {key: merged[key] for key in sorted(merged)}
40
+
41
+
42
+ def is_cache_eligible(node: Node, registry: Registry) -> bool:
43
+ """False when any seed param resolves to a negative (random) value."""
44
+ descriptor = registry.get(node.type)
45
+ defaults = descriptor.defaults()
46
+ for key in descriptor.seed_keys():
47
+ value = node.params.get(key, defaults.get(key))
48
+ try:
49
+ if int(value) < 0:
50
+ return False
51
+ except (TypeError, ValueError):
52
+ return False
53
+ return True
54
+
55
+
56
+ def node_cache_key(
57
+ graph: Graph,
58
+ node_id: str,
59
+ registry: Registry,
60
+ asset_hashes: dict[str, str],
61
+ _memo: dict[str, str] | None = None,
62
+ ) -> str:
63
+ """A stable content hash for a node's output. Asset refs contribute their byte hash."""
64
+ memo = _memo if _memo is not None else {}
65
+ if node_id in memo:
66
+ return memo[node_id]
67
+ node = graph.node(node_id)
68
+ upstream = {
69
+ port_id: [
70
+ [edge.output, node_cache_key(graph, edge.from_node, registry, asset_hashes, memo)]
71
+ for edge in edges
72
+ ]
73
+ for port_id, edges in sorted(node.inputs.items())
74
+ }
75
+ payload = {
76
+ "type": node.type,
77
+ "params": _canonical_params(node, registry),
78
+ "inputs": upstream,
79
+ "asset": asset_hashes.get(node_id),
80
+ }
81
+ digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
82
+ memo[node_id] = digest
83
+ return digest