lattice-contract 0.1.0__tar.gz
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.
- lattice_contract-0.1.0/.gitignore +27 -0
- lattice_contract-0.1.0/PKG-INFO +21 -0
- lattice_contract-0.1.0/README.md +11 -0
- lattice_contract-0.1.0/lattice_contract/__init__.py +62 -0
- lattice_contract-0.1.0/lattice_contract/manifest.py +456 -0
- lattice_contract-0.1.0/lattice_contract/ops.py +210 -0
- lattice_contract-0.1.0/lattice_contract/py.typed +0 -0
- lattice_contract-0.1.0/pyproject.toml +32 -0
- lattice_contract-0.1.0/tests/test_contract_manifest.py +77 -0
- lattice_contract-0.1.0/tests/test_contract_surface.py +17 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# uv
|
|
2
|
+
.venv
|
|
3
|
+
.python-version
|
|
4
|
+
.ruff_cache
|
|
5
|
+
.pytest_cache
|
|
6
|
+
__pycache__
|
|
7
|
+
*.py[cod]
|
|
8
|
+
dist
|
|
9
|
+
build
|
|
10
|
+
benchmarks/results/
|
|
11
|
+
**/_ext*.so
|
|
12
|
+
**/_ext*.pyd
|
|
13
|
+
**/_ext*.dll
|
|
14
|
+
**/_ext*.dylib
|
|
15
|
+
**/_ext.pyi
|
|
16
|
+
|
|
17
|
+
# ly's choice
|
|
18
|
+
references
|
|
19
|
+
|
|
20
|
+
# preferences
|
|
21
|
+
/.vscode
|
|
22
|
+
/.zed
|
|
23
|
+
/.vercel
|
|
24
|
+
.env*
|
|
25
|
+
|
|
26
|
+
# os-related
|
|
27
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lattice-contract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Backend-neutral sparse lattice artifact contract
|
|
5
|
+
Author-email: "Z.Y. Lin" <me@iki.moe>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ir,lattice,model-artifact,sparse
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# lattice-contract
|
|
12
|
+
|
|
13
|
+
`lattice-contract` is the backend-neutral artifact contract package used by
|
|
14
|
+
`mlx-lattice`.
|
|
15
|
+
|
|
16
|
+
It contains the sparse model manifest dataclasses, validation helpers, value
|
|
17
|
+
type names, and operation-contract annotations. It intentionally does not
|
|
18
|
+
import MLX, Torch, native extensions, or backend runtime objects.
|
|
19
|
+
|
|
20
|
+
This package exists so training-side and deployment-side tools can share the
|
|
21
|
+
same sparse graph contract while keeping their execution stacks independent.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# lattice-contract
|
|
2
|
+
|
|
3
|
+
`lattice-contract` is the backend-neutral artifact contract package used by
|
|
4
|
+
`mlx-lattice`.
|
|
5
|
+
|
|
6
|
+
It contains the sparse model manifest dataclasses, validation helpers, value
|
|
7
|
+
type names, and operation-contract annotations. It intentionally does not
|
|
8
|
+
import MLX, Torch, native extensions, or backend runtime objects.
|
|
9
|
+
|
|
10
|
+
This package exists so training-side and deployment-side tools can share the
|
|
11
|
+
same sparse graph contract while keeping their execution stacks independent.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from lattice_contract.manifest import (
|
|
4
|
+
CURRENT_SCHEMA_VERSION,
|
|
5
|
+
DTypePolicy,
|
|
6
|
+
IRInputRef,
|
|
7
|
+
IRManifest,
|
|
8
|
+
IRNode,
|
|
9
|
+
IRParameter,
|
|
10
|
+
IRSparseSupport,
|
|
11
|
+
IRTensorSpec,
|
|
12
|
+
IRValueType,
|
|
13
|
+
Triple,
|
|
14
|
+
ir_value_type,
|
|
15
|
+
is_ir_value_type,
|
|
16
|
+
load_manifest,
|
|
17
|
+
manifest_from_dict,
|
|
18
|
+
manifest_to_dict,
|
|
19
|
+
triple,
|
|
20
|
+
)
|
|
21
|
+
from lattice_contract.ops import (
|
|
22
|
+
IROpArtifactHints,
|
|
23
|
+
IROpSpec,
|
|
24
|
+
IRParameterKind,
|
|
25
|
+
ir_op_spec,
|
|
26
|
+
iter_op_specs,
|
|
27
|
+
lattice_op_hints,
|
|
28
|
+
op_artifact_hints,
|
|
29
|
+
op_spec,
|
|
30
|
+
validate_node_against_spec,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__version__ = '0.1.0'
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
'CURRENT_SCHEMA_VERSION',
|
|
37
|
+
'DTypePolicy',
|
|
38
|
+
'IRInputRef',
|
|
39
|
+
'IRManifest',
|
|
40
|
+
'IRNode',
|
|
41
|
+
'IROpArtifactHints',
|
|
42
|
+
'IROpSpec',
|
|
43
|
+
'IRParameter',
|
|
44
|
+
'IRParameterKind',
|
|
45
|
+
'IRSparseSupport',
|
|
46
|
+
'IRTensorSpec',
|
|
47
|
+
'IRValueType',
|
|
48
|
+
'Triple',
|
|
49
|
+
'__version__',
|
|
50
|
+
'ir_op_spec',
|
|
51
|
+
'ir_value_type',
|
|
52
|
+
'is_ir_value_type',
|
|
53
|
+
'iter_op_specs',
|
|
54
|
+
'lattice_op_hints',
|
|
55
|
+
'load_manifest',
|
|
56
|
+
'manifest_from_dict',
|
|
57
|
+
'manifest_to_dict',
|
|
58
|
+
'op_artifact_hints',
|
|
59
|
+
'op_spec',
|
|
60
|
+
'triple',
|
|
61
|
+
'validate_node_against_spec',
|
|
62
|
+
]
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal, cast
|
|
8
|
+
|
|
9
|
+
CURRENT_SCHEMA_VERSION = '0.1'
|
|
10
|
+
|
|
11
|
+
DTypePolicy = Literal['preserve', 'fp32', 'fp16', 'fp16_inference']
|
|
12
|
+
IRValueType = Literal[
|
|
13
|
+
'any',
|
|
14
|
+
'sparse_tensor',
|
|
15
|
+
'dense_tensor',
|
|
16
|
+
'relation',
|
|
17
|
+
'coordinate_set',
|
|
18
|
+
'alignment',
|
|
19
|
+
'quantization',
|
|
20
|
+
'point_voxel_map',
|
|
21
|
+
'coordinate_ordering',
|
|
22
|
+
'sparse_occupancy',
|
|
23
|
+
'occupancy_expansion',
|
|
24
|
+
'bytes',
|
|
25
|
+
]
|
|
26
|
+
IRParameter = str
|
|
27
|
+
type IRInputRef = str | tuple[str, ...]
|
|
28
|
+
Triple = tuple[int, int, int]
|
|
29
|
+
|
|
30
|
+
_IR_VALUE_TYPES = frozenset(
|
|
31
|
+
(
|
|
32
|
+
'any',
|
|
33
|
+
'sparse_tensor',
|
|
34
|
+
'dense_tensor',
|
|
35
|
+
'relation',
|
|
36
|
+
'coordinate_set',
|
|
37
|
+
'alignment',
|
|
38
|
+
'quantization',
|
|
39
|
+
'point_voxel_map',
|
|
40
|
+
'coordinate_ordering',
|
|
41
|
+
'sparse_occupancy',
|
|
42
|
+
'occupancy_expansion',
|
|
43
|
+
'bytes',
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
_COORDINATE_ORDER = ('batch', 'x', 'y', 'z')
|
|
47
|
+
_FEATURE_LAYOUT = ('row', 'channel')
|
|
48
|
+
_WEIGHT_LAYOUT = 'mlx-lattice'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def triple(value: int | Sequence[int], *, name: str) -> Triple:
|
|
52
|
+
"""Normalize an integer or 3-sequence into a spatial integer triple."""
|
|
53
|
+
|
|
54
|
+
if isinstance(value, int):
|
|
55
|
+
return (value, value, value)
|
|
56
|
+
if len(value) != 3:
|
|
57
|
+
raise ValueError(f'{name} must be an int or a sequence of 3 ints.')
|
|
58
|
+
out = tuple(int(item) for item in value)
|
|
59
|
+
if len(out) != 3:
|
|
60
|
+
raise ValueError(f'{name} must be an int or a sequence of 3 ints.')
|
|
61
|
+
return cast('Triple', out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class IRSparseSupport:
|
|
66
|
+
"""Sparse relation/support attributes for an IR operation."""
|
|
67
|
+
|
|
68
|
+
kind: str
|
|
69
|
+
kernel_size: Triple | None = None
|
|
70
|
+
stride: Triple | None = None
|
|
71
|
+
padding: Triple | None = None
|
|
72
|
+
dilation: Triple | None = None
|
|
73
|
+
target: str | None = None
|
|
74
|
+
mode: str | None = None
|
|
75
|
+
join: str | None = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class IRTensorSpec:
|
|
80
|
+
"""Named graph input or output specification."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
type: IRValueType
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class IRNode:
|
|
88
|
+
"""One semantic operation in a lattice model graph."""
|
|
89
|
+
|
|
90
|
+
id: str
|
|
91
|
+
op: str
|
|
92
|
+
inputs: dict[str, IRInputRef]
|
|
93
|
+
outputs: dict[str, str]
|
|
94
|
+
parameters: dict[str, IRParameter] = field(default_factory=dict)
|
|
95
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
96
|
+
support: IRSparseSupport | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True, slots=True)
|
|
100
|
+
class IRManifest:
|
|
101
|
+
"""Validated sparse model manifest.
|
|
102
|
+
|
|
103
|
+
The manifest is the stable artifact contract shared by future training
|
|
104
|
+
producers and the MLX artifact loader. It records semantic sparse graph
|
|
105
|
+
nodes and names the tensor weights stored beside it.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
schema_version: str
|
|
109
|
+
producer: dict[str, str]
|
|
110
|
+
runtime: dict[str, str]
|
|
111
|
+
inputs: tuple[IRTensorSpec, ...]
|
|
112
|
+
outputs: tuple[IRTensorSpec, ...]
|
|
113
|
+
nodes: tuple[IRNode, ...]
|
|
114
|
+
dtype_policy: DTypePolicy = 'preserve'
|
|
115
|
+
coordinate_order: tuple[str, ...] = _COORDINATE_ORDER
|
|
116
|
+
feature_layout: tuple[str, ...] = _FEATURE_LAYOUT
|
|
117
|
+
weight_layout: str = _WEIGHT_LAYOUT
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_manifest(path: str | Path) -> IRManifest:
|
|
121
|
+
"""Load and validate a manifest JSON file."""
|
|
122
|
+
|
|
123
|
+
with Path(path).open('r', encoding='utf-8') as file:
|
|
124
|
+
raw = json.load(file)
|
|
125
|
+
return manifest_from_dict(raw)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def manifest_from_dict(raw: Mapping[str, Any]) -> IRManifest:
|
|
129
|
+
"""Build a validated :class:`IRManifest` from decoded JSON data."""
|
|
130
|
+
|
|
131
|
+
_require_mapping(raw, 'manifest')
|
|
132
|
+
schema_version = _require_str(raw, 'schema_version')
|
|
133
|
+
if schema_version != CURRENT_SCHEMA_VERSION:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f'unsupported lattice IR schema_version {schema_version!r}; '
|
|
136
|
+
f'expected {CURRENT_SCHEMA_VERSION!r}.'
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
coordinate_order = _str_tuple(
|
|
140
|
+
raw.get('coordinate_order', _COORDINATE_ORDER),
|
|
141
|
+
'coordinate_order',
|
|
142
|
+
)
|
|
143
|
+
if coordinate_order != _COORDINATE_ORDER:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
"coordinate_order must be ['batch', 'x', 'y', 'z']."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
feature_layout = _str_tuple(
|
|
149
|
+
raw.get('feature_layout', _FEATURE_LAYOUT),
|
|
150
|
+
'feature_layout',
|
|
151
|
+
)
|
|
152
|
+
if feature_layout != _FEATURE_LAYOUT:
|
|
153
|
+
raise ValueError("feature_layout must be ['row', 'channel'].")
|
|
154
|
+
|
|
155
|
+
weight_layout = _require_str(
|
|
156
|
+
raw, 'weight_layout', default=_WEIGHT_LAYOUT
|
|
157
|
+
)
|
|
158
|
+
if weight_layout != _WEIGHT_LAYOUT:
|
|
159
|
+
raise ValueError("weight_layout must be 'mlx-lattice'.")
|
|
160
|
+
|
|
161
|
+
dtype_policy = _dtype_policy(
|
|
162
|
+
_require_str(raw, 'dtype_policy', default='preserve')
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
manifest = IRManifest(
|
|
166
|
+
schema_version=schema_version,
|
|
167
|
+
producer=_str_map(raw.get('producer', {}), 'producer'),
|
|
168
|
+
runtime=_str_map(raw.get('runtime', {}), 'runtime'),
|
|
169
|
+
coordinate_order=coordinate_order,
|
|
170
|
+
feature_layout=feature_layout,
|
|
171
|
+
weight_layout=weight_layout,
|
|
172
|
+
dtype_policy=dtype_policy,
|
|
173
|
+
inputs=tuple(
|
|
174
|
+
_tensor_spec(item, f'inputs[{index}]')
|
|
175
|
+
for index, item in enumerate(_require_list(raw, 'inputs'))
|
|
176
|
+
),
|
|
177
|
+
outputs=tuple(
|
|
178
|
+
_tensor_spec(item, f'outputs[{index}]')
|
|
179
|
+
for index, item in enumerate(_require_list(raw, 'outputs'))
|
|
180
|
+
),
|
|
181
|
+
nodes=tuple(
|
|
182
|
+
_node(item, f'nodes[{index}]')
|
|
183
|
+
for index, item in enumerate(_require_list(raw, 'nodes'))
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
_validate_graph(manifest)
|
|
187
|
+
return manifest
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def manifest_to_dict(manifest: IRManifest) -> dict[str, Any]:
|
|
191
|
+
"""Convert a manifest object to JSON-serializable data."""
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
'schema_version': manifest.schema_version,
|
|
195
|
+
'producer': dict(manifest.producer),
|
|
196
|
+
'runtime': dict(manifest.runtime),
|
|
197
|
+
'coordinate_order': list(manifest.coordinate_order),
|
|
198
|
+
'feature_layout': list(manifest.feature_layout),
|
|
199
|
+
'weight_layout': manifest.weight_layout,
|
|
200
|
+
'dtype_policy': manifest.dtype_policy,
|
|
201
|
+
'inputs': [
|
|
202
|
+
{'name': item.name, 'type': item.type}
|
|
203
|
+
for item in manifest.inputs
|
|
204
|
+
],
|
|
205
|
+
'outputs': [
|
|
206
|
+
{'name': item.name, 'type': item.type}
|
|
207
|
+
for item in manifest.outputs
|
|
208
|
+
],
|
|
209
|
+
'nodes': [_node_to_dict(node) for node in manifest.nodes],
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_ir_value_type(value: str) -> bool:
|
|
214
|
+
"""Return whether ``value`` is a supported lattice IR value type."""
|
|
215
|
+
|
|
216
|
+
return value in _IR_VALUE_TYPES
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def ir_value_type(value: str) -> IRValueType:
|
|
220
|
+
"""Validate and cast a string into :class:`IRValueType`."""
|
|
221
|
+
|
|
222
|
+
if not is_ir_value_type(value):
|
|
223
|
+
raise ValueError(f'unsupported IR value type: {value!r}.')
|
|
224
|
+
return cast('IRValueType', value)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _node_to_dict(node: IRNode) -> dict[str, Any]:
|
|
228
|
+
raw: dict[str, Any] = {
|
|
229
|
+
'id': node.id,
|
|
230
|
+
'op': node.op,
|
|
231
|
+
'inputs': {
|
|
232
|
+
key: value if isinstance(value, str) else list(value)
|
|
233
|
+
for key, value in node.inputs.items()
|
|
234
|
+
},
|
|
235
|
+
'outputs': dict(node.outputs),
|
|
236
|
+
}
|
|
237
|
+
if node.parameters:
|
|
238
|
+
raw['parameters'] = dict(node.parameters)
|
|
239
|
+
if node.attributes:
|
|
240
|
+
raw['attributes'] = dict(node.attributes)
|
|
241
|
+
if node.support is not None:
|
|
242
|
+
raw['support'] = _support_to_dict(node.support)
|
|
243
|
+
return raw
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _support_to_dict(support: IRSparseSupport) -> dict[str, Any]:
|
|
247
|
+
raw: dict[str, Any] = {'kind': support.kind}
|
|
248
|
+
for name in ('kernel_size', 'stride', 'padding', 'dilation'):
|
|
249
|
+
value = getattr(support, name)
|
|
250
|
+
if value is not None:
|
|
251
|
+
raw[name] = list(value)
|
|
252
|
+
for name in ('target', 'mode', 'join'):
|
|
253
|
+
value = getattr(support, name)
|
|
254
|
+
if value is not None:
|
|
255
|
+
raw[name] = value
|
|
256
|
+
return raw
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _support_attributes(support: IRSparseSupport) -> dict[str, Any]:
|
|
260
|
+
raw: dict[str, Any] = {}
|
|
261
|
+
for name in ('kernel_size', 'stride', 'padding', 'dilation'):
|
|
262
|
+
value = getattr(support, name)
|
|
263
|
+
if value is not None:
|
|
264
|
+
raw[name] = value
|
|
265
|
+
if support.target is not None:
|
|
266
|
+
raw['coordinates'] = support.target
|
|
267
|
+
if support.mode is not None:
|
|
268
|
+
raw['mode'] = support.mode
|
|
269
|
+
if support.join is not None:
|
|
270
|
+
raw['join'] = support.join
|
|
271
|
+
return raw
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _node(raw: Any, path: str) -> IRNode:
|
|
275
|
+
data = _require_mapping(raw, path)
|
|
276
|
+
attributes = dict(
|
|
277
|
+
_require_mapping(
|
|
278
|
+
data.get('attributes', {}),
|
|
279
|
+
f'{path}.attributes',
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
support = (
|
|
283
|
+
None
|
|
284
|
+
if 'support' not in data
|
|
285
|
+
else _support(data['support'], f'{path}.support')
|
|
286
|
+
)
|
|
287
|
+
if support is not None:
|
|
288
|
+
attributes.update(_support_attributes(support))
|
|
289
|
+
return IRNode(
|
|
290
|
+
id=_require_str(data, 'id', path=path),
|
|
291
|
+
op=_require_str(data, 'op', path=path),
|
|
292
|
+
inputs=_input_map(data.get('inputs', {}), f'{path}.inputs'),
|
|
293
|
+
outputs=_str_map(data.get('outputs', {}), f'{path}.outputs'),
|
|
294
|
+
parameters=_str_map(
|
|
295
|
+
data.get('parameters', {}), f'{path}.parameters'
|
|
296
|
+
),
|
|
297
|
+
attributes=attributes,
|
|
298
|
+
support=support,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _support(raw: Any, path: str) -> IRSparseSupport:
|
|
303
|
+
data = _require_mapping(raw, path)
|
|
304
|
+
return IRSparseSupport(
|
|
305
|
+
kind=_require_str(data, 'kind', path=path),
|
|
306
|
+
kernel_size=_optional_triple(data, 'kernel_size', path),
|
|
307
|
+
stride=_optional_triple(data, 'stride', path),
|
|
308
|
+
padding=_optional_triple(data, 'padding', path),
|
|
309
|
+
dilation=_optional_triple(data, 'dilation', path),
|
|
310
|
+
target=_optional_str(data, 'target', path),
|
|
311
|
+
mode=_optional_str(data, 'mode', path),
|
|
312
|
+
join=_optional_str(data, 'join', path),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _tensor_spec(raw: Any, path: str) -> IRTensorSpec:
|
|
317
|
+
data = _require_mapping(raw, path)
|
|
318
|
+
name = _require_str(data, 'name', path=path)
|
|
319
|
+
value_type = _require_str(data, 'type', path=path)
|
|
320
|
+
try:
|
|
321
|
+
typed = ir_value_type(value_type)
|
|
322
|
+
except ValueError as exc:
|
|
323
|
+
raise ValueError(
|
|
324
|
+
f'{path}.type is not a supported lattice IR value type.'
|
|
325
|
+
) from exc
|
|
326
|
+
return IRTensorSpec(name, typed)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _validate_graph(manifest: IRManifest) -> None:
|
|
330
|
+
names: set[str] = set()
|
|
331
|
+
node_ids: set[str] = set()
|
|
332
|
+
for item in manifest.inputs:
|
|
333
|
+
_require_unique(names, item.name, 'graph value')
|
|
334
|
+
for node in manifest.nodes:
|
|
335
|
+
_require_unique(node_ids, node.id, 'node id')
|
|
336
|
+
if not node.outputs:
|
|
337
|
+
raise ValueError(f'node {node.id!r} must define outputs.')
|
|
338
|
+
for value_ref in node.inputs.values():
|
|
339
|
+
for value_name in _value_refs(value_ref):
|
|
340
|
+
if value_name not in names:
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f'node {node.id!r} references unknown input '
|
|
343
|
+
f'{value_name!r}.'
|
|
344
|
+
)
|
|
345
|
+
for value_name in node.outputs.values():
|
|
346
|
+
_require_unique(names, value_name, 'graph value')
|
|
347
|
+
for item in manifest.outputs:
|
|
348
|
+
if item.name not in names:
|
|
349
|
+
raise ValueError(f'unknown manifest output {item.name!r}.')
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _require_unique(seen: set[str], value: str, label: str) -> None:
|
|
353
|
+
if value in seen:
|
|
354
|
+
raise ValueError(f'duplicate {label}: {value!r}.')
|
|
355
|
+
seen.add(value)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _optional_triple(
|
|
359
|
+
data: Mapping[str, Any],
|
|
360
|
+
name: str,
|
|
361
|
+
path: str,
|
|
362
|
+
) -> Triple | None:
|
|
363
|
+
if name not in data:
|
|
364
|
+
return None
|
|
365
|
+
return triple(data[name], name=f'{path}.{name}')
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _dtype_policy(value: str) -> DTypePolicy:
|
|
369
|
+
if value not in ('preserve', 'fp32', 'fp16', 'fp16_inference'):
|
|
370
|
+
raise ValueError(
|
|
371
|
+
'dtype_policy must be preserve, fp32, fp16, or fp16_inference.'
|
|
372
|
+
)
|
|
373
|
+
return cast('DTypePolicy', value)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _str_tuple(raw: Any, path: str) -> tuple[str, ...]:
|
|
377
|
+
if not isinstance(raw, list | tuple):
|
|
378
|
+
raise ValueError(f'{path} must be a list of strings.')
|
|
379
|
+
values = tuple(raw)
|
|
380
|
+
if not all(isinstance(item, str) for item in values):
|
|
381
|
+
raise ValueError(f'{path} must be a list of strings.')
|
|
382
|
+
return cast(tuple[str, ...], values)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _str_map(raw: Any, path: str) -> dict[str, str]:
|
|
386
|
+
data = _require_mapping(raw, path)
|
|
387
|
+
out: dict[str, str] = {}
|
|
388
|
+
for key, value in data.items():
|
|
389
|
+
if not isinstance(key, str) or not isinstance(value, str):
|
|
390
|
+
raise ValueError(f'{path} must map strings to strings.')
|
|
391
|
+
out[key] = value
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _input_map(raw: Any, path: str) -> dict[str, IRInputRef]:
|
|
396
|
+
data = _require_mapping(raw, path)
|
|
397
|
+
out: dict[str, IRInputRef] = {}
|
|
398
|
+
for key, value in data.items():
|
|
399
|
+
if not isinstance(key, str):
|
|
400
|
+
raise ValueError(f'{path} must map strings to value refs.')
|
|
401
|
+
if isinstance(value, str):
|
|
402
|
+
out[key] = value
|
|
403
|
+
continue
|
|
404
|
+
if isinstance(value, list | tuple) and all(
|
|
405
|
+
isinstance(item, str) for item in value
|
|
406
|
+
):
|
|
407
|
+
out[key] = tuple(value)
|
|
408
|
+
continue
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f'{path}.{key} must be a string or list of strings.'
|
|
411
|
+
)
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _value_refs(value: IRInputRef) -> tuple[str, ...]:
|
|
416
|
+
return (value,) if isinstance(value, str) else value
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _require_mapping(raw: Any, path: str) -> Mapping[str, Any]:
|
|
420
|
+
if not isinstance(raw, Mapping):
|
|
421
|
+
raise ValueError(f'{path} must be an object.')
|
|
422
|
+
return raw
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _require_list(raw: Mapping[str, Any], name: str) -> Sequence[Any]:
|
|
426
|
+
value = raw.get(name)
|
|
427
|
+
if not isinstance(value, list):
|
|
428
|
+
raise ValueError(f'{name} must be a list.')
|
|
429
|
+
return value
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _require_str(
|
|
433
|
+
raw: Mapping[str, Any],
|
|
434
|
+
name: str,
|
|
435
|
+
*,
|
|
436
|
+
path: str = '',
|
|
437
|
+
default: str | None = None,
|
|
438
|
+
) -> str:
|
|
439
|
+
value = raw.get(name, default)
|
|
440
|
+
if not isinstance(value, str):
|
|
441
|
+
prefix = f'{path}.' if path else ''
|
|
442
|
+
raise ValueError(f'{prefix}{name} must be a string.')
|
|
443
|
+
return value
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _optional_str(
|
|
447
|
+
raw: Mapping[str, Any],
|
|
448
|
+
name: str,
|
|
449
|
+
path: str,
|
|
450
|
+
) -> str | None:
|
|
451
|
+
if name not in raw:
|
|
452
|
+
return None
|
|
453
|
+
value = raw[name]
|
|
454
|
+
if not isinstance(value, str):
|
|
455
|
+
raise ValueError(f'{path}.{name} must be a string.')
|
|
456
|
+
return value
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Literal, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from lattice_contract.manifest import IRInputRef, IRNode, IRValueType
|
|
8
|
+
|
|
9
|
+
DeclarationT = TypeVar('DeclarationT', bound=Callable)
|
|
10
|
+
FunctionT = TypeVar('FunctionT', bound=Callable)
|
|
11
|
+
IRParameterKind = Literal[
|
|
12
|
+
'array',
|
|
13
|
+
'optional_array',
|
|
14
|
+
'quantized_weight',
|
|
15
|
+
'array_or_quantized_weight',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class IROpSpec:
|
|
21
|
+
"""Static semantic contract for one lattice IR operation."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
inputs: frozenset[str]
|
|
25
|
+
outputs: frozenset[str]
|
|
26
|
+
output_types: dict[str, IRValueType]
|
|
27
|
+
input_types: dict[str, IRValueType] = field(default_factory=dict)
|
|
28
|
+
value_attribute_types: dict[str, IRValueType] = field(
|
|
29
|
+
default_factory=dict
|
|
30
|
+
)
|
|
31
|
+
parameters: frozenset[str] = frozenset()
|
|
32
|
+
optional_parameters: frozenset[str] = frozenset()
|
|
33
|
+
attributes: frozenset[str] = frozenset()
|
|
34
|
+
value_attributes: frozenset[str] = frozenset()
|
|
35
|
+
requires_support: bool = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class IROpArtifactHints:
|
|
40
|
+
"""Artifact hints that cannot be inferred from annotations alone."""
|
|
41
|
+
|
|
42
|
+
parameters: Mapping[str, IRParameterKind] = field(default_factory=dict)
|
|
43
|
+
optional_parameters: Mapping[str, IRParameterKind] = field(
|
|
44
|
+
default_factory=dict
|
|
45
|
+
)
|
|
46
|
+
attributes: frozenset[str] = frozenset()
|
|
47
|
+
value_attributes: frozenset[str] = frozenset()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_OP_SPECS: dict[str, IROpSpec] = {}
|
|
51
|
+
_ARTIFACT_HINT_ATTR = '__mlx_lattice_op_artifact_hints__'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def lattice_op_hints(
|
|
55
|
+
*,
|
|
56
|
+
parameters: Mapping[str, str] | None = None,
|
|
57
|
+
optional_parameters: Mapping[str, str] | None = None,
|
|
58
|
+
attributes: set[str] | None = None,
|
|
59
|
+
value_attributes: set[str] | None = None,
|
|
60
|
+
) -> Callable[[FunctionT], FunctionT]:
|
|
61
|
+
"""Attach artifact classification hints to a public op function.
|
|
62
|
+
|
|
63
|
+
Most operation bindings are inferred from annotations. Hints are reserved
|
|
64
|
+
for ambiguous tensor arguments, especially persisted weights and optional
|
|
65
|
+
graph-carried values whose annotations include more than one IR type.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
hints = IROpArtifactHints(
|
|
69
|
+
parameters=_parameter_kinds(parameters),
|
|
70
|
+
optional_parameters=_parameter_kinds(optional_parameters),
|
|
71
|
+
attributes=frozenset(attributes or ()),
|
|
72
|
+
value_attributes=frozenset(value_attributes or ()),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def decorator(function: FunctionT) -> FunctionT:
|
|
76
|
+
setattr(function, _ARTIFACT_HINT_ATTR, hints)
|
|
77
|
+
return function
|
|
78
|
+
|
|
79
|
+
return decorator
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def op_artifact_hints(function: Callable) -> IROpArtifactHints:
|
|
83
|
+
"""Return artifact hints attached by :func:`lattice_op_hints`."""
|
|
84
|
+
|
|
85
|
+
return cast(
|
|
86
|
+
IROpArtifactHints,
|
|
87
|
+
getattr(function, _ARTIFACT_HINT_ATTR, IROpArtifactHints()),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parameter_kinds(
|
|
92
|
+
values: Mapping[str, str] | None,
|
|
93
|
+
) -> dict[str, IRParameterKind]:
|
|
94
|
+
return {
|
|
95
|
+
name: _parameter_kind(value)
|
|
96
|
+
for name, value in dict(values or {}).items()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parameter_kind(value: str) -> IRParameterKind:
|
|
101
|
+
if value not in (
|
|
102
|
+
'array',
|
|
103
|
+
'optional_array',
|
|
104
|
+
'quantized_weight',
|
|
105
|
+
'array_or_quantized_weight',
|
|
106
|
+
):
|
|
107
|
+
raise ValueError(f'unsupported IR parameter kind: {value!r}.')
|
|
108
|
+
return cast('IRParameterKind', value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ir_op_spec(
|
|
112
|
+
name: str,
|
|
113
|
+
*,
|
|
114
|
+
inputs: set[str],
|
|
115
|
+
outputs: set[str],
|
|
116
|
+
output_types: Mapping[str, IRValueType] | None = None,
|
|
117
|
+
input_types: Mapping[str, IRValueType] | None = None,
|
|
118
|
+
value_attribute_types: Mapping[str, IRValueType] | None = None,
|
|
119
|
+
parameters: set[str] | None = None,
|
|
120
|
+
optional_parameters: set[str] | None = None,
|
|
121
|
+
attributes: set[str] | None = None,
|
|
122
|
+
value_attributes: set[str] | None = None,
|
|
123
|
+
requires_support: bool = False,
|
|
124
|
+
) -> Callable[[DeclarationT], DeclarationT]:
|
|
125
|
+
"""Register the semantic contract for one IR operation.
|
|
126
|
+
|
|
127
|
+
The annotation keeps the stable IR operation set compact and discoverable
|
|
128
|
+
without maintaining a hand-written registry table. Artifact consumers
|
|
129
|
+
attach their implementations separately, so importing
|
|
130
|
+
:mod:`lattice_contract` exposes the complete semantic contract without
|
|
131
|
+
importing an MLX, Torch, or native graph executor.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
spec = IROpSpec(
|
|
135
|
+
name=name,
|
|
136
|
+
inputs=frozenset(inputs),
|
|
137
|
+
outputs=frozenset(outputs),
|
|
138
|
+
output_types=dict(output_types or {}),
|
|
139
|
+
input_types=dict(input_types or {}),
|
|
140
|
+
value_attribute_types=dict(value_attribute_types or {}),
|
|
141
|
+
parameters=frozenset(parameters or ()),
|
|
142
|
+
optional_parameters=frozenset(optional_parameters or ()),
|
|
143
|
+
attributes=frozenset(attributes or ()),
|
|
144
|
+
value_attributes=frozenset(value_attributes or ()),
|
|
145
|
+
requires_support=requires_support,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def decorator(declaration: DeclarationT) -> DeclarationT:
|
|
149
|
+
if name in _OP_SPECS:
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f'duplicate lattice IR op registration: {name}.'
|
|
152
|
+
)
|
|
153
|
+
_OP_SPECS[name] = spec
|
|
154
|
+
return declaration
|
|
155
|
+
|
|
156
|
+
return decorator
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def iter_op_specs() -> Iterator[IROpSpec]:
|
|
160
|
+
"""Iterate registered IR operation specs."""
|
|
161
|
+
|
|
162
|
+
return iter(_OP_SPECS.values())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def op_spec(name: str) -> IROpSpec:
|
|
166
|
+
"""Return the registered spec for ``name`` or raise ``ValueError``."""
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return _OP_SPECS[name]
|
|
170
|
+
except KeyError:
|
|
171
|
+
pass
|
|
172
|
+
raise ValueError(f'unsupported lattice IR op: {name!r}.')
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def validate_node_against_spec(node: IRNode) -> None:
|
|
176
|
+
"""Validate node ports/parameters against the registered op spec."""
|
|
177
|
+
|
|
178
|
+
spec = op_spec(node.op)
|
|
179
|
+
_require_keys(node.inputs, spec.inputs, f'{node.id}.inputs')
|
|
180
|
+
_require_keys(node.outputs, spec.outputs, f'{node.id}.outputs')
|
|
181
|
+
allowed = spec.parameters | spec.optional_parameters
|
|
182
|
+
missing = spec.parameters - set(node.parameters)
|
|
183
|
+
extra = set(node.parameters) - allowed
|
|
184
|
+
if missing:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f'{node.id}.parameters missing required keys: '
|
|
187
|
+
f'{sorted(missing)}.'
|
|
188
|
+
)
|
|
189
|
+
if extra:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f'{node.id}.parameters has unsupported keys: {sorted(extra)}.'
|
|
192
|
+
)
|
|
193
|
+
if spec.requires_support and node.support is None:
|
|
194
|
+
raise ValueError(f'{node.id} requires a support object.')
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _require_keys(
|
|
198
|
+
values: Mapping[str, IRInputRef],
|
|
199
|
+
expected: frozenset[str],
|
|
200
|
+
path: str,
|
|
201
|
+
) -> None:
|
|
202
|
+
actual = set(values)
|
|
203
|
+
missing = expected - actual
|
|
204
|
+
extra = actual - expected
|
|
205
|
+
if missing:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
f'{path} missing required keys: {sorted(missing)}.'
|
|
208
|
+
)
|
|
209
|
+
if extra:
|
|
210
|
+
raise ValueError(f'{path} has unsupported keys: {sorted(extra)}.')
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lattice-contract"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Backend-neutral sparse lattice artifact contract"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Z.Y. Lin", email = "me@iki.moe" }]
|
|
9
|
+
keywords = ["sparse", "lattice", "ir", "model-artifact"]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling>=1.27"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
dev = ["pytest>=8", "ruff>=0.15.15", "ty>=0.0.42"]
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["lattice_contract"]
|
|
20
|
+
|
|
21
|
+
[tool.ruff]
|
|
22
|
+
line-length = 76
|
|
23
|
+
target-version = "py312"
|
|
24
|
+
|
|
25
|
+
[tool.ruff.lint]
|
|
26
|
+
select = ["E4", "E7", "E9", "F", "I", "B", "UP", "SIM", "RUF"]
|
|
27
|
+
fixable = ["ALL"]
|
|
28
|
+
|
|
29
|
+
[tool.ruff.format]
|
|
30
|
+
quote-style = "single"
|
|
31
|
+
indent-style = "space"
|
|
32
|
+
line-ending = "lf"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from lattice_contract import (
|
|
6
|
+
ir_value_type,
|
|
7
|
+
is_ir_value_type,
|
|
8
|
+
manifest_from_dict,
|
|
9
|
+
manifest_to_dict,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _manifest() -> dict:
|
|
14
|
+
return {
|
|
15
|
+
'schema_version': '0.1',
|
|
16
|
+
'producer': {'name': 'test'},
|
|
17
|
+
'runtime': {'name': 'mlx-lattice', 'version': '>=0.2,<0.3'},
|
|
18
|
+
'coordinate_order': ['batch', 'x', 'y', 'z'],
|
|
19
|
+
'feature_layout': ['row', 'channel'],
|
|
20
|
+
'weight_layout': 'mlx-lattice',
|
|
21
|
+
'dtype_policy': 'preserve',
|
|
22
|
+
'inputs': [{'name': 'input', 'type': 'sparse_tensor'}],
|
|
23
|
+
'outputs': [{'name': 'output', 'type': 'sparse_tensor'}],
|
|
24
|
+
'nodes': [
|
|
25
|
+
{
|
|
26
|
+
'id': 'relu',
|
|
27
|
+
'op': 'feature.relu',
|
|
28
|
+
'inputs': {'input': 'input'},
|
|
29
|
+
'outputs': {'output': 'output'},
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_manifest_roundtrip_preserves_semantic_contract() -> None:
|
|
36
|
+
manifest = manifest_from_dict(_manifest())
|
|
37
|
+
|
|
38
|
+
raw = manifest_to_dict(manifest)
|
|
39
|
+
|
|
40
|
+
assert raw['schema_version'] == '0.1'
|
|
41
|
+
assert raw['coordinate_order'] == ['batch', 'x', 'y', 'z']
|
|
42
|
+
assert raw['feature_layout'] == ['row', 'channel']
|
|
43
|
+
assert raw['weight_layout'] == 'mlx-lattice'
|
|
44
|
+
assert raw['nodes'][0]['op'] == 'feature.relu'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_manifest_rejects_unknown_schema_version() -> None:
|
|
48
|
+
raw = _manifest()
|
|
49
|
+
raw['schema_version'] = '9.9'
|
|
50
|
+
|
|
51
|
+
with pytest.raises(ValueError, match='unsupported lattice IR'):
|
|
52
|
+
manifest_from_dict(raw)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_ir_value_type_helper_validates_schema_values() -> None:
|
|
56
|
+
assert is_ir_value_type('sparse_tensor')
|
|
57
|
+
assert ir_value_type('dense_tensor') == 'dense_tensor'
|
|
58
|
+
assert not is_ir_value_type('not_a_value_type')
|
|
59
|
+
|
|
60
|
+
with pytest.raises(ValueError, match='unsupported IR value type'):
|
|
61
|
+
ir_value_type('not_a_value_type')
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_manifest_rejects_unknown_graph_input_reference() -> None:
|
|
65
|
+
raw = _manifest()
|
|
66
|
+
raw['nodes'][0]['inputs']['input'] = 'missing'
|
|
67
|
+
|
|
68
|
+
with pytest.raises(ValueError, match='unknown input'):
|
|
69
|
+
manifest_from_dict(raw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_manifest_rejects_duplicate_graph_values() -> None:
|
|
73
|
+
raw = _manifest()
|
|
74
|
+
raw['nodes'][0]['outputs']['output'] = 'input'
|
|
75
|
+
|
|
76
|
+
with pytest.raises(ValueError, match='duplicate graph value'):
|
|
77
|
+
manifest_from_dict(raw)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_lattice_contract_imports_without_mlx_lattice_runtime() -> None:
|
|
8
|
+
sys.modules.pop('lattice_contract', None)
|
|
9
|
+
sys.modules.pop('lattice_contract.manifest', None)
|
|
10
|
+
sys.modules.pop('lattice_contract.ops', None)
|
|
11
|
+
sys.modules.pop('mlx_lattice', None)
|
|
12
|
+
|
|
13
|
+
contract = importlib.import_module('lattice_contract')
|
|
14
|
+
|
|
15
|
+
assert contract.__version__ == '0.1.0'
|
|
16
|
+
assert contract.CURRENT_SCHEMA_VERSION == '0.1'
|
|
17
|
+
assert 'mlx_lattice' not in sys.modules
|