axquant 1.3.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 (119) hide show
  1. axquant/__init__.py +5 -0
  2. axquant/__main__.py +3 -0
  3. axquant/activation_cache.py +512 -0
  4. axquant/analyzer.py +141 -0
  5. axquant/architectures/__init__.py +4 -0
  6. axquant/architectures/dense_family.py +419 -0
  7. axquant/architectures/nemotron3.py +156 -0
  8. axquant/architectures/qwen36.py +141 -0
  9. axquant/architectures/registry.py +105 -0
  10. axquant/architectures/types.py +19 -0
  11. axquant/artifact_paths.py +67 -0
  12. axquant/awq.py +271 -0
  13. axquant/benchmark.py +1532 -0
  14. axquant/benchmark_evidence.py +347 -0
  15. axquant/calibration.py +92 -0
  16. axquant/calibration_dataset.py +107 -0
  17. axquant/campaign.py +955 -0
  18. axquant/capture.py +818 -0
  19. axquant/capture_binding.py +150 -0
  20. axquant/certification/__init__.py +18 -0
  21. axquant/certification/common.py +226 -0
  22. axquant/certification/dispatch.py +75 -0
  23. axquant/certification/flagship.py +852 -0
  24. axquant/certification/packaging.py +123 -0
  25. axquant/certification/policy.py +26 -0
  26. axquant/certification/qwen3_next_direct.py +1832 -0
  27. axquant/certification/registry.py +127 -0
  28. axquant/claims.py +311 -0
  29. axquant/cli/__init__.py +2349 -0
  30. axquant/cli/_parser.py +1354 -0
  31. axquant/coding_sandbox.py +992 -0
  32. axquant/coding_suite.py +919 -0
  33. axquant/compatibility.py +380 -0
  34. axquant/converter.py +1436 -0
  35. axquant/data/__init__.py +0 -0
  36. axquant/data/convert_ladders.yaml +85 -0
  37. axquant/data/messages.yaml +18 -0
  38. axquant/data/profiles.yaml +118 -0
  39. axquant/data/quantizer_defaults.yaml +13 -0
  40. axquant/data/reference_calibration.jsonl +160 -0
  41. axquant/data/role_preferences.yaml +26 -0
  42. axquant/dataset_overlap.py +194 -0
  43. axquant/deferred.py +68 -0
  44. axquant/direct_quality.py +185 -0
  45. axquant/dwq.py +43 -0
  46. axquant/errors.py +50 -0
  47. axquant/experimental_bits.py +93 -0
  48. axquant/feasibility.py +583 -0
  49. axquant/gptq.py +347 -0
  50. axquant/hardware_registry.py +842 -0
  51. axquant/head_to_head.py +228 -0
  52. axquant/identity.py +155 -0
  53. axquant/inspector.py +654 -0
  54. axquant/kernel_latency.py +214 -0
  55. axquant/kv_exec.py +139 -0
  56. axquant/kv_probe.py +417 -0
  57. axquant/kv_quality.py +60 -0
  58. axquant/ladders.py +180 -0
  59. axquant/lifecycle.py +102 -0
  60. axquant/logging.py +22 -0
  61. axquant/manual.py +344 -0
  62. axquant/model_card.py +808 -0
  63. axquant/module_paths.py +199 -0
  64. axquant/mtp_sidecar.py +841 -0
  65. axquant/multimodal_backend.py +181 -0
  66. axquant/naming.py +106 -0
  67. axquant/numeric.py +39 -0
  68. axquant/package_data.py +52 -0
  69. axquant/pareto.py +79 -0
  70. axquant/planner.py +920 -0
  71. axquant/predicate.py +247 -0
  72. axquant/probe.py +1282 -0
  73. axquant/probe_capacity.py +261 -0
  74. axquant/profiles.py +55 -0
  75. axquant/publisher.py +672 -0
  76. axquant/quality.py +462 -0
  77. axquant/quantize.py +297 -0
  78. axquant/quantizers.py +786 -0
  79. axquant/recipes.py +234 -0
  80. axquant/recovery.py +392 -0
  81. axquant/refinement.py +1354 -0
  82. axquant/refinement_runner.py +611 -0
  83. axquant/release_audit.py +2109 -0
  84. axquant/release_exceptions.py +244 -0
  85. axquant/release_validation.py +151 -0
  86. axquant/reporting.py +1018 -0
  87. axquant/reproduction.py +285 -0
  88. axquant/revisions.py +11 -0
  89. axquant/role_policy.py +163 -0
  90. axquant/runtime.py +803 -0
  91. axquant/schema/__init__.py +535 -0
  92. axquant/schema/_base.py +27 -0
  93. axquant/schema/artifacts.py +2054 -0
  94. axquant/schema/campaign.py +647 -0
  95. axquant/schema/certification.py +844 -0
  96. axquant/schema/claims.py +116 -0
  97. axquant/schema/coding_suite.py +184 -0
  98. axquant/schema/enums.py +167 -0
  99. axquant/schema/flagship.py +55 -0
  100. axquant/schema/flagship_audit.py +276 -0
  101. axquant/schema/inventory.py +104 -0
  102. axquant/schema/kernel_latency.py +117 -0
  103. axquant/schema/lifecycle.py +189 -0
  104. axquant/schema/planning.py +747 -0
  105. axquant/schema/sensitivity.py +303 -0
  106. axquant/scoreboard.py +525 -0
  107. axquant/serde.py +156 -0
  108. axquant/simple_convert.py +305 -0
  109. axquant/source_prep.py +491 -0
  110. axquant/suites.py +329 -0
  111. axquant/support_policy.py +252 -0
  112. axquant/unified_sensitivity.py +192 -0
  113. axquant/validator.py +696 -0
  114. axquant/versioning.py +70 -0
  115. axquant-1.3.0.dist-info/METADATA +1049 -0
  116. axquant-1.3.0.dist-info/RECORD +119 -0
  117. axquant-1.3.0.dist-info/WHEEL +4 -0
  118. axquant-1.3.0.dist-info/entry_points.txt +2 -0
  119. axquant-1.3.0.dist-info/licenses/LICENSE +21 -0
axquant/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from axquant.schema import QuantizationPlan, SensitivityReport
2
+
3
+ __all__ = ["QuantizationPlan", "SensitivityReport", "__version__"]
4
+
5
+ __version__ = "1.3.0"
axquant/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from axquant.cli import entrypoint
2
+
3
+ entrypoint()
@@ -0,0 +1,512 @@
1
+ """Tokenized calibration and activation cache.
2
+
3
+ Manages the calibration cache directory structure with content-addressed
4
+ shards, checksum verification, and atomic completion markers. Changed
5
+ inputs always create a new cache directory rather than overwriting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ import os
13
+ import random
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import structlog
19
+
20
+ from axquant.errors import ArtifactError, BackendUnavailableError, CacheError
21
+ from axquant.schema import (
22
+ ModelIdentity,
23
+ ProfileName,
24
+ TokenizedCacheManifest,
25
+ )
26
+ from axquant.serde import file_sha256, load_model, stable_sha256, write_data
27
+ from axquant.versioning import collect_versions
28
+
29
+ log = structlog.get_logger()
30
+
31
+ _SHARD_PREFIX = "shard-"
32
+ _SHARD_SUFFIX = ".npz"
33
+ _COMPLETION_MARKER = "completion.json"
34
+ _COMPLETION_SCHEMA = "axquant.tokenized-cache-completion.v1"
35
+ _MANIFEST_NAME = "tokenized_cache_manifest.json"
36
+ _BACKEND_VERSION = "axquant-tokenizer-v1"
37
+ _SAMPLES_PER_SHARD = 100
38
+
39
+
40
+ def compute_cache_key(
41
+ *,
42
+ model: ModelIdentity,
43
+ dataset_sha256: str,
44
+ profile: ProfileName,
45
+ sequence_length: int,
46
+ random_seed: int,
47
+ tokenizer_revision: str | None = None,
48
+ config_digest: str | None = None,
49
+ backend_version: str = _BACKEND_VERSION,
50
+ mlx_version: str | None = None,
51
+ mlx_lm_version: str | None = None,
52
+ capture_points: tuple[str, ...] = ("output", "hidden"),
53
+ separation_attested: bool = False,
54
+ domains: tuple[str, ...] = (),
55
+ ) -> str:
56
+ """Compute a deterministic cache key from all identity-defining fields.
57
+
58
+ The cache key includes: source revision, config digest, tokenizer
59
+ revision, dataset digest, profile, sequence length, seed, backend
60
+ version, MLX/MLX-LM versions, and capture-point definition.
61
+ """
62
+ identity = {
63
+ "model_id": model.model_id,
64
+ "revision": model.revision,
65
+ "config_digest": config_digest,
66
+ "tokenizer_revision": tokenizer_revision,
67
+ "dataset_sha256": dataset_sha256,
68
+ "profile": profile.value,
69
+ "sequence_length": sequence_length,
70
+ "random_seed": random_seed,
71
+ "backend_version": backend_version,
72
+ "mlx_version": mlx_version,
73
+ "mlx_lm_version": mlx_lm_version,
74
+ "capture_points": list(capture_points),
75
+ "calibration_evaluation_separation_attested": separation_attested,
76
+ "domains": list(domains),
77
+ }
78
+ return stable_sha256(identity)
79
+
80
+
81
+ def _shard_path(directory: Path, index: int) -> Path:
82
+ return directory / f"{_SHARD_PREFIX}{index:04d}{_SHARD_SUFFIX}"
83
+
84
+
85
+ def verify_cache_integrity(cache_dir: Path, manifest: TokenizedCacheManifest) -> list[str]:
86
+ """Verify all shards in the cache directory against the manifest.
87
+
88
+ Returns a list of issues (empty means all checks passed).
89
+ """
90
+ issues: list[str] = []
91
+ tokenized_dir = cache_dir / "tokenized"
92
+ if not tokenized_dir.is_dir():
93
+ issues.append("tokenized directory missing")
94
+ return issues
95
+
96
+ expected_shards = {
97
+ _shard_path(tokenized_dir, index).name for index in range(manifest.shard_count)
98
+ }
99
+ recorded_shards = set(manifest.shard_sha256)
100
+ for name in sorted(expected_shards - recorded_shards):
101
+ issues.append(f"missing checksum binding: {name}")
102
+ for name in sorted(recorded_shards - expected_shards):
103
+ issues.append(f"unexpected checksum binding: {name}")
104
+
105
+ for i in range(manifest.shard_count):
106
+ shard = _shard_path(tokenized_dir, i)
107
+ if not shard.is_file():
108
+ issues.append(f"missing shard: {shard.name}")
109
+ elif shard.stat().st_size == 0:
110
+ issues.append(f"empty shard: {shard.name}")
111
+ else:
112
+ expected_sha256 = manifest.shard_sha256.get(shard.name)
113
+ if expected_sha256 is not None and file_sha256(shard) != expected_sha256:
114
+ issues.append(f"checksum mismatch: {shard.name}")
115
+ continue
116
+ try:
117
+ import numpy as np
118
+
119
+ with np.load(shard, allow_pickle=False) as data:
120
+ required = {"input_ids", "attention_mask", "sample_indices"}
121
+ missing = required - set(data.files)
122
+ if missing:
123
+ issues.append(
124
+ f"invalid shard {shard.name}: missing arrays {sorted(missing)}"
125
+ )
126
+ continue
127
+ input_ids = data["input_ids"]
128
+ attention_mask = data["attention_mask"]
129
+ sample_indices = data["sample_indices"]
130
+ if input_ids.ndim != 2 or input_ids.shape != attention_mask.shape:
131
+ issues.append(f"invalid shard {shard.name}: token array shape mismatch")
132
+ if sample_indices.ndim != 1 or len(sample_indices) != len(input_ids):
133
+ issues.append(f"invalid shard {shard.name}: sample index shape mismatch")
134
+ except (OSError, ValueError) as exc:
135
+ issues.append(f"invalid shard {shard.name}: {exc}")
136
+
137
+ # Check for extra shards
138
+ existing_shards = sorted(tokenized_dir.glob(f"{_SHARD_PREFIX}*{_SHARD_SUFFIX}"))
139
+ if len(existing_shards) > manifest.shard_count:
140
+ issues.append(
141
+ f"found {len(existing_shards)} shards but manifest declares {manifest.shard_count}"
142
+ )
143
+
144
+ return issues
145
+
146
+
147
+ def _read_dataset(path: Path) -> list[dict[str, Any]]:
148
+ samples: list[dict[str, Any]] = []
149
+ try:
150
+ with path.open(encoding="utf-8") as source:
151
+ for line_number, line in enumerate(source, 1):
152
+ if not line.strip():
153
+ continue
154
+ value = json.loads(line)
155
+ if not isinstance(value, dict):
156
+ raise CacheError(f"{path}:{line_number} must contain a JSON object")
157
+ samples.append(value)
158
+ except json.JSONDecodeError as exc:
159
+ raise CacheError(f"invalid JSONL in {path}:{exc.lineno}: {exc.msg}") from exc
160
+ except OSError as exc:
161
+ raise CacheError(f"cannot read calibration dataset: {exc}") from exc
162
+ if not samples:
163
+ raise CacheError("calibration dataset contains no samples")
164
+ return samples
165
+
166
+
167
+ def _sample_text(sample: dict[str, Any], tokenizer: Any) -> str:
168
+ messages = sample.get("messages")
169
+ if isinstance(messages, list) and messages:
170
+ apply_template = getattr(tokenizer, "apply_chat_template", None)
171
+ if callable(apply_template):
172
+ rendered = apply_template(messages, tokenize=False, add_generation_prompt=False)
173
+ if isinstance(rendered, str) and rendered:
174
+ return rendered
175
+ for key in ("text", "prompt", "content", "instruction"):
176
+ value = sample.get(key)
177
+ if isinstance(value, str) and value:
178
+ response = sample.get("response") or sample.get("output")
179
+ return f"{value}\n{response}" if isinstance(response, str) and response else value
180
+ raise CacheError(
181
+ "calibration samples require non-empty text, prompt, content, instruction, or messages"
182
+ )
183
+
184
+
185
+ def _load_tokenizer(model: ModelIdentity, tokenizer_revision: str | None) -> Any:
186
+ try:
187
+ from transformers import AutoTokenizer
188
+ except ImportError:
189
+ raise BackendUnavailableError(
190
+ "tokenization requires transformers; install axquant[mlx]"
191
+ ) from None
192
+ source = model.local_path or model.model_id
193
+ kwargs: dict[str, Any] = {"trust_remote_code": False}
194
+ if model.local_path is not None:
195
+ kwargs["local_files_only"] = True
196
+ elif tokenizer_revision or model.revision:
197
+ kwargs["revision"] = tokenizer_revision or model.revision
198
+ try:
199
+ return AutoTokenizer.from_pretrained(source, **kwargs)
200
+ except (OSError, ValueError) as exc:
201
+ raise CacheError(f"cannot load tokenizer for {source}: {exc}") from exc
202
+
203
+
204
+ def _tokenizer_sha256(tokenizer: Any) -> str:
205
+ try:
206
+ vocabulary = tokenizer.get_vocab()
207
+ except (AttributeError, TypeError):
208
+ vocabulary = {}
209
+ special_tokens_map = getattr(tokenizer, "special_tokens_map", {}) or {}
210
+ special_tokens = {str(key): str(value) for key, value in special_tokens_map.items()}
211
+ return stable_sha256(
212
+ {
213
+ "class": type(tokenizer).__name__,
214
+ "vocabulary": vocabulary,
215
+ "special_tokens": special_tokens,
216
+ }
217
+ )
218
+
219
+
220
+ def _write_npz_atomic(path: Path, *, compressed: bool = False, **arrays: Any) -> None:
221
+ import numpy as np
222
+
223
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
224
+ try:
225
+ with os.fdopen(descriptor, "wb") as destination:
226
+ if compressed:
227
+ np.savez_compressed(destination, **arrays)
228
+ else:
229
+ np.savez(destination, **arrays)
230
+ destination.flush()
231
+ os.fsync(destination.fileno())
232
+ os.replace(temporary_name, path)
233
+ except Exception:
234
+ with contextlib.suppress(FileNotFoundError):
235
+ os.unlink(temporary_name)
236
+ raise
237
+
238
+
239
+ def is_cache_complete(
240
+ cache_dir: Path,
241
+ manifest: TokenizedCacheManifest | None = None,
242
+ ) -> bool:
243
+ """Verify that the completion marker binds the finished cache manifest.
244
+
245
+ Legacy markers did not carry a schema or manifest digest. They remain
246
+ readable only when every field they did record matches the manifest; new
247
+ markers additionally bind the complete semantic manifest identity.
248
+ """
249
+
250
+ bound_manifest = manifest or load_cache_manifest(cache_dir)
251
+ if bound_manifest is None or not bound_manifest.complete:
252
+ return False
253
+ marker_path = cache_dir / _COMPLETION_MARKER
254
+ if not marker_path.is_file():
255
+ return False
256
+ try:
257
+ marker = json.loads(marker_path.read_text(encoding="utf-8"))
258
+ except (OSError, json.JSONDecodeError):
259
+ return False
260
+ if not isinstance(marker, dict) or marker.get("complete") is not True:
261
+ return False
262
+ expected_fields: dict[str, Any] = {
263
+ "cache_key_sha256": bound_manifest.cache_key_sha256,
264
+ "shard_count": bound_manifest.shard_count,
265
+ "total_tokens": bound_manifest.total_tokens,
266
+ }
267
+ if any(marker.get(name) != expected for name, expected in expected_fields.items()):
268
+ return False
269
+ schema_version = marker.get("schema_version")
270
+ if schema_version is None:
271
+ return True
272
+ if schema_version != _COMPLETION_SCHEMA:
273
+ return False
274
+ return marker.get("manifest_sha256") == stable_sha256(bound_manifest)
275
+
276
+
277
+ def load_cache_manifest(cache_dir: Path) -> TokenizedCacheManifest | None:
278
+ """Load the tokenized cache manifest if it exists."""
279
+ manifest_path = cache_dir / _MANIFEST_NAME
280
+ if not manifest_path.is_file():
281
+ return None
282
+ try:
283
+ return load_model(manifest_path, TokenizedCacheManifest)
284
+ except (ArtifactError, ValueError):
285
+ return None
286
+
287
+
288
+ def tokenize_calibration(
289
+ *,
290
+ model: ModelIdentity,
291
+ dataset_path: str | Path,
292
+ output_dir: str | Path,
293
+ profile: ProfileName,
294
+ sequence_length: int,
295
+ random_seed: int,
296
+ tokenizer_revision: str | None = None,
297
+ config_digest: str | None = None,
298
+ tokenizer: Any | None = None,
299
+ calibration_manifest_sha256: str | None = None,
300
+ separation_attested: bool = False,
301
+ domains: list[str] | None = None,
302
+ ) -> TokenizedCacheManifest:
303
+ """Tokenize a calibration dataset and write the cache structure.
304
+
305
+ This function requires MLX-LM for tokenization. If MLX-LM is not
306
+ available, it raises BackendUnavailableError.
307
+
308
+ Cache structure:
309
+ calibration-cache/
310
+ tokenized_cache_manifest.json
311
+ tokenized/
312
+ shard-0000.npz ... shard-NNNN.npz
313
+ completion.json
314
+ """
315
+ dataset = Path(dataset_path).expanduser().resolve()
316
+ if not dataset.is_file():
317
+ raise CacheError(f"calibration dataset does not exist: {dataset}")
318
+
319
+ dataset_sha = file_sha256(dataset)
320
+ samples_data = _read_dataset(dataset)
321
+ recorded_domains = [
322
+ domain.strip()
323
+ for sample in samples_data
324
+ if isinstance((domain := sample.get("domain")), str) and domain.strip()
325
+ ]
326
+ observed_domains = sorted(set(recorded_domains))
327
+ requested_domains = sorted(set(domains or observed_domains))
328
+ if observed_domains and not set(requested_domains).issubset(observed_domains):
329
+ missing = sorted(set(requested_domains) - set(observed_domains))
330
+ raise CacheError(f"declared calibration domains have no matching samples: {missing}")
331
+ domain_provenance = (
332
+ "sample-records"
333
+ if len(recorded_domains) == len(samples_data) and observed_domains
334
+ else "declared"
335
+ )
336
+ versions = collect_versions()
337
+ cache_dir = Path(output_dir).expanduser().resolve()
338
+ existing_manifest = load_cache_manifest(cache_dir)
339
+ if (
340
+ existing_manifest is not None
341
+ and existing_manifest.calibration_manifest_sha256 != calibration_manifest_sha256
342
+ ):
343
+ raise CacheError(
344
+ "calibration cache already exists with a different calibration manifest binding "
345
+ f"at {cache_dir}; use a new output directory"
346
+ )
347
+ if existing_manifest is not None and (
348
+ existing_manifest.dataset_sha256 != dataset_sha
349
+ or existing_manifest.model != model
350
+ or existing_manifest.profile != profile
351
+ or existing_manifest.sequence_length != sequence_length
352
+ ):
353
+ raise CacheError(
354
+ f"calibration cache already exists with different inputs at {cache_dir}; "
355
+ "use a new output directory"
356
+ )
357
+ tokenizer_instance = tokenizer or _load_tokenizer(model, tokenizer_revision)
358
+ tokenizer_sha = _tokenizer_sha256(tokenizer_instance)
359
+
360
+ cache_key = compute_cache_key(
361
+ model=model,
362
+ dataset_sha256=dataset_sha,
363
+ profile=profile,
364
+ sequence_length=sequence_length,
365
+ random_seed=random_seed,
366
+ tokenizer_revision=tokenizer_revision or model.revision,
367
+ config_digest=config_digest or tokenizer_sha,
368
+ mlx_version=versions.mlx,
369
+ mlx_lm_version=versions.mlx_lm,
370
+ separation_attested=separation_attested,
371
+ domains=tuple(requested_domains),
372
+ )
373
+
374
+ # Check for existing cache with same key
375
+ if existing_manifest is not None:
376
+ if existing_manifest.cache_key_sha256 == cache_key:
377
+ if is_cache_complete(cache_dir, existing_manifest):
378
+ issues = verify_cache_integrity(cache_dir, existing_manifest)
379
+ if issues:
380
+ raise CacheError(f"completed calibration cache failed verification: {issues}")
381
+ log.info("calibration_cache_reused", path=str(cache_dir))
382
+ return existing_manifest
383
+ # Incomplete cache with same key - verify and resume
384
+ issues = verify_cache_integrity(cache_dir, existing_manifest)
385
+ if not issues:
386
+ final_manifest = existing_manifest.model_copy(update={"complete": True})
387
+ write_data(cache_dir / _MANIFEST_NAME, final_manifest)
388
+ _write_completion_marker(cache_dir, final_manifest)
389
+ return final_manifest
390
+ raise CacheError(f"existing cache is incomplete and cannot be resumed: {issues}")
391
+ # Different inputs - refuse to overwrite
392
+ raise CacheError(
393
+ f"calibration cache already exists with different inputs at {cache_dir}; "
394
+ "use a new output directory"
395
+ )
396
+
397
+ # Create cache structure
398
+ tokenized_dir = cache_dir / "tokenized"
399
+ tokenized_dir.mkdir(parents=True, exist_ok=True)
400
+
401
+ try:
402
+ import numpy as np
403
+ except ImportError:
404
+ raise BackendUnavailableError(
405
+ "tokenization requires numpy; install with: pip install numpy"
406
+ ) from None
407
+
408
+ order = list(range(len(samples_data)))
409
+ random.Random(random_seed).shuffle(order)
410
+ encoded: list[tuple[int, list[int]]] = []
411
+ for sample_index in order:
412
+ text = _sample_text(samples_data[sample_index], tokenizer_instance)
413
+ token_ids = tokenizer_instance.encode(
414
+ text,
415
+ add_special_tokens=True,
416
+ truncation=True,
417
+ max_length=sequence_length,
418
+ )
419
+ if not isinstance(token_ids, list):
420
+ token_ids = list(token_ids)
421
+ normalized_ids = [int(token) for token in token_ids[:sequence_length]]
422
+ if not normalized_ids:
423
+ fallback_token = getattr(tokenizer_instance, "eos_token_id", None)
424
+ normalized_ids = [int(fallback_token) if fallback_token is not None else 0]
425
+ encoded.append((sample_index, normalized_ids))
426
+
427
+ shard_count = max(1, (len(encoded) + _SAMPLES_PER_SHARD - 1) // _SAMPLES_PER_SHARD)
428
+ total_tokens = sum(len(tokens) for _, tokens in encoded)
429
+ shard_sha256: dict[str, str] = {}
430
+ pad_token = getattr(tokenizer_instance, "pad_token_id", None)
431
+ if pad_token is None:
432
+ pad_token = getattr(tokenizer_instance, "eos_token_id", None)
433
+ pad_token_id = int(pad_token) if pad_token is not None else 0
434
+
435
+ for shard_index in range(shard_count):
436
+ shard_samples = encoded[
437
+ shard_index * _SAMPLES_PER_SHARD : (shard_index + 1) * _SAMPLES_PER_SHARD
438
+ ]
439
+ width = max(len(tokens) for _, tokens in shard_samples)
440
+ input_ids = np.full((len(shard_samples), width), pad_token_id, dtype=np.int32)
441
+ attention_mask = np.zeros((len(shard_samples), width), dtype=np.uint8)
442
+ sample_indices = np.empty((len(shard_samples),), dtype=np.int64)
443
+ for row, (sample_index, token_ids) in enumerate(shard_samples):
444
+ input_ids[row, : len(token_ids)] = token_ids
445
+ attention_mask[row, : len(token_ids)] = 1
446
+ sample_indices[row] = sample_index
447
+ shard_file = _shard_path(tokenized_dir, shard_index)
448
+ _write_npz_atomic(
449
+ shard_file,
450
+ compressed=True,
451
+ input_ids=input_ids,
452
+ attention_mask=attention_mask,
453
+ sample_indices=sample_indices,
454
+ )
455
+ shard_sha256[shard_file.name] = file_sha256(shard_file)
456
+
457
+ manifest = TokenizedCacheManifest(
458
+ cache_key_sha256=cache_key,
459
+ model=model,
460
+ dataset_sha256=dataset_sha,
461
+ profile=profile,
462
+ domains=requested_domains,
463
+ domain_provenance=domain_provenance,
464
+ sequence_length=sequence_length,
465
+ samples=len(samples_data),
466
+ shard_count=shard_count,
467
+ total_tokens=total_tokens,
468
+ tokenizer_revision=tokenizer_revision or model.revision,
469
+ tokenizer_sha256=tokenizer_sha,
470
+ sample_order_sha256=stable_sha256(order),
471
+ calibration_manifest_sha256=calibration_manifest_sha256,
472
+ calibration_evaluation_separation_attested=separation_attested,
473
+ backend_version=_BACKEND_VERSION,
474
+ shard_sha256=shard_sha256,
475
+ software_versions=versions,
476
+ complete=False,
477
+ )
478
+
479
+ # Write manifest
480
+ write_data(cache_dir / _MANIFEST_NAME, manifest)
481
+
482
+ # Verify all shards before writing completion marker
483
+ issues = verify_cache_integrity(cache_dir, manifest)
484
+ if issues:
485
+ raise CacheError(f"cache verification failed after writing: {issues}")
486
+
487
+ # Write atomic completion marker
488
+ final_manifest = manifest.model_copy(update={"complete": True})
489
+ write_data(cache_dir / _MANIFEST_NAME, final_manifest)
490
+ _write_completion_marker(cache_dir, final_manifest)
491
+
492
+ log.info(
493
+ "calibration_cache_created",
494
+ path=str(cache_dir),
495
+ shards=shard_count,
496
+ samples=len(samples_data),
497
+ total_tokens=total_tokens,
498
+ )
499
+ return final_manifest
500
+
501
+
502
+ def _write_completion_marker(cache_dir: Path, manifest: TokenizedCacheManifest) -> None:
503
+ """Write the atomic completion marker."""
504
+ marker_data = {
505
+ "schema_version": _COMPLETION_SCHEMA,
506
+ "complete": True,
507
+ "cache_key_sha256": manifest.cache_key_sha256,
508
+ "manifest_sha256": stable_sha256(manifest),
509
+ "shard_count": manifest.shard_count,
510
+ "total_tokens": manifest.total_tokens,
511
+ }
512
+ write_data(cache_dir / _COMPLETION_MARKER, marker_data)
axquant/analyzer.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import ValidationError
4
+
5
+ from axquant.errors import PlanningError
6
+ from axquant.schema import (
7
+ AX_ENGINE_EXECUTABLE_BITS,
8
+ AX_ENGINE_EXECUTABLE_GROUP_SIZES,
9
+ CandidateMeasurement,
10
+ EvidenceKind,
11
+ Inventory,
12
+ MetricVector,
13
+ ProfileName,
14
+ QuantMethod,
15
+ SensitivityReport,
16
+ TensorRole,
17
+ TensorSensitivity,
18
+ )
19
+ from axquant.serde import stable_sha256
20
+
21
+ _ROLE_SENSITIVITY = {
22
+ TensorRole.EMBEDDING: 0.90,
23
+ TensorRole.ATTENTION: 0.72,
24
+ TensorRole.MLP: 0.45,
25
+ TensorRole.NORM: 1.20,
26
+ TensorRole.LM_HEAD: 1.25,
27
+ TensorRole.ROUTER: 1.10,
28
+ TensorRole.EXPERT: 0.38,
29
+ TensorRole.MTP_PROJECTION: 1.20,
30
+ TensorRole.MTP_BLOCK: 1.10,
31
+ TensorRole.MTP_OUTPUT: 1.35,
32
+ TensorRole.VISION: 0.90,
33
+ TensorRole.AUDIO: 0.90,
34
+ TensorRole.OTHER: 0.60,
35
+ }
36
+
37
+
38
+ def _prior_metrics(
39
+ role: TensorRole,
40
+ bits: int,
41
+ *,
42
+ group_size: int | None = 64,
43
+ ) -> MetricVector:
44
+ if bits == 16:
45
+ return MetricVector()
46
+ # Smaller groups weakly reduce prior noise (AXQ-028 development heuristic only).
47
+ group_factor = 1.0 if group_size is None else (float(group_size) / 64.0) ** 0.5
48
+ noise = (2.0 ** (4 - bits)) * group_factor
49
+ sensitivity = _ROLE_SENSITIVITY[role]
50
+ mtp_factor = 1.4 if role.is_mtp else 0.08
51
+ long_context_factor = 1.25 if role == TensorRole.ATTENTION else 0.30
52
+ return MetricVector(
53
+ output_kl=sensitivity * noise,
54
+ hidden_state_error=sensitivity * 0.80 * noise,
55
+ cosine_distance=sensitivity * 0.45 * noise,
56
+ token_disagreement=sensitivity * 0.55 * noise,
57
+ task_loss_delta=sensitivity * 0.65 * noise,
58
+ mtp_acceptance_loss=sensitivity * mtp_factor * noise,
59
+ long_context_loss=sensitivity * long_context_factor * noise,
60
+ )
61
+
62
+
63
+ def architecture_prior_report(
64
+ inventory: Inventory,
65
+ *,
66
+ profile: ProfileName,
67
+ candidate_bits: tuple[int, ...] = (4, 6, 8, 16),
68
+ group_size: int = 64,
69
+ candidate_group_sizes: tuple[int, ...] = (),
70
+ ) -> SensitivityReport:
71
+ """Build architecture-prior sensitivity with optional multi-group candidates (AXQ-028)."""
72
+ try:
73
+ inventory = Inventory.model_validate(inventory.model_dump(mode="python"))
74
+ except ValidationError as exc:
75
+ raise PlanningError(f"invalid inventory for architecture analysis: {exc}") from exc
76
+ if not inventory.tensors:
77
+ raise PlanningError("architecture analysis requires a non-empty tensor inventory")
78
+ if not candidate_bits:
79
+ raise PlanningError("architecture analysis requires at least one candidate bit-width")
80
+ if any(type(bits) is not int for bits in candidate_bits):
81
+ raise PlanningError("architecture candidate bit-widths must be integers")
82
+ normalized_bits = tuple(sorted(set(candidate_bits)))
83
+ unsupported_bits = set(normalized_bits) - AX_ENGINE_EXECUTABLE_BITS
84
+ if unsupported_bits:
85
+ raise PlanningError(
86
+ f"AX Engine does not support candidate bit-widths {sorted(unsupported_bits)}"
87
+ )
88
+ raw_groups = candidate_group_sizes or (group_size,)
89
+ if not raw_groups or any(type(size) is not int for size in raw_groups):
90
+ raise PlanningError("architecture candidate group sizes must be non-empty integers")
91
+ effective_groups = tuple(sorted(set(raw_groups)))
92
+ unsupported_groups = set(effective_groups) - AX_ENGINE_EXECUTABLE_GROUP_SIZES
93
+ if unsupported_groups:
94
+ raise PlanningError(
95
+ f"AX Engine does not support candidate group sizes {sorted(unsupported_groups)}"
96
+ )
97
+ entries: list[TensorSensitivity] = []
98
+ for tensor in inventory.tensors:
99
+ bits_for_tensor = normalized_bits if tensor.quantizable else (16,)
100
+ candidates: list[CandidateMeasurement] = []
101
+ for bits in bits_for_tensor:
102
+ if bits == 16:
103
+ candidates.append(
104
+ CandidateMeasurement(
105
+ bits=16,
106
+ method=QuantMethod.BF16,
107
+ group_size=None,
108
+ metrics=_prior_metrics(tensor.role, 16),
109
+ note="architecture prior; not a measured quality result",
110
+ )
111
+ )
112
+ continue
113
+ for size in effective_groups:
114
+ candidates.append(
115
+ CandidateMeasurement(
116
+ bits=bits,
117
+ method=QuantMethod.AFFINE,
118
+ group_size=size,
119
+ metrics=_prior_metrics(tensor.role, bits, group_size=size),
120
+ note="architecture prior; not a measured quality result",
121
+ )
122
+ )
123
+ entries.append(TensorSensitivity(tensor=tensor, candidates=candidates))
124
+ warnings = [
125
+ "This report contains architecture priors, not calibration measurements.",
126
+ "Conversion planning requires --allow-unmeasured for this report.",
127
+ ]
128
+ if len(effective_groups) > 1:
129
+ warnings.append(
130
+ "Multi-group architecture priors weakly prefer smaller group sizes; "
131
+ "this is development evidence only (AXQ-028)."
132
+ )
133
+ return SensitivityReport(
134
+ model=inventory.model,
135
+ architecture_profile=inventory.architecture_profile,
136
+ profile=profile,
137
+ evidence_kind=EvidenceKind.ARCHITECTURE_PRIOR,
138
+ inventory_sha256=stable_sha256(inventory.model_dump(mode="json", exclude={"created_at"})),
139
+ entries=entries,
140
+ warnings=warnings,
141
+ )
@@ -0,0 +1,4 @@
1
+ from axquant.architectures.registry import adapter_for
2
+ from axquant.architectures.types import ArchitectureAdapter
3
+
4
+ __all__ = ["ArchitectureAdapter", "adapter_for"]