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.
Files changed (60) hide show
  1. orbitquant/__init__.py +30 -0
  2. orbitquant/adaln.py +174 -0
  3. orbitquant/artifacts/__init__.py +32 -0
  4. orbitquant/artifacts/assets.py +59 -0
  5. orbitquant/artifacts/benchmark.py +133 -0
  6. orbitquant/artifacts/checksums.py +132 -0
  7. orbitquant/artifacts/comparisons.py +140 -0
  8. orbitquant/artifacts/loader.py +109 -0
  9. orbitquant/artifacts/manifest.py +138 -0
  10. orbitquant/artifacts/model_card.py +389 -0
  11. orbitquant/artifacts/refresh.py +65 -0
  12. orbitquant/artifacts/repair.py +130 -0
  13. orbitquant/artifacts/validator.py +449 -0
  14. orbitquant/artifacts/writer.py +229 -0
  15. orbitquant/benchmarks.py +494 -0
  16. orbitquant/cli/__init__.py +1 -0
  17. orbitquant/cli/main.py +1867 -0
  18. orbitquant/codebooks/__init__.py +13 -0
  19. orbitquant/codebooks/lloyd_max.py +260 -0
  20. orbitquant/config.py +148 -0
  21. orbitquant/errors.py +6 -0
  22. orbitquant/eval/__init__.py +65 -0
  23. orbitquant/eval/assets.py +88 -0
  24. orbitquant/eval/external_export.py +326 -0
  25. orbitquant/eval/external_metrics.py +190 -0
  26. orbitquant/eval/external_plan.py +383 -0
  27. orbitquant/eval/metrics.py +44 -0
  28. orbitquant/eval/native_plan.py +362 -0
  29. orbitquant/eval/native_runner.py +483 -0
  30. orbitquant/eval/native_settings.py +87 -0
  31. orbitquant/eval/prompts.py +291 -0
  32. orbitquant/eval/report.py +670 -0
  33. orbitquant/functional.py +22 -0
  34. orbitquant/hub.py +2501 -0
  35. orbitquant/kernels/__init__.py +13 -0
  36. orbitquant/kernels/dispatch.py +308 -0
  37. orbitquant/kernels/mps.py +147 -0
  38. orbitquant/kernels/native_packed_matmul.py +125 -0
  39. orbitquant/kernels/triton_cuda.py +1316 -0
  40. orbitquant/layers.py +567 -0
  41. orbitquant/modeling.py +443 -0
  42. orbitquant/packing/__init__.py +3 -0
  43. orbitquant/packing/bitpack.py +72 -0
  44. orbitquant/pipeline.py +236 -0
  45. orbitquant/policies/__init__.py +7 -0
  46. orbitquant/policies/flux.py +5 -0
  47. orbitquant/policies/flux2.py +5 -0
  48. orbitquant/policies/generic_dit.py +255 -0
  49. orbitquant/policies/wan.py +5 -0
  50. orbitquant/policies/z_image.py +5 -0
  51. orbitquant/quantizer.py +390 -0
  52. orbitquant/rotations/__init__.py +3 -0
  53. orbitquant/rotations/fwht.py +26 -0
  54. orbitquant/rotations/rpbh.py +119 -0
  55. orbitquant/transformers_ops.py +95 -0
  56. orbitquant-0.1.0.dist-info/METADATA +519 -0
  57. orbitquant-0.1.0.dist-info/RECORD +60 -0
  58. orbitquant-0.1.0.dist-info/WHEEL +4 -0
  59. orbitquant-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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