orbitquant 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.
- orbitquant/__init__.py +30 -0
- orbitquant/adaln.py +174 -0
- orbitquant/artifacts/__init__.py +32 -0
- orbitquant/artifacts/assets.py +59 -0
- orbitquant/artifacts/benchmark.py +133 -0
- orbitquant/artifacts/checksums.py +132 -0
- orbitquant/artifacts/comparisons.py +140 -0
- orbitquant/artifacts/loader.py +109 -0
- orbitquant/artifacts/manifest.py +138 -0
- orbitquant/artifacts/model_card.py +389 -0
- orbitquant/artifacts/refresh.py +65 -0
- orbitquant/artifacts/repair.py +130 -0
- orbitquant/artifacts/validator.py +449 -0
- orbitquant/artifacts/writer.py +229 -0
- orbitquant/benchmarks.py +494 -0
- orbitquant/cli/__init__.py +1 -0
- orbitquant/cli/main.py +1867 -0
- orbitquant/codebooks/__init__.py +13 -0
- orbitquant/codebooks/lloyd_max.py +260 -0
- orbitquant/config.py +148 -0
- orbitquant/errors.py +6 -0
- orbitquant/eval/__init__.py +65 -0
- orbitquant/eval/assets.py +88 -0
- orbitquant/eval/external_export.py +326 -0
- orbitquant/eval/external_metrics.py +190 -0
- orbitquant/eval/external_plan.py +383 -0
- orbitquant/eval/metrics.py +44 -0
- orbitquant/eval/native_plan.py +362 -0
- orbitquant/eval/native_runner.py +483 -0
- orbitquant/eval/native_settings.py +87 -0
- orbitquant/eval/prompts.py +291 -0
- orbitquant/eval/report.py +670 -0
- orbitquant/functional.py +22 -0
- orbitquant/hub.py +2501 -0
- orbitquant/kernels/__init__.py +13 -0
- orbitquant/kernels/dispatch.py +308 -0
- orbitquant/kernels/mps.py +147 -0
- orbitquant/kernels/native_packed_matmul.py +125 -0
- orbitquant/kernels/triton_cuda.py +1316 -0
- orbitquant/layers.py +567 -0
- orbitquant/modeling.py +443 -0
- orbitquant/packing/__init__.py +3 -0
- orbitquant/packing/bitpack.py +72 -0
- orbitquant/pipeline.py +236 -0
- orbitquant/policies/__init__.py +7 -0
- orbitquant/policies/flux.py +5 -0
- orbitquant/policies/flux2.py +5 -0
- orbitquant/policies/generic_dit.py +255 -0
- orbitquant/policies/wan.py +5 -0
- orbitquant/policies/z_image.py +5 -0
- orbitquant/quantizer.py +390 -0
- orbitquant/rotations/__init__.py +3 -0
- orbitquant/rotations/fwht.py +26 -0
- orbitquant/rotations/rpbh.py +119 -0
- orbitquant/transformers_ops.py +95 -0
- orbitquant-0.1.0.dist-info/METADATA +519 -0
- orbitquant-0.1.0.dist-info/RECORD +60 -0
- orbitquant-0.1.0.dist-info/WHEEL +4 -0
- orbitquant-0.1.0.dist-info/entry_points.txt +2 -0
- orbitquant-0.1.0.dist-info/licenses/LICENSE +201 -0
orbitquant/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""OrbitQuant clean-room implementation for diffusion transformer quantization."""
|
|
2
|
+
|
|
3
|
+
from orbitquant.config import OrbitQuantConfig
|
|
4
|
+
from orbitquant.layers import OrbitQuantLinear
|
|
5
|
+
from orbitquant.modeling import prewarm_quantized_linear_modules
|
|
6
|
+
from orbitquant.pipeline import (
|
|
7
|
+
build_diffusers_pipeline_quantization_config,
|
|
8
|
+
load_quantized_pipeline_component,
|
|
9
|
+
load_quantized_pipeline_from_artifact,
|
|
10
|
+
quantize_pipeline,
|
|
11
|
+
save_quantized_pipeline_component,
|
|
12
|
+
)
|
|
13
|
+
from orbitquant.quantizer import register_hf_quantizers
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
register_hf_quantizers()
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"OrbitQuantConfig",
|
|
21
|
+
"OrbitQuantLinear",
|
|
22
|
+
"__version__",
|
|
23
|
+
"build_diffusers_pipeline_quantization_config",
|
|
24
|
+
"load_quantized_pipeline_from_artifact",
|
|
25
|
+
"load_quantized_pipeline_component",
|
|
26
|
+
"prewarm_quantized_linear_modules",
|
|
27
|
+
"quantize_pipeline",
|
|
28
|
+
"register_hf_quantizers",
|
|
29
|
+
"save_quantized_pipeline_component",
|
|
30
|
+
]
|
orbitquant/adaln.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import torch
|
|
4
|
+
from torch import nn
|
|
5
|
+
from torch.nn import functional as F
|
|
6
|
+
|
|
7
|
+
from orbitquant.config import OrbitQuantConfig
|
|
8
|
+
from orbitquant.packing import pack_lowbit, unpack_lowbit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _packed_length(value_count: int, bits: int) -> int:
|
|
12
|
+
return (value_count * bits + 7) // 8
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _quantize_adaln_weight_reference(
|
|
16
|
+
weight: torch.Tensor,
|
|
17
|
+
*,
|
|
18
|
+
group_size: int,
|
|
19
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
20
|
+
num_groups = (weight.shape[1] + group_size - 1) // group_size
|
|
21
|
+
padded = torch.zeros(
|
|
22
|
+
weight.shape[0],
|
|
23
|
+
num_groups * group_size,
|
|
24
|
+
dtype=torch.float32,
|
|
25
|
+
device=weight.device,
|
|
26
|
+
)
|
|
27
|
+
padded[:, : weight.shape[1]] = weight.to(torch.float32)
|
|
28
|
+
grouped = padded.reshape(weight.shape[0], num_groups, group_size)
|
|
29
|
+
scales = grouped.abs().amax(dim=-1).clamp_min(1e-12) / 7.0
|
|
30
|
+
quantized_signed = torch.round(grouped / scales[..., None]).clamp(-8, 7).to(torch.int16)
|
|
31
|
+
quantized_unsigned = (quantized_signed + 8).to(torch.uint8)
|
|
32
|
+
packed = pack_lowbit(quantized_unsigned.flatten(), bits=4, validate=False)
|
|
33
|
+
return packed, scales
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _quantize_adaln_weight(
|
|
37
|
+
weight: torch.Tensor,
|
|
38
|
+
*,
|
|
39
|
+
group_size: int,
|
|
40
|
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
|
41
|
+
if weight.is_cuda:
|
|
42
|
+
try:
|
|
43
|
+
from orbitquant.kernels.triton_cuda import quantize_adaln_weight_with_triton
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
else:
|
|
47
|
+
return quantize_adaln_weight_with_triton(weight, group_size=group_size)
|
|
48
|
+
return _quantize_adaln_weight_reference(weight, group_size=group_size)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RTNInt4Linear(nn.Module):
|
|
52
|
+
"""Symmetric INT4 round-to-nearest linear used for AdaLN modulation weights."""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
*,
|
|
57
|
+
in_features: int,
|
|
58
|
+
out_features: int,
|
|
59
|
+
group_size: int,
|
|
60
|
+
module_name: str,
|
|
61
|
+
packed_weight: torch.Tensor,
|
|
62
|
+
scales: torch.Tensor,
|
|
63
|
+
bias: torch.Tensor | None,
|
|
64
|
+
) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.in_features = in_features
|
|
67
|
+
self.out_features = out_features
|
|
68
|
+
self.group_size = group_size
|
|
69
|
+
self.module_name = module_name
|
|
70
|
+
self.num_groups = (in_features + group_size - 1) // group_size
|
|
71
|
+
self.register_buffer("packed_weight", packed_weight)
|
|
72
|
+
self.register_buffer("scales", scales)
|
|
73
|
+
if bias is None:
|
|
74
|
+
self.register_parameter("bias", None)
|
|
75
|
+
else:
|
|
76
|
+
self.bias = nn.Parameter(bias.detach().clone(), requires_grad=False)
|
|
77
|
+
self._dequantized_weight_cache: torch.Tensor | None = None
|
|
78
|
+
self._dequantized_weight_cache_key: tuple[str, torch.dtype] | None = None
|
|
79
|
+
|
|
80
|
+
def clear_dequantized_cache(self) -> None:
|
|
81
|
+
self._dequantized_weight_cache = None
|
|
82
|
+
self._dequantized_weight_cache_key = None
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_linear(
|
|
86
|
+
cls, layer: nn.Linear, *, config: OrbitQuantConfig, module_name: str
|
|
87
|
+
) -> RTNInt4Linear:
|
|
88
|
+
group_size = config.adaln_group_size
|
|
89
|
+
weight = layer.weight.detach().to(torch.float32)
|
|
90
|
+
packed, scales = _quantize_adaln_weight(weight, group_size=group_size)
|
|
91
|
+
bias = None if layer.bias is None else layer.bias.detach()
|
|
92
|
+
return cls(
|
|
93
|
+
in_features=layer.in_features,
|
|
94
|
+
out_features=layer.out_features,
|
|
95
|
+
group_size=group_size,
|
|
96
|
+
module_name=module_name,
|
|
97
|
+
packed_weight=packed,
|
|
98
|
+
scales=scales.to(torch.bfloat16),
|
|
99
|
+
bias=bias,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def empty_from_linear(
|
|
104
|
+
cls, layer: nn.Linear, *, config: OrbitQuantConfig, module_name: str
|
|
105
|
+
) -> RTNInt4Linear:
|
|
106
|
+
group_size = config.adaln_group_size
|
|
107
|
+
num_groups = (layer.in_features + group_size - 1) // group_size
|
|
108
|
+
packed = torch.empty(
|
|
109
|
+
_packed_length(layer.out_features * num_groups * group_size, 4),
|
|
110
|
+
dtype=torch.uint8,
|
|
111
|
+
device=layer.weight.device,
|
|
112
|
+
)
|
|
113
|
+
scales = torch.empty(
|
|
114
|
+
layer.out_features, num_groups, dtype=torch.bfloat16, device=layer.weight.device
|
|
115
|
+
)
|
|
116
|
+
bias = None
|
|
117
|
+
if layer.bias is not None:
|
|
118
|
+
bias = torch.zeros(layer.out_features, dtype=layer.bias.dtype, device=layer.bias.device)
|
|
119
|
+
return cls(
|
|
120
|
+
in_features=layer.in_features,
|
|
121
|
+
out_features=layer.out_features,
|
|
122
|
+
group_size=group_size,
|
|
123
|
+
module_name=module_name,
|
|
124
|
+
packed_weight=packed,
|
|
125
|
+
scales=scales,
|
|
126
|
+
bias=bias,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def _dequantize_weight(self, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
|
|
130
|
+
cache_key = (str(device), dtype)
|
|
131
|
+
if (
|
|
132
|
+
self._dequantized_weight_cache is not None
|
|
133
|
+
and self._dequantized_weight_cache_key == cache_key
|
|
134
|
+
):
|
|
135
|
+
return self._dequantized_weight_cache
|
|
136
|
+
|
|
137
|
+
if device.type == "cuda":
|
|
138
|
+
try:
|
|
139
|
+
from orbitquant.kernels.triton_cuda import dequantize_adaln_weight_with_triton
|
|
140
|
+
except Exception:
|
|
141
|
+
dequantize_adaln_weight_with_triton = None
|
|
142
|
+
if dequantize_adaln_weight_with_triton is not None:
|
|
143
|
+
weight = dequantize_adaln_weight_with_triton(
|
|
144
|
+
self.packed_weight,
|
|
145
|
+
self.scales,
|
|
146
|
+
out_features=self.out_features,
|
|
147
|
+
in_features=self.in_features,
|
|
148
|
+
group_size=self.group_size,
|
|
149
|
+
device=device,
|
|
150
|
+
)
|
|
151
|
+
dequantized = weight.to(dtype=dtype)
|
|
152
|
+
self._dequantized_weight_cache = dequantized.detach()
|
|
153
|
+
self._dequantized_weight_cache_key = cache_key
|
|
154
|
+
return dequantized
|
|
155
|
+
|
|
156
|
+
total = self.out_features * self.num_groups * self.group_size
|
|
157
|
+
unsigned = unpack_lowbit(self.packed_weight, bits=4, length=total).to(device=device)
|
|
158
|
+
signed = unsigned.to(torch.int16).sub(8).to(torch.float32)
|
|
159
|
+
grouped = signed.reshape(self.out_features, self.num_groups, self.group_size)
|
|
160
|
+
scales = self.scales.to(device=device, dtype=torch.float32)
|
|
161
|
+
weight = (grouped * scales[..., None]).reshape(
|
|
162
|
+
self.out_features, self.num_groups * self.group_size
|
|
163
|
+
)
|
|
164
|
+
weight = weight[:, : self.in_features]
|
|
165
|
+
dequantized = weight.to(device=device, dtype=dtype)
|
|
166
|
+
self._dequantized_weight_cache = dequantized.detach()
|
|
167
|
+
self._dequantized_weight_cache_key = cache_key
|
|
168
|
+
return dequantized
|
|
169
|
+
|
|
170
|
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
|
171
|
+
activation = x.to(torch.bfloat16)
|
|
172
|
+
weight = self._dequantize_weight(device=x.device, dtype=torch.bfloat16)
|
|
173
|
+
bias = None if self.bias is None else self.bias.to(device=x.device, dtype=torch.bfloat16)
|
|
174
|
+
return F.linear(activation, weight, bias)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from orbitquant.artifacts.assets import record_artifact_asset
|
|
2
|
+
from orbitquant.artifacts.benchmark import record_artifact_metrics
|
|
3
|
+
from orbitquant.artifacts.checksums import sha256_file, write_sha256sums
|
|
4
|
+
from orbitquant.artifacts.comparisons import create_artifact_image_comparisons
|
|
5
|
+
from orbitquant.artifacts.loader import load_orbitquant_artifact
|
|
6
|
+
from orbitquant.artifacts.manifest import OrbitQuantManifest
|
|
7
|
+
from orbitquant.artifacts.model_card import render_model_card
|
|
8
|
+
from orbitquant.artifacts.refresh import refresh_artifact_checksums
|
|
9
|
+
from orbitquant.artifacts.repair import repair_artifact_metadata
|
|
10
|
+
from orbitquant.artifacts.validator import (
|
|
11
|
+
validate_artifact_policy_inventory,
|
|
12
|
+
validate_orbitquant_artifact,
|
|
13
|
+
validate_policy_inventory_payloads,
|
|
14
|
+
)
|
|
15
|
+
from orbitquant.artifacts.writer import save_orbitquant_artifact
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"OrbitQuantManifest",
|
|
19
|
+
"create_artifact_image_comparisons",
|
|
20
|
+
"load_orbitquant_artifact",
|
|
21
|
+
"record_artifact_asset",
|
|
22
|
+
"record_artifact_metrics",
|
|
23
|
+
"refresh_artifact_checksums",
|
|
24
|
+
"render_model_card",
|
|
25
|
+
"repair_artifact_metadata",
|
|
26
|
+
"save_orbitquant_artifact",
|
|
27
|
+
"validate_artifact_policy_inventory",
|
|
28
|
+
"validate_orbitquant_artifact",
|
|
29
|
+
"validate_policy_inventory_payloads",
|
|
30
|
+
"sha256_file",
|
|
31
|
+
"write_sha256sums",
|
|
32
|
+
]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from orbitquant.artifacts.checksums import (
|
|
7
|
+
sha256_file,
|
|
8
|
+
validate_checksums,
|
|
9
|
+
write_sha256sums_from_manifest,
|
|
10
|
+
)
|
|
11
|
+
from orbitquant.artifacts.manifest import OrbitQuantManifest
|
|
12
|
+
from orbitquant.artifacts.validator import validate_required_artifact_files
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _read_manifest(artifact_path: Path) -> OrbitQuantManifest:
|
|
16
|
+
return OrbitQuantManifest.from_dict(
|
|
17
|
+
json.loads((artifact_path / "orbitquant_manifest.json").read_text(encoding="utf-8"))
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _relative_asset_path(artifact_path: Path, asset_path: Path) -> str:
|
|
22
|
+
try:
|
|
23
|
+
relative = asset_path.resolve().relative_to(artifact_path.resolve())
|
|
24
|
+
except ValueError as exc:
|
|
25
|
+
raise ValueError("artifact asset must be inside the artifact directory") from exc
|
|
26
|
+
if relative.parts[0] != "assets":
|
|
27
|
+
raise ValueError("artifact asset must live under assets/")
|
|
28
|
+
return relative.as_posix()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def record_artifact_asset(
|
|
32
|
+
artifact_dir: str | Path,
|
|
33
|
+
asset_path: str | Path,
|
|
34
|
+
*,
|
|
35
|
+
validate_checksums_enabled: bool = True,
|
|
36
|
+
refresh_checksums_enabled: bool = True,
|
|
37
|
+
) -> str:
|
|
38
|
+
artifact_path = Path(artifact_dir)
|
|
39
|
+
asset = Path(asset_path)
|
|
40
|
+
if not asset.is_file():
|
|
41
|
+
raise RuntimeError(f"artifact asset missing: {asset}")
|
|
42
|
+
|
|
43
|
+
validate_required_artifact_files(artifact_path)
|
|
44
|
+
manifest = _read_manifest(artifact_path)
|
|
45
|
+
if validate_checksums_enabled:
|
|
46
|
+
validate_checksums(artifact_path, manifest.checksums)
|
|
47
|
+
|
|
48
|
+
relative_path = _relative_asset_path(artifact_path, asset)
|
|
49
|
+
if not refresh_checksums_enabled:
|
|
50
|
+
return relative_path
|
|
51
|
+
checksums = dict(manifest.checksums)
|
|
52
|
+
checksums[relative_path] = sha256_file(asset)
|
|
53
|
+
payload = manifest.to_dict()
|
|
54
|
+
payload["checksums"] = checksums
|
|
55
|
+
(artifact_path / "orbitquant_manifest.json").write_text(
|
|
56
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
57
|
+
)
|
|
58
|
+
write_sha256sums_from_manifest(artifact_path, checksums)
|
|
59
|
+
return relative_path
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from orbitquant.artifacts.checksums import (
|
|
9
|
+
sha256_file,
|
|
10
|
+
validate_checksums,
|
|
11
|
+
write_sha256sums_from_manifest,
|
|
12
|
+
)
|
|
13
|
+
from orbitquant.artifacts.manifest import OrbitQuantManifest
|
|
14
|
+
from orbitquant.artifacts.validator import validate_required_artifact_files
|
|
15
|
+
|
|
16
|
+
_VALID_METRIC_SPLITS = {"original", "orbitquant"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_json_value(value: Any) -> Any:
|
|
20
|
+
if isinstance(value, Path):
|
|
21
|
+
return str(value)
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {str(key): _normalize_json_value(item) for key, item in value.items()}
|
|
24
|
+
if isinstance(value, list | tuple):
|
|
25
|
+
return [_normalize_json_value(item) for item in value]
|
|
26
|
+
item = getattr(value, "item", None)
|
|
27
|
+
if callable(item):
|
|
28
|
+
try:
|
|
29
|
+
return item()
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _csv_value(value: Any) -> Any:
|
|
36
|
+
normalized = _normalize_json_value(value)
|
|
37
|
+
if isinstance(normalized, dict | list):
|
|
38
|
+
return json.dumps(normalized, sort_keys=True, separators=(",", ":"))
|
|
39
|
+
return normalized
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_manifest(artifact_path: Path) -> OrbitQuantManifest:
|
|
43
|
+
return OrbitQuantManifest.from_dict(
|
|
44
|
+
json.loads((artifact_path / "orbitquant_manifest.json").read_text(encoding="utf-8"))
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_summary(summary_path: Path) -> dict[str, Any]:
|
|
49
|
+
if not summary_path.is_file():
|
|
50
|
+
return {}
|
|
51
|
+
return json.loads(summary_path.read_text(encoding="utf-8"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _record_count(jsonl_path: Path) -> int:
|
|
55
|
+
return sum(1 for line in jsonl_path.read_text(encoding="utf-8").splitlines() if line.strip())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_manifest_with_refreshed_checksums(
|
|
59
|
+
artifact_path: Path, manifest: OrbitQuantManifest, relative_paths: tuple[str, ...]
|
|
60
|
+
) -> dict[str, str]:
|
|
61
|
+
checksums = dict(manifest.checksums)
|
|
62
|
+
for relative_path in relative_paths:
|
|
63
|
+
checksums[relative_path] = sha256_file(artifact_path / relative_path)
|
|
64
|
+
payload = manifest.to_dict()
|
|
65
|
+
payload["checksums"] = checksums
|
|
66
|
+
(artifact_path / "orbitquant_manifest.json").write_text(
|
|
67
|
+
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
68
|
+
)
|
|
69
|
+
return checksums
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def record_artifact_metrics(
|
|
73
|
+
artifact_dir: str | Path,
|
|
74
|
+
*,
|
|
75
|
+
split: str,
|
|
76
|
+
metrics: dict[str, Any],
|
|
77
|
+
metadata: dict[str, Any] | None = None,
|
|
78
|
+
validate_checksums_enabled: bool = True,
|
|
79
|
+
refresh_checksums_enabled: bool = True,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
normalized_split = split.lower()
|
|
82
|
+
if normalized_split not in _VALID_METRIC_SPLITS:
|
|
83
|
+
raise ValueError("split must be one of: original, orbitquant")
|
|
84
|
+
if not metrics:
|
|
85
|
+
raise ValueError("metrics must not be empty")
|
|
86
|
+
|
|
87
|
+
artifact_path = Path(artifact_dir)
|
|
88
|
+
validate_required_artifact_files(artifact_path)
|
|
89
|
+
manifest = _read_manifest(artifact_path)
|
|
90
|
+
if validate_checksums_enabled:
|
|
91
|
+
validate_checksums(artifact_path, manifest.checksums)
|
|
92
|
+
|
|
93
|
+
record = {
|
|
94
|
+
"split": normalized_split,
|
|
95
|
+
"metrics": _normalize_json_value(metrics),
|
|
96
|
+
"metadata": {} if metadata is None else _normalize_json_value(metadata),
|
|
97
|
+
}
|
|
98
|
+
summary_path = artifact_path / "benchmark" / "summary.json"
|
|
99
|
+
jsonl_path = artifact_path / "benchmark" / f"{normalized_split}.metrics.jsonl"
|
|
100
|
+
csv_path = artifact_path / "benchmark" / f"{normalized_split}.metrics.csv"
|
|
101
|
+
|
|
102
|
+
with jsonl_path.open("a", encoding="utf-8") as handle:
|
|
103
|
+
handle.write(json.dumps(record, separators=(",", ":")) + "\n")
|
|
104
|
+
|
|
105
|
+
write_csv_header = not csv_path.exists()
|
|
106
|
+
with csv_path.open("a", encoding="utf-8", newline="") as handle:
|
|
107
|
+
writer = csv.writer(handle)
|
|
108
|
+
if write_csv_header:
|
|
109
|
+
writer.writerow(["metric", "value"])
|
|
110
|
+
for metric, value in record["metrics"].items():
|
|
111
|
+
writer.writerow([metric, _csv_value(value)])
|
|
112
|
+
|
|
113
|
+
summary = _read_summary(summary_path)
|
|
114
|
+
summary["status"] = "metrics_recorded"
|
|
115
|
+
split_summaries = summary.setdefault("metrics", {})
|
|
116
|
+
split_summaries[normalized_split] = {
|
|
117
|
+
"records": _record_count(jsonl_path),
|
|
118
|
+
"latest": record,
|
|
119
|
+
}
|
|
120
|
+
summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
changed_paths = (
|
|
123
|
+
"benchmark/summary.json",
|
|
124
|
+
f"benchmark/{normalized_split}.metrics.jsonl",
|
|
125
|
+
f"benchmark/{normalized_split}.metrics.csv",
|
|
126
|
+
)
|
|
127
|
+
if not refresh_checksums_enabled:
|
|
128
|
+
return record
|
|
129
|
+
checksums = _write_manifest_with_refreshed_checksums(
|
|
130
|
+
artifact_path, manifest, changed_paths
|
|
131
|
+
)
|
|
132
|
+
write_sha256sums_from_manifest(artifact_path, checksums)
|
|
133
|
+
return record
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_IGNORED_ARTIFACT_PATH_PARTS = {
|
|
7
|
+
".cache",
|
|
8
|
+
".gitattributes",
|
|
9
|
+
".gitignore",
|
|
10
|
+
".pytest_cache",
|
|
11
|
+
".ruff_cache",
|
|
12
|
+
"__pycache__",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_ignored_artifact_relative_path(relative_path: str | Path) -> bool:
|
|
17
|
+
return any(part in _IGNORED_ARTIFACT_PATH_PARTS for part in Path(relative_path).parts)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sha256_file(path: str | Path) -> str:
|
|
21
|
+
digest = hashlib.sha256()
|
|
22
|
+
with Path(path).open("rb") as handle:
|
|
23
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
24
|
+
digest.update(chunk)
|
|
25
|
+
return digest.hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def write_sha256sums(root: str | Path, output: str | Path | None = None) -> Path:
|
|
29
|
+
root_path = Path(root)
|
|
30
|
+
output_path = root_path / "SHA256SUMS" if output is None else Path(output)
|
|
31
|
+
lines: list[str] = []
|
|
32
|
+
for path in sorted(root_path.rglob("*")):
|
|
33
|
+
if not path.is_file() or path == output_path:
|
|
34
|
+
continue
|
|
35
|
+
rel = path.relative_to(root_path)
|
|
36
|
+
if is_ignored_artifact_relative_path(rel):
|
|
37
|
+
continue
|
|
38
|
+
lines.append(f"{sha256_file(path)} {rel.as_posix()}")
|
|
39
|
+
output_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
|
40
|
+
return output_path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_sha256sums(path: str | Path) -> dict[str, str]:
|
|
44
|
+
path = Path(path)
|
|
45
|
+
if not path.is_file():
|
|
46
|
+
return {}
|
|
47
|
+
entries: dict[str, str] = {}
|
|
48
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
49
|
+
if not line.strip():
|
|
50
|
+
continue
|
|
51
|
+
digest, _, relative_path = line.partition(" ")
|
|
52
|
+
if digest and relative_path:
|
|
53
|
+
entries[relative_path] = digest
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_sha256sums_from_manifest(
|
|
58
|
+
root: str | Path,
|
|
59
|
+
checksums: dict[str, str],
|
|
60
|
+
output: str | Path | None = None,
|
|
61
|
+
) -> Path:
|
|
62
|
+
root_path = Path(root)
|
|
63
|
+
output_path = root_path / "SHA256SUMS" if output is None else Path(output)
|
|
64
|
+
entries = read_sha256sums(output_path)
|
|
65
|
+
entries.update(checksums)
|
|
66
|
+
|
|
67
|
+
manifest_path = root_path / "orbitquant_manifest.json"
|
|
68
|
+
if manifest_path.is_file():
|
|
69
|
+
entries["orbitquant_manifest.json"] = sha256_file(manifest_path)
|
|
70
|
+
readme_path = root_path / "README.md"
|
|
71
|
+
if readme_path.is_file():
|
|
72
|
+
entries["README.md"] = sha256_file(readme_path)
|
|
73
|
+
|
|
74
|
+
entries = {
|
|
75
|
+
relative_path: digest
|
|
76
|
+
for relative_path, digest in entries.items()
|
|
77
|
+
if relative_path != "SHA256SUMS"
|
|
78
|
+
and not is_ignored_artifact_relative_path(relative_path)
|
|
79
|
+
and (root_path / relative_path).is_file()
|
|
80
|
+
}
|
|
81
|
+
output_path.write_text(
|
|
82
|
+
"\n".join(f"{digest} {relative_path}" for relative_path, digest in sorted(entries.items()))
|
|
83
|
+
+ ("\n" if entries else ""),
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
)
|
|
86
|
+
return output_path
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_checksums(root: str | Path, checksums: dict[str, str]) -> None:
|
|
90
|
+
root_path = Path(root)
|
|
91
|
+
for relative_path, expected in checksums.items():
|
|
92
|
+
if is_ignored_artifact_relative_path(relative_path):
|
|
93
|
+
continue
|
|
94
|
+
path = root_path / relative_path
|
|
95
|
+
if not path.is_file():
|
|
96
|
+
raise RuntimeError(f"artifact checksum target missing: {relative_path}")
|
|
97
|
+
actual = sha256_file(path)
|
|
98
|
+
if actual != expected:
|
|
99
|
+
raise RuntimeError(
|
|
100
|
+
f"artifact checksum mismatch for {relative_path}: "
|
|
101
|
+
f"expected {expected}, got {actual}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_sha256sums(
|
|
106
|
+
root: str | Path,
|
|
107
|
+
*,
|
|
108
|
+
required_paths: tuple[str, ...] = (),
|
|
109
|
+
sha256sums_path: str | Path | None = None,
|
|
110
|
+
) -> dict[str, str]:
|
|
111
|
+
root_path = Path(root)
|
|
112
|
+
sums_path = root_path / "SHA256SUMS" if sha256sums_path is None else Path(sha256sums_path)
|
|
113
|
+
entries = read_sha256sums(sums_path)
|
|
114
|
+
if not entries:
|
|
115
|
+
raise RuntimeError("SHA256SUMS is empty or missing")
|
|
116
|
+
missing_entries = sorted(set(required_paths) - set(entries))
|
|
117
|
+
if missing_entries:
|
|
118
|
+
raise RuntimeError(f"SHA256SUMS missing entries: {missing_entries}")
|
|
119
|
+
for relative_path, expected in entries.items():
|
|
120
|
+
if is_ignored_artifact_relative_path(relative_path):
|
|
121
|
+
continue
|
|
122
|
+
if relative_path == "SHA256SUMS":
|
|
123
|
+
raise RuntimeError("SHA256SUMS must not include itself")
|
|
124
|
+
path = root_path / relative_path
|
|
125
|
+
if not path.is_file():
|
|
126
|
+
raise RuntimeError(f"SHA256SUMS target missing: {relative_path}")
|
|
127
|
+
actual = sha256_file(path)
|
|
128
|
+
if actual != expected:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
f"SHA256SUMS mismatch for {relative_path}: expected {expected}, got {actual}"
|
|
131
|
+
)
|
|
132
|
+
return entries
|