singularic 1.0.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 (55) hide show
  1. singularic/__init__.py +13 -0
  2. singularic/checkpoint/bundle.py +59 -0
  3. singularic/checkpoint/manager.py +350 -0
  4. singularic/checkpoint/store.py +107 -0
  5. singularic/core/apply.py +62 -0
  6. singularic/core/canonical.py +332 -0
  7. singularic/core/context.py +51 -0
  8. singularic/core/enums.py +43 -0
  9. singularic/core/module.py +970 -0
  10. singularic/core/path.py +114 -0
  11. singularic/core/placement.py +27 -0
  12. singularic/core/precision.py +322 -0
  13. singularic/core/rng.py +191 -0
  14. singularic/core/schema.py +302 -0
  15. singularic/core/selectors.py +151 -0
  16. singularic/core/state.py +134 -0
  17. singularic/core/train_state.py +152 -0
  18. singularic/core/variables.py +412 -0
  19. singularic/data/dataset.py +555 -0
  20. singularic/data/ops.py +982 -0
  21. singularic/data/runtime.py +758 -0
  22. singularic/data/spec.py +459 -0
  23. singularic/distributed/sharding.py +366 -0
  24. singularic/inference/attention.py +143 -0
  25. singularic/inference/cache.py +612 -0
  26. singularic/inference/engine.py +398 -0
  27. singularic/inference/sampling.py +154 -0
  28. singularic/inspect/report.py +277 -0
  29. singularic/nn/attention.py +467 -0
  30. singularic/nn/containers.py +228 -0
  31. singularic/nn/functional.py +164 -0
  32. singularic/nn/initializers.py +255 -0
  33. singularic/nn/kernels.py +532 -0
  34. singularic/nn/layers.py +594 -0
  35. singularic/nn/moe.py +482 -0
  36. singularic/nn/paged.py +70 -0
  37. singularic/nn/transformer.py +566 -0
  38. singularic/nn/vision.py +158 -0
  39. singularic/optim/algorithms.py +278 -0
  40. singularic/optim/muon.py +91 -0
  41. singularic/optim/runtime.py +147 -0
  42. singularic/optim/schedules.py +152 -0
  43. singularic/optim/specs.py +161 -0
  44. singularic/optim/transforms.py +460 -0
  45. singularic/py.typed +1 -0
  46. singularic/train/checkpoint.py +93 -0
  47. singularic/train/compiler.py +162 -0
  48. singularic/train/metrics.py +215 -0
  49. singularic/train/specs.py +203 -0
  50. singularic/train/trainer.py +621 -0
  51. singularic/train/transitions.py +442 -0
  52. singularic-1.0.0.dist-info/METADATA +152 -0
  53. singularic-1.0.0.dist-info/RECORD +55 -0
  54. singularic-1.0.0.dist-info/WHEEL +4 -0
  55. singularic-1.0.0.dist-info/licenses/LICENSE +201 -0
singularic/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Singularic's intentionally minimal package marker.
2
+
3
+ Stable APIs are imported from their owning modules. Child directories are
4
+ PEP 420 namespaces and deliberately have no package-barrel initializers.
5
+ """
6
+
7
+ from importlib.metadata import version as _distribution_version
8
+ from typing import Final
9
+
10
+ from .core.apply import apply as apply
11
+ from .core.state import State as State
12
+
13
+ __version__: Final = _distribution_version("singularic")
@@ -0,0 +1,59 @@
1
+ """Generic portable checkpoint values independent of training orchestration."""
2
+
3
+ import json
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from types import MappingProxyType
7
+ from typing import Any
8
+
9
+ from singularic.core.state import State
10
+
11
+
12
+ def _json_value(value: Mapping[str, Any] | None, description: str) -> Mapping[str, Any]:
13
+ resolved = {} if value is None else dict(value)
14
+ try:
15
+ json.dumps(resolved, allow_nan=False, sort_keys=True)
16
+ except (TypeError, ValueError) as error:
17
+ raise TypeError(
18
+ f"CheckpointBundle {description} must be JSON-compatible"
19
+ ) from error
20
+ return MappingProxyType(resolved)
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class CheckpointBundle:
25
+ """Named portable :class:`State` groups plus JSON metadata and cursor data.
26
+
27
+ The bundle has no knowledge of models, optimizers, or trainers. Group names
28
+ are stable storage ownership boundaries, allowing a caller to restore just
29
+ the local state it owns.
30
+ """
31
+
32
+ groups: Mapping[str, State]
33
+ metadata: Mapping[str, Any] | None = None
34
+ dataset_cursor: Mapping[str, Any] | None = None
35
+
36
+ def __post_init__(self) -> None:
37
+ normalized = dict(self.groups)
38
+ if not normalized or any(
39
+ not isinstance(name, str) or not name for name in normalized
40
+ ):
41
+ raise ValueError("CheckpointBundle groups must have non-empty string names")
42
+ if any(not isinstance(state, State) for state in normalized.values()):
43
+ raise TypeError("CheckpointBundle groups must be State values")
44
+ object.__setattr__(self, "groups", MappingProxyType(normalized))
45
+ object.__setattr__(self, "metadata", _json_value(self.metadata, "metadata"))
46
+ object.__setattr__(
47
+ self, "dataset_cursor", _json_value(self.dataset_cursor, "dataset cursor")
48
+ )
49
+
50
+ def subset(self, names: tuple[str, ...]) -> CheckpointBundle:
51
+ if not names or len(names) != len(set(names)):
52
+ raise ValueError(
53
+ "CheckpointBundle subset names must be non-empty and unique"
54
+ )
55
+ try:
56
+ groups = {name: self.groups[name] for name in names}
57
+ except KeyError as error:
58
+ raise KeyError(f"Unknown checkpoint group {error.args[0]!r}") from error
59
+ return CheckpointBundle(groups, self.metadata, self.dataset_cursor)
@@ -0,0 +1,350 @@
1
+ """Immutable generic checkpoint bundles over a minimal byte-store protocol."""
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ import threading
7
+ import uuid
8
+ from collections.abc import Callable, Mapping
9
+ from concurrent.futures import Future, ThreadPoolExecutor
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any, Final, Self
13
+
14
+ import jax
15
+ import jax.numpy as jnp
16
+ import numpy as np
17
+ from safetensors.numpy import load, save
18
+
19
+ from singularic.core.state import State, StateSchema
20
+
21
+ from .bundle import CheckpointBundle
22
+ from .store import ByteStore, PosixCheckpointStore, normalize_storage_key
23
+
24
+ _FORMAT: Final = "singularic-checkpoint-bundle"
25
+
26
+
27
+ def _json_bytes(value: Mapping[str, Any]) -> bytes:
28
+ return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
29
+
30
+
31
+ def _json_object(payload: bytes, description: str) -> dict[str, Any]:
32
+ try:
33
+ value = json.loads(payload)
34
+ except (UnicodeDecodeError, ValueError) as error:
35
+ raise ValueError(f"Invalid {description} JSON") from error
36
+ if not isinstance(value, dict):
37
+ raise ValueError(f"{description} must be a JSON object")
38
+ return value
39
+
40
+
41
+ @dataclass(frozen=True, slots=True, order=True)
42
+ class CheckpointRef:
43
+ """One atomically published immutable bundle generation."""
44
+
45
+ step: int
46
+ generation: str
47
+ commit_key: str
48
+
49
+ def __post_init__(self) -> None:
50
+ if self.step < 0 or not re.fullmatch(r"[0-9a-f]{32}", self.generation):
51
+ raise ValueError("Invalid checkpoint reference")
52
+ normalize_storage_key(self.commit_key)
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class CheckpointRestore:
57
+ bundle: CheckpointBundle
58
+ checkpoint: CheckpointRef
59
+
60
+
61
+ class SaveHandle:
62
+ """Completion boundary for one asynchronous immutable publication."""
63
+
64
+ def __init__(
65
+ self, future: Future[CheckpointRef], consume: Callable[[], None]
66
+ ) -> None:
67
+ self._future = future
68
+ self._consume = consume
69
+
70
+ @property
71
+ def done(self) -> bool:
72
+ return self._future.done()
73
+
74
+ def result(self, timeout: float | None = None) -> CheckpointRef:
75
+ try:
76
+ return self._future.result(timeout=timeout)
77
+ finally:
78
+ if self._future.done():
79
+ self._consume()
80
+
81
+ wait = result
82
+
83
+
84
+ class CheckpointManager:
85
+ """Persist and restore generic named state groups without Trainer knowledge."""
86
+
87
+ def __init__(
88
+ self,
89
+ directory: str | Path | None = None,
90
+ *,
91
+ store: ByteStore | None = None,
92
+ max_to_keep: int | None = None,
93
+ ) -> None:
94
+ if (directory is None) == (store is None):
95
+ raise ValueError("Provide exactly one of directory or store")
96
+ if max_to_keep is not None and max_to_keep <= 0:
97
+ raise ValueError("max_to_keep must be positive")
98
+ if store is None:
99
+ assert directory is not None
100
+ self.store: ByteStore = PosixCheckpointStore(Path(directory))
101
+ else:
102
+ self.store = store
103
+ if not isinstance(self.store, ByteStore):
104
+ raise TypeError("store must implement ByteStore")
105
+ self.max_to_keep = max_to_keep
106
+ self._lock = threading.Lock()
107
+ self._executor: ThreadPoolExecutor | None = None
108
+ self._pending: set[Future[CheckpointRef]] = set()
109
+ self._closed = False
110
+
111
+ def __enter__(self) -> Self:
112
+ return self
113
+
114
+ def __exit__(self, *_: object) -> None:
115
+ self.close()
116
+
117
+ @staticmethod
118
+ def _commit_key(step: int) -> str:
119
+ return f"commits/step-{step:012d}.json"
120
+
121
+ def _put(self, key: str, payload: bytes) -> None:
122
+ if not self.store.put_if_absent(key, payload):
123
+ raise FileExistsError(f"Immutable checkpoint key already exists: {key}")
124
+
125
+ def save(self, bundle: CheckpointBundle, *, step: int) -> CheckpointRef:
126
+ """Write all named groups, then publish one conditional commit record."""
127
+
128
+ if not isinstance(bundle, CheckpointBundle):
129
+ raise TypeError("bundle must be a CheckpointBundle")
130
+ if step < 0:
131
+ raise ValueError("Checkpoint step must be nonnegative")
132
+ commit_key = self._commit_key(step)
133
+ if self.store.exists(commit_key):
134
+ raise FileExistsError(f"Checkpoint step {step} is already committed")
135
+ generation = uuid.uuid4().hex
136
+ prefix = f"generations/{generation}"
137
+ arrays: dict[str, np.ndarray[Any, Any]] = {}
138
+ groups: dict[str, Any] = {}
139
+ for group_name, state in bundle.groups.items():
140
+ tensors: list[dict[str, Any]] = []
141
+ for index, value in enumerate(state.values):
142
+ name = f"{group_name}:{index}"
143
+ typed_key = bool(jnp.issubdtype(value.dtype, jax.dtypes.prng_key))
144
+ physical = jax.random.key_data(value) if typed_key else value
145
+ arrays[name] = np.asarray(jax.device_get(physical))
146
+ tensors.append(
147
+ {
148
+ "name": name,
149
+ "typed_key": typed_key,
150
+ "key_impl": str(jax.random.key_impl(value))
151
+ if typed_key
152
+ else None,
153
+ }
154
+ )
155
+ groups[group_name] = {"schema": state.schema.to_wire(), "tensors": tensors}
156
+ payload = save(arrays)
157
+ metadata = _json_bytes(
158
+ {
159
+ "format": _FORMAT,
160
+ "step": step,
161
+ "generation": generation,
162
+ "groups": groups,
163
+ "metadata": dict(bundle.metadata or {}),
164
+ "dataset_cursor": dict(bundle.dataset_cursor or {}),
165
+ "arrays_sha256": hashlib.sha256(payload).hexdigest(),
166
+ }
167
+ )
168
+ arrays_key = f"{prefix}/arrays.safetensors"
169
+ metadata_key = f"{prefix}/metadata.json"
170
+ commit = _json_bytes(
171
+ {
172
+ "format": _FORMAT,
173
+ "step": step,
174
+ "generation": generation,
175
+ "metadata_key": metadata_key,
176
+ "metadata_sha256": hashlib.sha256(metadata).hexdigest(),
177
+ }
178
+ )
179
+ self._put(arrays_key, payload)
180
+ self._put(metadata_key, metadata)
181
+ self._put(commit_key, commit)
182
+ reference = CheckpointRef(step, generation, commit_key)
183
+ self._prune()
184
+ return reference
185
+
186
+ def save_async(self, bundle: CheckpointBundle, *, step: int) -> SaveHandle:
187
+ with self._lock:
188
+ if self._closed:
189
+ raise RuntimeError("Checkpoint manager is closed")
190
+ if self._executor is None:
191
+ self._executor = ThreadPoolExecutor(max_workers=1)
192
+ future = self._executor.submit(self.save, bundle, step=step)
193
+ self._pending.add(future)
194
+
195
+ def consume() -> None:
196
+ with self._lock:
197
+ self._pending.discard(future)
198
+
199
+ return SaveHandle(future, consume)
200
+
201
+ def wait_for_pending(self) -> None:
202
+ with self._lock:
203
+ pending = tuple(self._pending)
204
+ for future in pending:
205
+ try:
206
+ future.result()
207
+ finally:
208
+ with self._lock:
209
+ self._pending.discard(future)
210
+
211
+ def close(self) -> None:
212
+ with self._lock:
213
+ if self._closed:
214
+ return
215
+ self._closed = True
216
+ executor = self._executor
217
+ self.wait_for_pending()
218
+ if executor is not None:
219
+ executor.shutdown(wait=True)
220
+
221
+ def checkpoints(self) -> tuple[CheckpointRef, ...]:
222
+ records: list[CheckpointRef] = []
223
+ for key in self.store.list("commits/"):
224
+ record = _json_object(self.store.get(key), "checkpoint commit")
225
+ if record.get("format") != _FORMAT:
226
+ raise ValueError("Unsupported checkpoint commit")
227
+ records.append(
228
+ CheckpointRef(int(record["step"]), str(record["generation"]), key)
229
+ )
230
+ return tuple(sorted(records))
231
+
232
+ def latest(self) -> CheckpointRef:
233
+ records = self.checkpoints()
234
+ if not records:
235
+ raise FileNotFoundError("Checkpoint store contains no committed bundles")
236
+ return records[-1]
237
+
238
+ def restore(
239
+ self,
240
+ checkpoint: CheckpointRef | str | None = None,
241
+ *,
242
+ targets: Mapping[str, State] | None = None,
243
+ ) -> CheckpointRestore:
244
+ reference = self.latest() if checkpoint is None else self._reference(checkpoint)
245
+ commit = _json_object(self.store.get(reference.commit_key), "checkpoint commit")
246
+ if commit.get("format") != _FORMAT:
247
+ raise ValueError("Unsupported checkpoint commit")
248
+ if (
249
+ commit.get("step") != reference.step
250
+ or commit.get("generation") != reference.generation
251
+ ):
252
+ raise ValueError("Checkpoint reference does not match its commit")
253
+ metadata_key = str(commit["metadata_key"])
254
+ if metadata_key != f"generations/{reference.generation}/metadata.json":
255
+ raise ValueError("Checkpoint metadata key does not match its generation")
256
+ metadata_payload = self.store.get(normalize_storage_key(metadata_key))
257
+ if hashlib.sha256(metadata_payload).hexdigest() != commit.get(
258
+ "metadata_sha256"
259
+ ):
260
+ raise ValueError("Checkpoint metadata checksum mismatch")
261
+ metadata = _json_object(metadata_payload, "checkpoint metadata")
262
+ if metadata.get("format") != _FORMAT:
263
+ raise ValueError("Unsupported checkpoint metadata")
264
+ if (
265
+ metadata.get("step") != reference.step
266
+ or metadata.get("generation") != reference.generation
267
+ ):
268
+ raise ValueError("Checkpoint metadata does not match its commit")
269
+ arrays_key = f"generations/{reference.generation}/arrays.safetensors"
270
+ arrays_payload = self.store.get(arrays_key)
271
+ if hashlib.sha256(arrays_payload).hexdigest() != metadata.get("arrays_sha256"):
272
+ raise ValueError("Checkpoint array checksum mismatch")
273
+ decoded = load(arrays_payload)
274
+ restored: dict[str, State] = {}
275
+ raw_groups = metadata.get("groups")
276
+ if not isinstance(raw_groups, Mapping):
277
+ raise ValueError("Checkpoint groups must be an object")
278
+ expected_names: set[str] = set()
279
+ for name, raw_group in raw_groups.items():
280
+ if not isinstance(name, str) or not isinstance(raw_group, Mapping):
281
+ raise ValueError("Invalid checkpoint group")
282
+ schema = StateSchema.from_wire(raw_group["schema"])
283
+ tensors = raw_group["tensors"]
284
+ if not isinstance(tensors, list) or len(tensors) != len(schema):
285
+ raise ValueError("Checkpoint tensors do not match their schema")
286
+ values: list[jax.Array] = []
287
+ target = None if targets is None else targets.get(name)
288
+ if target is not None and target.schema != schema:
289
+ raise ValueError(f"Checkpoint group {name!r} does not match its target")
290
+ for index, item in enumerate(tensors):
291
+ if not isinstance(item, Mapping) or not isinstance(
292
+ item.get("name"), str
293
+ ):
294
+ raise ValueError("Invalid checkpoint tensor record")
295
+ tensor_name = item["name"]
296
+ if tensor_name != f"{name}:{index}" or tensor_name in expected_names:
297
+ raise ValueError("Checkpoint tensor names are inconsistent")
298
+ expected_names.add(tensor_name)
299
+ try:
300
+ value = jnp.asarray(decoded[tensor_name])
301
+ except KeyError as error:
302
+ raise ValueError(
303
+ "Checkpoint array payload is incomplete"
304
+ ) from error
305
+ if bool(item.get("typed_key")):
306
+ impl = item.get("key_impl")
307
+ if not isinstance(impl, str):
308
+ raise ValueError(
309
+ "Typed key checkpoint is missing its implementation"
310
+ )
311
+ value = jax.random.wrap_key_data(value, impl=impl)
312
+ if target is not None:
313
+ value = jax.device_put(value, target.values[index].sharding)
314
+ values.append(value)
315
+ restored[name] = State(tuple(values), schema)
316
+ if set(decoded) != expected_names:
317
+ raise ValueError("Checkpoint array payload does not match its metadata")
318
+ if targets is not None:
319
+ unknown = set(targets) - set(restored)
320
+ if unknown:
321
+ raise KeyError(
322
+ f"Checkpoint is missing target groups: {sorted(unknown)}"
323
+ )
324
+ bundle_metadata = metadata.get("metadata", {})
325
+ dataset_cursor = metadata.get("dataset_cursor", {})
326
+ if not isinstance(bundle_metadata, Mapping) or not isinstance(
327
+ dataset_cursor, Mapping
328
+ ):
329
+ raise ValueError("Checkpoint bundle metadata must be objects")
330
+ return CheckpointRestore(
331
+ CheckpointBundle(restored, bundle_metadata, dataset_cursor), reference
332
+ )
333
+
334
+ def _reference(self, checkpoint: CheckpointRef | str) -> CheckpointRef:
335
+ if isinstance(checkpoint, CheckpointRef):
336
+ return checkpoint
337
+ key = normalize_storage_key(checkpoint)
338
+ record = _json_object(self.store.get(key), "checkpoint commit")
339
+ if record.get("format") != _FORMAT:
340
+ raise ValueError("Unsupported checkpoint commit")
341
+ return CheckpointRef(int(record["step"]), str(record["generation"]), key)
342
+
343
+ def _prune(self) -> None:
344
+ if self.max_to_keep is None:
345
+ return
346
+ expired = self.checkpoints()[: -self.max_to_keep]
347
+ for reference in expired:
348
+ for key in self.store.list(f"generations/{reference.generation}/"):
349
+ self.store.delete(key)
350
+ self.store.delete(reference.commit_key)
@@ -0,0 +1,107 @@
1
+ """Byte-oriented checkpoint storage and its durable POSIX implementation."""
2
+
3
+ import os
4
+ import uuid
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+ from typing import Protocol, runtime_checkable
8
+
9
+
10
+ @runtime_checkable
11
+ class ByteStore(Protocol):
12
+ """Dependency-free immutable-key checkpoint storage boundary."""
13
+
14
+ def get(self, key: str) -> bytes: ...
15
+ def put_if_absent(self, key: str, value: bytes) -> bool: ...
16
+ def exists(self, key: str) -> bool: ...
17
+ def list(self, prefix: str = "") -> tuple[str, ...]: ...
18
+ def delete(self, key: str) -> None: ...
19
+
20
+
21
+ def normalize_storage_key(key: str, *, allow_empty: bool = False) -> str:
22
+ """Validate and return a normalized relative POSIX storage key."""
23
+
24
+ if not isinstance(key, str) or "\\" in key or "\x00" in key:
25
+ raise ValueError("Storage keys must be normalized POSIX strings")
26
+ if allow_empty and key == "":
27
+ return ""
28
+ path = PurePosixPath(key)
29
+ normalized = path.as_posix()
30
+ if (
31
+ path.is_absolute()
32
+ or ".." in path.parts
33
+ or normalized != key
34
+ or (not allow_empty and normalized in {"", "."})
35
+ ):
36
+ raise ValueError("Storage keys must be normalized and relative")
37
+ return "" if normalized == "." else normalized
38
+
39
+
40
+ def fsync_directory(path: Path) -> None:
41
+ descriptor = os.open(path, os.O_RDONLY)
42
+ try:
43
+ os.fsync(descriptor)
44
+ finally:
45
+ os.close(descriptor)
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class PosixCheckpointStore:
50
+ """Durable shared-filesystem implementation of :class:`ByteStore`."""
51
+
52
+ root: Path
53
+
54
+ def __post_init__(self) -> None:
55
+ object.__setattr__(self, "root", Path(self.root))
56
+ self.root.mkdir(parents=True, exist_ok=True)
57
+
58
+ def path(self, key: str) -> Path:
59
+ candidate = self.root / normalize_storage_key(key)
60
+ try:
61
+ candidate.resolve(strict=False).relative_to(self.root.resolve())
62
+ except ValueError as error:
63
+ raise ValueError("Storage key escapes the configured root") from error
64
+ return candidate
65
+
66
+ def get(self, key: str) -> bytes:
67
+ return self.path(key).read_bytes()
68
+
69
+ def put_if_absent(self, key: str, value: bytes) -> bool:
70
+ path = self.path(key)
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ temporary = path.parent / f".{path.name}.{uuid.uuid4().hex}.writing"
73
+ try:
74
+ with temporary.open("xb") as file:
75
+ file.write(value)
76
+ file.flush()
77
+ os.fsync(file.fileno())
78
+ try:
79
+ os.link(temporary, path)
80
+ except FileExistsError:
81
+ return False
82
+ fsync_directory(path.parent)
83
+ return True
84
+ finally:
85
+ temporary.unlink(missing_ok=True)
86
+
87
+ def list(self, prefix: str = "") -> tuple[str, ...]:
88
+ trailing_separator = prefix.endswith("/")
89
+ candidate = prefix[:-1] if trailing_separator else prefix
90
+ normalized = normalize_storage_key(candidate, allow_empty=True)
91
+ if trailing_separator and normalized:
92
+ normalized += "/"
93
+ return tuple(
94
+ sorted(
95
+ path.relative_to(self.root).as_posix()
96
+ for path in self.root.rglob("*")
97
+ if path.is_file()
98
+ and not path.name.endswith(".writing")
99
+ and path.relative_to(self.root).as_posix().startswith(normalized)
100
+ )
101
+ )
102
+
103
+ def delete(self, key: str) -> None:
104
+ self.path(key).unlink(missing_ok=True)
105
+
106
+ def exists(self, key: str) -> bool:
107
+ return self.path(key).is_file()
@@ -0,0 +1,62 @@
1
+ """The explicit graph/state execution boundary for Singularic programs."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from .context import execution_context
7
+ from .module import GraphDef, _apply_graph
8
+ from .precision import NumericsPlan, PrecisionPolicy, precision_scope
9
+ from .rng import RngPlan, rng_scope
10
+ from .state import State
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class ApplyContext:
15
+ """Execution inputs that used to be hidden in ambient framework state.
16
+
17
+ ``rng_plan`` and ``rng_state`` must either be supplied together or omitted.
18
+ Callers can use ordinary JAX transformations over :func:`apply` because the
19
+ complete mutable program state remains an explicit argument/result.
20
+ """
21
+
22
+ precision: PrecisionPolicy | NumericsPlan | None = None
23
+ rng_plan: RngPlan | None = None
24
+ rng_state: State | None = None
25
+
26
+ def __post_init__(self) -> None:
27
+ if (self.rng_plan is None) != (self.rng_state is None):
28
+ raise ValueError("ApplyContext requires both rng_plan and rng_state")
29
+
30
+
31
+ def apply(
32
+ graph: GraphDef,
33
+ state: State,
34
+ *args: Any,
35
+ context: ApplyContext | None = None,
36
+ method: str = "__call__",
37
+ **kwargs: Any,
38
+ ) -> tuple[Any, State]:
39
+ """Execute one graph method with all framework policy inputs explicit."""
40
+
41
+ resolved_context = ApplyContext() if context is None else context
42
+ precision = resolved_context.precision
43
+ numerics = (
44
+ None
45
+ if precision is None
46
+ else precision
47
+ if isinstance(precision, NumericsPlan)
48
+ else precision.compile(state.schema)
49
+ )
50
+ with execution_context():
51
+ if numerics is None:
52
+ if resolved_context.rng_plan is None:
53
+ return _apply_graph(graph, state, *args, method=method, **kwargs)
54
+ assert resolved_context.rng_state is not None
55
+ with rng_scope(resolved_context.rng_plan, resolved_context.rng_state):
56
+ return _apply_graph(graph, state, *args, method=method, **kwargs)
57
+ with precision_scope(numerics):
58
+ if resolved_context.rng_plan is None:
59
+ return _apply_graph(graph, state, *args, method=method, **kwargs)
60
+ assert resolved_context.rng_state is not None
61
+ with rng_scope(resolved_context.rng_plan, resolved_context.rng_state):
62
+ return _apply_graph(graph, state, *args, method=method, **kwargs)