graphspace 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.
graphspace/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ from ._version import __version__
2
+ from .claims import Basis, Claim
3
+ from .core import Analysis, Graph, Node, TensorSpec, ResourceContract, ExecutionRecord, MemoryPlan, MemoryValue
4
+ from .uncertainty import Uncertain
5
+ from .failures import (
6
+ GraphspaceError, ShapeMismatch, DTypeMismatch, ResourceLimitExceeded, UnknownValue, ContractViolation,
7
+ BackendUnavailable, LowConfidence,
8
+ )
9
+
10
+ __all__ = [
11
+ "__version__",
12
+ "Analysis", "Basis", "Claim",
13
+ "Graph", "Node", "TensorSpec", "ResourceContract", "ExecutionRecord", "MemoryPlan", "MemoryValue", "Uncertain",
14
+ "GraphspaceError", "ShapeMismatch", "DTypeMismatch", "ResourceLimitExceeded", "UnknownValue",
15
+ "ContractViolation", "BackendUnavailable", "LowConfidence",
16
+ ]
graphspace/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,28 @@
1
+ from collections.abc import Iterable
2
+ import hashlib
3
+ import json
4
+
5
+ from ..failures import BackendUnavailable
6
+
7
+ BACKENDS = ("python", "numpy")
8
+ _INSTANCES: dict = {}
9
+
10
+
11
+ def get_backend(name: str):
12
+ if name in _INSTANCES:
13
+ return _INSTANCES[name]
14
+ if name == "python":
15
+ from .python import PythonBackend
16
+ return _INSTANCES.setdefault(name, PythonBackend())
17
+ if name == "numpy":
18
+ from .numpy import NumpyBackend
19
+ return _INSTANCES.setdefault(name, NumpyBackend())
20
+ raise BackendUnavailable(f"unknown backend {name!r}", expected=list(BACKENDS), actual=name)
21
+
22
+
23
+ def frame_digest(entries: Iterable[tuple[str, str, int, bytes]]) -> str:
24
+ digest = hashlib.sha256()
25
+ for name, dtype, length, payload in sorted(entries, key=lambda entry: entry[0]):
26
+ header = json.dumps([name, dtype, length]).encode()
27
+ digest.update(len(header).to_bytes(8, "little") + header + payload)
28
+ return digest.hexdigest()
@@ -0,0 +1,127 @@
1
+ from collections.abc import Mapping
2
+ from typing import Any
3
+
4
+ from ..core import FLOAT_DTYPES, INT_RANGES, Dims, Node, TensorSpec, _product
5
+ from ..failures import BackendUnavailable, ContractViolation, DTypeMismatch, ShapeMismatch
6
+ from . import frame_digest
7
+ from .python import PythonBackend
8
+
9
+
10
+ class NumpyBackend:
11
+ name = "numpy"
12
+
13
+ def __init__(self) -> None:
14
+ try:
15
+ import numpy
16
+ except ImportError as error:
17
+ raise BackendUnavailable(
18
+ "backend 'numpy' requires NumPy",
19
+ actual="numpy not installed", remediation="pip install 'graphspace[numpy]'",
20
+ ) from error
21
+ self.np = numpy
22
+ self.version = numpy.__version__
23
+ self._python = PythonBackend()
24
+
25
+ def session(self):
26
+ return self.np.errstate(all="ignore")
27
+
28
+ def length(self, name: str, data: Any) -> int:
29
+ if isinstance(data, self.np.ndarray):
30
+ return int(data.size)
31
+ return self._python.length(name, data)
32
+
33
+ def coerce(self, name: str, data: Any, spec: TensorSpec, dims: Dims):
34
+ np = self.np
35
+ shape = spec.concrete_shape(dims)
36
+ if not isinstance(data, np.ndarray):
37
+ return np.array(self._python.coerce(name, data, spec, dims), dtype=spec.dtype).reshape(shape)
38
+ if isinstance(data, np.ma.MaskedArray):
39
+ raise DTypeMismatch(
40
+ f"input {name} is a masked array",
41
+ node=name, expected="ndarray", actual="MaskedArray", remediation="pass data.filled(value) or data.compressed()",
42
+ )
43
+ data = np.asarray(data)
44
+ if data.size != _product(shape):
45
+ raise ShapeMismatch(
46
+ f"input {name} has {data.size} elements, shape {spec.shape} needs {_product(shape)}",
47
+ node=name, expected=_product(shape), actual=int(data.size),
48
+ )
49
+ if data.ndim > 1 and data.shape != shape:
50
+ raise ShapeMismatch(f"input {name} has shape {data.shape}, expected {shape}", node=name, expected=shape, actual=data.shape)
51
+ allowed = "fiu" if spec.dtype in FLOAT_DTYPES else "iu"
52
+ if data.dtype.kind not in allowed:
53
+ raise DTypeMismatch(
54
+ f"input {name} has dtype {data.dtype}, not usable as {spec.dtype}",
55
+ node=name, expected=spec.dtype, actual=str(data.dtype),
56
+ )
57
+ if spec.dtype in INT_RANGES and data.size:
58
+ low, high = INT_RANGES[spec.dtype]
59
+ if int(data.min()) < low or int(data.max()) > high:
60
+ raise DTypeMismatch(
61
+ f"input {name}: value outside {spec.dtype} range",
62
+ node=name, expected=[low, high], actual=f"{int(((data < low) | (data > high)).sum())} values out of range",
63
+ )
64
+ array = data if data.dtype == spec.dtype else data.astype(spec.dtype)
65
+ return array.reshape(shape)
66
+
67
+ def run(self, node: Node, args: list, in_shapes: list[tuple[int, ...]], out_shape: tuple[int, ...], out: Any = None):
68
+ np = self.np
69
+ dtype = node.output_spec.dtype
70
+ if dtype in INT_RANGES and node.operation in {"add", "multiply", "subtract", "matmul"}:
71
+ wide = np.int64 if dtype == "int32" and node.operation != "matmul" else object
72
+ result = self._apply(node, [arg.astype(wide) for arg in args], out_shape, None)
73
+ low, high = INT_RANGES[dtype]
74
+ if result.size and (result.min() < low or result.max() > high):
75
+ raise DTypeMismatch(
76
+ f"{node.output}: value outside {dtype} range",
77
+ node=node.output, expected=[low, high], actual=f"{int(((result < low) | (result > high)).sum())} values out of range",
78
+ )
79
+ return result.astype(dtype)
80
+ return self._apply(node, args, out_shape, out)
81
+
82
+ def _apply(self, node: Node, args: list, out_shape: tuple[int, ...], out: Any):
83
+ np = self.np
84
+ if node.operation == "add":
85
+ return np.add(*args, out=out, order="C")
86
+ if node.operation == "multiply":
87
+ return np.multiply(*args, out=out, order="C")
88
+ if node.operation == "subtract":
89
+ return np.subtract(*args, out=out, order="C")
90
+ if node.operation == "divide":
91
+ return np.divide(*args, out=out, order="C")
92
+ if node.operation == "relu":
93
+ return np.maximum(args[0], args[0].dtype.type(0), out=out, order="C")
94
+ if node.operation == "scale":
95
+ return np.multiply(args[0], args[0].dtype.type(node.attribute("factor")), out=out, order="C")
96
+ if node.operation == "reshape":
97
+ return args[0].reshape(out_shape)
98
+ if node.operation == "transpose":
99
+ return args[0].transpose(node.attribute("axes"))
100
+ if node.operation == "matmul":
101
+ return np.matmul(*args, out=out)
102
+ if node.operation == "softmax":
103
+ values = args[0]
104
+ result = np.subtract(values, values.max(axis=-1, keepdims=True, initial=-np.inf), out=out, order="C")
105
+ np.exp(result, out=result)
106
+ return np.divide(result, result.sum(axis=-1, keepdims=True), out=result)
107
+ if node.operation == "layer_norm":
108
+ values, gamma, beta = args
109
+ width = values.shape[-1]
110
+ result = np.subtract(values, values.mean(axis=-1, keepdims=True), out=out, order="C")
111
+ rows = result.reshape(-1, width)
112
+ variance = np.einsum("ij,ij->i", rows, rows).reshape(result.shape[:-1] + (1,))
113
+ np.divide(variance, width, out=variance)
114
+ np.add(variance, node.attribute("eps"), out=variance)
115
+ np.sqrt(variance, out=variance)
116
+ np.divide(result, variance, out=result)
117
+ np.multiply(result, gamma, out=result)
118
+ return np.add(result, beta, out=result)
119
+ raise ContractViolation(f"{node.name}: unsupported operation {node.operation}", node=node.name, actual=node.operation)
120
+
121
+ def digest(self, values: Mapping[str, tuple[str, Any]]) -> str:
122
+ np = self.np
123
+ entries = []
124
+ for name, (dtype, data) in values.items():
125
+ wide = np.ascontiguousarray(data, dtype="<f8" if dtype in FLOAT_DTYPES else "<i8")
126
+ entries.append((name, dtype, int(wide.size), wide.tobytes()))
127
+ return frame_digest(entries)
@@ -0,0 +1,184 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from contextlib import nullcontext
3
+ from typing import Any
4
+ import array
5
+ import math
6
+ import platform
7
+ import sys
8
+
9
+ from ..core import FLOAT_DTYPES, INT_RANGES, Dims, Node, TensorSpec, _product
10
+ from ..failures import ContractViolation, DTypeMismatch, ShapeMismatch
11
+ from . import frame_digest
12
+
13
+
14
+ class PythonBackend:
15
+ name = "python"
16
+ version = platform.python_version()
17
+
18
+ def session(self):
19
+ return nullcontext()
20
+
21
+ def length(self, name: str, data: Any) -> int:
22
+ if isinstance(data, (str, bytes)) or not isinstance(data, Sequence):
23
+ raise DTypeMismatch(
24
+ f"input {name} must be a flat sequence of numbers, got {type(data).__name__}",
25
+ node=name, expected="sequence", actual=type(data).__name__,
26
+ )
27
+ return len(data)
28
+
29
+ def coerce(self, name: str, data: Any, spec: TensorSpec, dims: Dims) -> list:
30
+ expected = _product(spec.concrete_shape(dims))
31
+ if self.length(name, data) != expected:
32
+ raise ShapeMismatch(
33
+ f"input {name} has {len(data)} elements, shape {spec.shape} needs {expected}",
34
+ node=name, expected=expected, actual=len(data),
35
+ )
36
+ if spec.dtype in FLOAT_DTYPES:
37
+ if all(type(value) is float for value in data):
38
+ return data if isinstance(data, list) else list(data)
39
+ if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in data):
40
+ raise DTypeMismatch(
41
+ f"input {name} must contain only numbers for {spec.dtype}",
42
+ node=name, expected=spec.dtype, actual=_first_invalid(data, (int, float)),
43
+ )
44
+ return [float(value) for value in data]
45
+ if not all(type(value) is int for value in data) and any(
46
+ isinstance(value, bool) or not isinstance(value, int) for value in data
47
+ ):
48
+ raise DTypeMismatch(
49
+ f"input {name} must contain only ints for {spec.dtype}",
50
+ node=name, expected=spec.dtype, actual=_first_invalid(data, (int,)),
51
+ )
52
+ values = [int(value) for value in data]
53
+ check_range(name, values, spec.dtype)
54
+ return values
55
+
56
+ def run(
57
+ self, node: Node, args: list[list], in_shapes: list[tuple[int, ...]], out_shape: tuple[int, ...], out: Any = None,
58
+ ) -> list:
59
+ result = self._apply(node, args, in_shapes, out_shape)
60
+ check_range(node.output, result, node.output_spec.dtype)
61
+ return result
62
+
63
+ def _apply(self, node: Node, args: list[list], in_shapes: list[tuple[int, ...]], out_shape: tuple[int, ...]) -> list:
64
+ operation = node.operation
65
+ if operation in BINARY:
66
+ left, right = args
67
+ if in_shapes[0] != out_shape:
68
+ left = [left[index] for index in broadcast_indices(in_shapes[0], out_shape)]
69
+ if in_shapes[1] != out_shape:
70
+ right = [right[index] for index in broadcast_indices(in_shapes[1], out_shape)]
71
+ function = BINARY[operation]
72
+ return [function(a, b) for a, b in zip(left, right)]
73
+ if operation == "relu":
74
+ zero = 0.0 if node.output_spec.dtype in FLOAT_DTYPES else 0
75
+ return [zero if value < zero else value for value in args[0]]
76
+ if operation == "scale":
77
+ factor = node.attribute("factor")
78
+ return [value * factor for value in args[0]]
79
+ if operation == "reshape":
80
+ return args[0]
81
+ if operation == "transpose":
82
+ return [args[0][index] for index in transpose_indices(in_shapes[0], node.attribute("axes"))]
83
+ if operation == "matmul":
84
+ left, right = args
85
+ (rows, inner), (_, columns) = in_shapes
86
+ return [sum(left[row * inner + k] * right[k * columns + col] for k in range(inner)) for row in range(rows) for col in range(columns)]
87
+ if operation == "softmax":
88
+ return [value for row in _rows(args[0], out_shape) for value in _softmax(row)]
89
+ if operation == "layer_norm":
90
+ values, gamma, beta = args
91
+ eps = node.attribute("eps")
92
+ return [value for row in _rows(values, out_shape) for value in _layer_norm(row, gamma, beta, eps)]
93
+ raise ContractViolation(f"{node.name}: unsupported operation {operation}", node=node.name, actual=operation)
94
+
95
+ def digest(self, values: Mapping[str, tuple[str, list]]) -> str:
96
+ entries = []
97
+ for name, (dtype, data) in values.items():
98
+ packed = array.array("d" if dtype in FLOAT_DTYPES else "q", data)
99
+ if sys.byteorder == "big":
100
+ packed.byteswap()
101
+ entries.append((name, dtype, len(data), packed.tobytes()))
102
+ return frame_digest(entries)
103
+
104
+
105
+ def _divide(a: float, b: float) -> float:
106
+ if b != 0:
107
+ return a / b
108
+ if a != a or a == 0:
109
+ return math.nan
110
+ return math.copysign(math.inf, a) * math.copysign(1.0, b)
111
+
112
+
113
+ BINARY = {
114
+ "add": lambda a, b: a + b,
115
+ "subtract": lambda a, b: a - b,
116
+ "multiply": lambda a, b: a * b,
117
+ "divide": _divide,
118
+ }
119
+
120
+
121
+ def _strides(shape: tuple[int, ...]) -> list[int]:
122
+ strides = [1] * len(shape)
123
+ for axis in range(len(shape) - 2, -1, -1):
124
+ strides[axis] = strides[axis + 1] * shape[axis + 1]
125
+ return strides
126
+
127
+
128
+ def _expand(sizes: tuple[int, ...], strides: list[int]) -> list[int]:
129
+ indices = [0]
130
+ for size, stride in zip(sizes, strides):
131
+ indices = [base + step * stride for base in indices for step in range(size)]
132
+ return indices
133
+
134
+
135
+ def broadcast_indices(in_shape: tuple[int, ...], out_shape: tuple[int, ...]) -> list[int]:
136
+ padded = (1,) * (len(out_shape) - len(in_shape)) + tuple(in_shape)
137
+ strides = [0 if size == 1 else stride for size, stride in zip(padded, _strides(padded))]
138
+ return _expand(out_shape, strides)
139
+
140
+
141
+ def transpose_indices(in_shape: tuple[int, ...], axes: tuple[int, ...]) -> list[int]:
142
+ strides = _strides(in_shape)
143
+ return _expand(tuple(in_shape[axis] for axis in axes), [strides[axis] for axis in axes])
144
+
145
+
146
+ def _rows(values: list, shape: tuple[int, ...]):
147
+ width = shape[-1]
148
+ for start in range(0, len(values), width or 1):
149
+ yield values[start:start + width]
150
+
151
+
152
+ def _softmax(row: list) -> list:
153
+ if any(value != value for value in row):
154
+ return [math.nan] * len(row)
155
+ peak = max(row, default=0.0)
156
+ exponents = [math.exp(value - peak) if value - peak == value - peak else math.nan for value in row]
157
+ total = sum(exponents)
158
+ return [_divide(value, total) for value in exponents]
159
+
160
+
161
+ def _layer_norm(row: list, gamma: list, beta: list, eps: float) -> list:
162
+ width = len(row)
163
+ mean = sum(row) / width if width else math.nan
164
+ centered = [value - mean for value in row]
165
+ deviation = math.sqrt(sum(value * value for value in centered) / width + eps) if width else math.nan
166
+ return [value / deviation * g + b for value, g, b in zip(centered, gamma, beta)]
167
+
168
+
169
+ def check_range(name: str, values: list, dtype: str) -> None:
170
+ if dtype not in INT_RANGES:
171
+ return
172
+ low, high = INT_RANGES[dtype]
173
+ if values and (min(values) < low or max(values) > high):
174
+ raise DTypeMismatch(
175
+ f"{name}: value outside {dtype} range",
176
+ node=name, expected=[low, high], actual=f"{sum(not low <= value <= high for value in values)} values out of range",
177
+ )
178
+
179
+
180
+ def _first_invalid(data: Sequence, allowed: tuple[type, ...]) -> str:
181
+ for value in data:
182
+ if isinstance(value, bool) or not isinstance(value, allowed):
183
+ return type(value).__name__
184
+ return ""
graphspace/claims.py ADDED
@@ -0,0 +1,28 @@
1
+ from collections.abc import Iterable
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+
7
+ class Basis(str, Enum):
8
+ DECLARED = "declared"
9
+ PROVEN = "proven"
10
+ INFERRED = "inferred"
11
+ ESTIMATED = "estimated"
12
+ RUNTIME_CHECKED = "runtime_checked"
13
+ MEASURED = "measured"
14
+ BACKEND_REPORTED = "backend_reported"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Claim:
19
+ name: str
20
+ value: Any
21
+ basis: Basis
22
+
23
+
24
+ def find_claim(claims: Iterable[Claim], name: str) -> Claim:
25
+ for claim in claims:
26
+ if claim.name == name:
27
+ return claim
28
+ raise KeyError(name)
graphspace/cli.py ADDED
@@ -0,0 +1,24 @@
1
+ from .core import Graph, ResourceContract, TensorSpec
2
+
3
+
4
+ def demo() -> int:
5
+ graph = Graph("demo_add", ResourceContract.max_memory(65536, deterministic=True))
6
+ spec = TensorSpec((2, 4), "float32")
7
+ graph.input("a", spec)
8
+ graph.input("b", spec)
9
+ output = graph.add("a", "b")
10
+ graph.output(output)
11
+ result, record = graph.execute({"a": [1.0] * 8, "b": [2.0] * 8})
12
+ print(f"graph={record.graph_name}")
13
+ print(f"result_elements={len(result)}")
14
+ print(f"peak_memory_bytes={record.peak_memory_bytes}")
15
+ print(f"deterministic={record.deterministic}")
16
+ return 0
17
+
18
+
19
+ def main() -> int:
20
+ return demo()
21
+
22
+
23
+ if __name__ == "__main__":
24
+ raise SystemExit(main())