vector-mtrl 0.2.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 (81) hide show
  1. mtrl/__init__.py +144 -0
  2. mtrl/_alignment_report.py +105 -0
  3. mtrl/_archive_documents.py +73 -0
  4. mtrl/_archive_integrity.py +539 -0
  5. mtrl/_archive_loading.py +1070 -0
  6. mtrl/_archive_serialise.py +696 -0
  7. mtrl/_candidate_matching.py +216 -0
  8. mtrl/_cli.py +28 -0
  9. mtrl/_disclosure.py +735 -0
  10. mtrl/_ingestion_shortcuts.py +99 -0
  11. mtrl/_install_targets.py +36 -0
  12. mtrl/_optional.py +38 -0
  13. mtrl/_provenance.py +32 -0
  14. mtrl/_receipts.py +211 -0
  15. mtrl/_record_resolution.py +60 -0
  16. mtrl/_replay_plan.py +557 -0
  17. mtrl/_replay_run.py +734 -0
  18. mtrl/_run_management.py +400 -0
  19. mtrl/_source_capture.py +507 -0
  20. mtrl/_table_plan.py +271 -0
  21. mtrl/_table_provenance.py +387 -0
  22. mtrl/_table_values.py +188 -0
  23. mtrl/_transform_apply.py +223 -0
  24. mtrl/_transform_validation.py +1367 -0
  25. mtrl/archive/__init__.py +54 -0
  26. mtrl/archive/canonical.py +45 -0
  27. mtrl/archive/codecs.py +88 -0
  28. mtrl/archive/dtypes.py +39 -0
  29. mtrl/archive/reader.py +945 -0
  30. mtrl/archive/validation.py +1872 -0
  31. mtrl/connectors/__init__.py +1 -0
  32. mtrl/connectors/protocols.py +84 -0
  33. mtrl/disclosure.py +273 -0
  34. mtrl/domain/__init__.py +129 -0
  35. mtrl/domain/archive_limits.py +46 -0
  36. mtrl/domain/committed_values.py +539 -0
  37. mtrl/domain/frozen_containers.py +168 -0
  38. mtrl/domain/identities.py +174 -0
  39. mtrl/domain/provenance.py +448 -0
  40. mtrl/domain/records.py +233 -0
  41. mtrl/domain/revisions.py +121 -0
  42. mtrl/domain/source.py +13 -0
  43. mtrl/domain/verification.py +302 -0
  44. mtrl/excel.py +139 -0
  45. mtrl/exceptions.py +215 -0
  46. mtrl/exports.py +326 -0
  47. mtrl/features.py +251 -0
  48. mtrl/frames.py +228 -0
  49. mtrl/grids.py +1172 -0
  50. mtrl/integrity/__init__.py +143 -0
  51. mtrl/integrity/canonical.py +511 -0
  52. mtrl/integrity/digests.py +363 -0
  53. mtrl/integrity/manifest.py +631 -0
  54. mtrl/integrity/receipts.py +771 -0
  55. mtrl/integrity/verification.py +1303 -0
  56. mtrl/models.py +952 -0
  57. mtrl/mtrl.py +1852 -0
  58. mtrl/operations/__init__.py +36 -0
  59. mtrl/operations/_identity.py +17 -0
  60. mtrl/operations/_validation.py +106 -0
  61. mtrl/operations/arithmetic.py +207 -0
  62. mtrl/operations/base.py +292 -0
  63. mtrl/operations/resampling.py +203 -0
  64. mtrl/operations/resampling_scipy.py +138 -0
  65. mtrl/operations/spectra.py +58 -0
  66. mtrl/processing/__init__.py +23 -0
  67. mtrl/processing/_table_capture.py +341 -0
  68. mtrl/processing/limits.py +9 -0
  69. mtrl/processing/registry.py +1533 -0
  70. mtrl/protocols.py +13 -0
  71. mtrl/py.typed +0 -0
  72. mtrl/replay.py +946 -0
  73. mtrl/tables.py +1365 -0
  74. mtrl/transformations/__init__.py +22 -0
  75. mtrl/transformations/base.py +17 -0
  76. mtrl/units.py +60 -0
  77. vector_mtrl-0.2.0.dist-info/METADATA +142 -0
  78. vector_mtrl-0.2.0.dist-info/RECORD +81 -0
  79. vector_mtrl-0.2.0.dist-info/WHEEL +4 -0
  80. vector_mtrl-0.2.0.dist-info/licenses/LICENSE +373 -0
  81. vector_mtrl-0.2.0.dist-info/licenses/NOTICE +30 -0
@@ -0,0 +1,539 @@
1
+ """Integrity validation and digest checks for new-format archives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import zipfile
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING, Any, cast
9
+
10
+ from mtrl._archive_documents import (
11
+ _CANONICAL_OPERATION_PARAMETER_PATHS,
12
+ _deserialise_metadata_field,
13
+ _normalise_new_object_list,
14
+ )
15
+ from mtrl.archive import BoundedZipReader, decode_numpy
16
+ from mtrl.archive.validation import ArchiveDocuments, validate_decoded_numpy_payload
17
+ from mtrl.domain import CoverageStatus, Revision
18
+ from mtrl.exceptions import (
19
+ CanonicalisationError,
20
+ InsufficientCoverageError,
21
+ IntegrityLayer,
22
+ IntegrityMismatchError,
23
+ InvalidArchiveStructureError,
24
+ )
25
+ from mtrl.integrity.canonical import decode_schema_int
26
+ from mtrl.integrity.digests import (
27
+ DigestOrder,
28
+ canonical_payload_digest,
29
+ raw_digest,
30
+ record_digest,
31
+ revision_digest,
32
+ source_digest,
33
+ )
34
+ from mtrl.integrity.digests import (
35
+ storage_digest as calculate_storage_digest,
36
+ )
37
+ from mtrl.integrity.manifest import record_body
38
+ from mtrl.models import ArchiveEntry, CheckpointEncoding, CheckpointManifestEntry, Manifest
39
+
40
+ if TYPE_CHECKING:
41
+ from mtrl.mtrl import Mtrl
42
+
43
+
44
+ def _assert_integrity_mapping(
45
+ expected: Any,
46
+ candidate: Any,
47
+ *,
48
+ label: IntegrityLayer,
49
+ ) -> None:
50
+ """Compare one committed integrity mapping with a rebuilt mapping."""
51
+ if not isinstance(expected, Mapping) or not isinstance(candidate, Mapping):
52
+ return
53
+ for item_id, expected_value in expected.items():
54
+ if candidate.get(item_id) != expected_value:
55
+ raise IntegrityMismatchError(
56
+ f"Committed {label} '{item_id}' has an integrity mismatch.",
57
+ layer=label,
58
+ )
59
+
60
+
61
+ def _assert_current_application_state(
62
+ container: Mtrl,
63
+ expected_integrity: Mapping[str, Any],
64
+ candidate_entries: Sequence[tuple[str, bytes]],
65
+ ) -> None:
66
+ """Compare every baseline revision identity before allowing a new revision."""
67
+ expected_bodies = expected_integrity.get("revision_bodies", {})
68
+ expected_roots = expected_integrity.get("revision_roots", {})
69
+ if not isinstance(expected_bodies, Mapping) or not isinstance(
70
+ expected_roots,
71
+ Mapping,
72
+ ):
73
+ return
74
+ current_revisions = {str(revision.revision_id): revision for revision in container._revisions}
75
+ for revision_id, expected_body in expected_bodies.items():
76
+ revision = current_revisions.get(str(revision_id))
77
+ if revision is None or not isinstance(expected_body, Mapping):
78
+ raise IntegrityMismatchError(
79
+ f"Committed revision '{revision_id}' has an integrity mismatch.",
80
+ layer="revision",
81
+ )
82
+ _validate_revision_body_identity(revision, expected_body)
83
+ if revision.revision_root != expected_roots.get(revision_id):
84
+ raise IntegrityMismatchError(
85
+ f"Committed revision '{revision_id}' has an integrity mismatch.",
86
+ layer="revision",
87
+ )
88
+ if len(current_revisions) == len(expected_bodies):
89
+ _assert_saved_entries_unchanged(container._archive_path, candidate_entries)
90
+
91
+
92
+ def _assert_saved_entries_unchanged(
93
+ archive_path: Path | None,
94
+ candidate_entries: Sequence[tuple[str, bytes]],
95
+ ) -> None:
96
+ """Reject in-place edits when no new application revision exists."""
97
+ if archive_path is None:
98
+ return
99
+ with zipfile.ZipFile(archive_path) as archive:
100
+ saved_entries = {entry.filename: archive.read(entry) for entry in archive.infolist()}
101
+ if dict(candidate_entries) != saved_entries:
102
+ raise IntegrityMismatchError(
103
+ "Committed evidence changed without creating a new revision.",
104
+ layer="storage",
105
+ )
106
+
107
+
108
+ def _assert_integrity_inventory(expected: Any, candidate: Any) -> None:
109
+ """Compare committed payload and source inventory descriptors."""
110
+ if not isinstance(expected, Mapping) or not isinstance(candidate, Mapping):
111
+ return
112
+ covered_kinds = {"payload", "source", "environment-record", "source-bundle"}
113
+ for entry_path, expected_descriptor in expected.items():
114
+ if not isinstance(expected_descriptor, Mapping):
115
+ continue
116
+ candidate_descriptor = candidate.get(entry_path)
117
+ if not isinstance(candidate_descriptor, Mapping):
118
+ raise IntegrityMismatchError(
119
+ f"Committed inventory entry '{entry_path}' is missing.",
120
+ layer="storage",
121
+ )
122
+ if expected_descriptor.get("kind") not in covered_kinds:
123
+ continue
124
+ if dict(candidate_descriptor) != dict(expected_descriptor):
125
+ raise IntegrityMismatchError(
126
+ f"Committed inventory entry '{entry_path}' has an integrity mismatch.",
127
+ layer="storage",
128
+ )
129
+
130
+
131
+ def _validate_integrity_manifest(
132
+ archive: BoundedZipReader,
133
+ manifest: Manifest,
134
+ documents: ArchiveDocuments,
135
+ *,
136
+ allow_partial: bool = False,
137
+ ) -> CoverageStatus:
138
+ """Validate new-format inventory digests and every revision root."""
139
+ integrity_data = documents.integrity_data
140
+ if not isinstance(integrity_data, Mapping):
141
+ raise InvalidArchiveStructureError("Archive integrity record is incomplete.")
142
+ inventory = integrity_data.get("inventory")
143
+ if not isinstance(inventory, Mapping):
144
+ raise InvalidArchiveStructureError("Archive integrity inventory is incomplete.")
145
+ _validate_integrity_entry_storage(archive, inventory)
146
+ archive.preflight_numpy_headers(
147
+ checkpoint.entry
148
+ for checkpoint in documents.checkpoints
149
+ if checkpoint.encoding == CheckpointEncoding.NPY
150
+ )
151
+ _validate_integrity_payload_digests(archive, documents.checkpoints)
152
+ _validate_integrity_source_digests(archive, manifest)
153
+ record_coverage = _validate_integrity_record_digests(
154
+ archive,
155
+ inventory,
156
+ integrity_data,
157
+ )
158
+ _validate_integrity_revision_roots(documents, integrity_data)
159
+ inventory_coverage = _validate_integrity_inventory_keys(
160
+ archive,
161
+ manifest,
162
+ documents,
163
+ integrity_data,
164
+ )
165
+ if record_coverage and inventory_coverage:
166
+ return CoverageStatus.FULL
167
+ if not allow_partial:
168
+ raise InsufficientCoverageError(
169
+ "Archive integrity coverage is partial; full manifest coverage is required."
170
+ )
171
+ return CoverageStatus.PARTIAL
172
+
173
+
174
+ def _validate_integrity_storage_before_restoration(
175
+ archive: BoundedZipReader,
176
+ integrity_data: Any,
177
+ ) -> None:
178
+ """Check covered entry bytes before structural restoration can continue."""
179
+ if not isinstance(integrity_data, Mapping):
180
+ raise InvalidArchiveStructureError("Archive integrity record is incomplete.")
181
+ inventory = integrity_data.get("inventory")
182
+ if not isinstance(inventory, Mapping):
183
+ raise InvalidArchiveStructureError("Archive integrity inventory is incomplete.")
184
+ _validate_integrity_entry_storage(
185
+ archive,
186
+ inventory,
187
+ entry_paths=(ArchiveEntry.REVISIONS.value,),
188
+ )
189
+
190
+
191
+ def _validate_integrity_payload_digests(
192
+ archive: BoundedZipReader,
193
+ checkpoints: Sequence[CheckpointManifestEntry],
194
+ ) -> None:
195
+ """Verify raw and canonical payload digests before interpretation checks."""
196
+ for checkpoint in sorted(checkpoints, key=lambda value: value.entry):
197
+ entry_bytes = archive.read_entry(checkpoint.entry)
198
+ if checkpoint.encoding == CheckpointEncoding.BYTES:
199
+ if checkpoint.raw_digest is None:
200
+ raise InvalidArchiveStructureError(
201
+ f"Payload '{checkpoint.entry}' has incomplete raw digest evidence."
202
+ )
203
+ observed_raw_digest = raw_digest(entry_bytes)
204
+ if observed_raw_digest != checkpoint.raw_digest:
205
+ raise IntegrityMismatchError(
206
+ f"Payload '{checkpoint.entry}' has a raw digest mismatch: "
207
+ f"expected {checkpoint.raw_digest}; observed {observed_raw_digest}.",
208
+ layer="raw",
209
+ )
210
+ continue
211
+ if checkpoint.storage_digest is None or checkpoint.payload_digest is None:
212
+ raise InvalidArchiveStructureError(
213
+ f"Payload '{checkpoint.entry}' has incomplete dual digest evidence."
214
+ )
215
+ try:
216
+ archive.validate_numpy(checkpoint.entry)
217
+ decoded_array = decode_numpy(entry_bytes)
218
+ except (EOFError, ValueError, OSError) as error:
219
+ raise InvalidArchiveStructureError(
220
+ f"Payload '{checkpoint.entry}' is not valid for codec 'npy'."
221
+ ) from error
222
+ validate_decoded_numpy_payload(
223
+ checkpoint,
224
+ decoded_array,
225
+ checkpoint.entry,
226
+ )
227
+ if checkpoint.payload_digest is None:
228
+ continue
229
+ observed_payload_digest = canonical_payload_digest(
230
+ decoded_array,
231
+ order=cast(DigestOrder, checkpoint.order),
232
+ )
233
+ if observed_payload_digest != checkpoint.payload_digest:
234
+ raise IntegrityMismatchError(
235
+ f"Payload '{checkpoint.entry}' has a canonical payload digest mismatch: "
236
+ f"expected {checkpoint.payload_digest}; observed {observed_payload_digest}.",
237
+ layer="canonical",
238
+ )
239
+
240
+
241
+ def _validate_checkpoint_storage_digests(
242
+ archive: BoundedZipReader,
243
+ checkpoints: Sequence[CheckpointManifestEntry],
244
+ ) -> None:
245
+ """Verify all recorded payload storage digests before NumPy preflight."""
246
+ for checkpoint in sorted(checkpoints, key=lambda value: value.entry):
247
+ if checkpoint.storage_digest is None:
248
+ continue
249
+ entry_bytes = archive.read_entry(checkpoint.entry)
250
+ observed_storage_digest = calculate_storage_digest(
251
+ checkpoint.entry,
252
+ entry_bytes,
253
+ )
254
+ if observed_storage_digest != checkpoint.storage_digest:
255
+ raise IntegrityMismatchError(
256
+ f"Payload '{checkpoint.entry}' has a storage digest mismatch: "
257
+ f"expected {checkpoint.storage_digest}; observed {observed_storage_digest}.",
258
+ layer="storage",
259
+ )
260
+
261
+
262
+ def _validate_integrity_source_digests(
263
+ archive: BoundedZipReader,
264
+ manifest: Manifest,
265
+ ) -> None:
266
+ """Verify every retained source digest over its captured bytes."""
267
+ for source in sorted(
268
+ manifest.sources.values(),
269
+ key=lambda value: str(value.entry or value.source_id or value.filename),
270
+ ):
271
+ if source.entry is None or source.digest is None:
272
+ continue
273
+ observed_source_digest = source_digest(archive.read_entry(source.entry))
274
+ if observed_source_digest != source.digest:
275
+ raise IntegrityMismatchError(
276
+ f"Source '{source.entry}' has a digest mismatch: expected "
277
+ f"{source.digest}; observed {observed_source_digest}.",
278
+ layer="source",
279
+ )
280
+
281
+
282
+ def _validate_integrity_entry_storage(
283
+ archive: BoundedZipReader,
284
+ inventory: Mapping[str, Any],
285
+ *,
286
+ entry_paths: Iterable[str] | None = None,
287
+ ) -> None:
288
+ """Validate storage digests for every physical archive entry."""
289
+ paths = archive.entry_paths if entry_paths is None else entry_paths
290
+ for entry_path in paths:
291
+ if entry_path == ArchiveEntry.INTEGRITY.value:
292
+ continue
293
+ descriptor = inventory.get(entry_path)
294
+ if not isinstance(descriptor, Mapping):
295
+ raise InvalidArchiveStructureError(f"Archive integrity inventory omits '{entry_path}'.")
296
+ expected_length = descriptor.get("stored_length")
297
+ if expected_length is None:
298
+ raise InvalidArchiveStructureError(
299
+ f"Archive integrity inventory entry '{entry_path}' has no stored length."
300
+ )
301
+ try:
302
+ decoded_length = decode_schema_int(expected_length, non_negative=True)
303
+ except CanonicalisationError as error:
304
+ raise InvalidArchiveStructureError(
305
+ f"Archive integrity inventory entry '{entry_path}' has an invalid stored length."
306
+ ) from error
307
+ actual_length = len(archive.read_entry(entry_path))
308
+ if actual_length != decoded_length:
309
+ raise IntegrityMismatchError(
310
+ f"Archive entry '{entry_path}' has a stored length mismatch: "
311
+ f"expected {decoded_length}; observed {actual_length}.",
312
+ layer="storage",
313
+ )
314
+ expected_storage = descriptor.get("storage_digest")
315
+ if (
316
+ entry_path in archive.entry_paths
317
+ and descriptor.get("kind")
318
+ in {
319
+ "fixed",
320
+ "source",
321
+ "payload",
322
+ "revision-log",
323
+ "environment-record",
324
+ "source-bundle",
325
+ }
326
+ and not isinstance(expected_storage, str)
327
+ ):
328
+ raise InvalidArchiveStructureError(
329
+ f"Archive integrity inventory entry '{entry_path}' has no storage digest."
330
+ )
331
+ if expected_storage is None:
332
+ continue
333
+ observed_storage = calculate_storage_digest(
334
+ entry_path,
335
+ archive.read_entry(entry_path),
336
+ )
337
+ if observed_storage != expected_storage:
338
+ raise IntegrityMismatchError(
339
+ f"Archive entry '{entry_path}' has a storage digest mismatch: "
340
+ f"expected {expected_storage}; observed {observed_storage}.",
341
+ layer="storage",
342
+ )
343
+
344
+
345
+ def _validate_integrity_record_digests(
346
+ archive: BoundedZipReader,
347
+ inventory: Mapping[str, Any],
348
+ integrity_data: Mapping[str, Any],
349
+ ) -> bool:
350
+ """Validate immutable record bodies and their digest index."""
351
+ records_document = _normalise_new_object_list(
352
+ archive.read_json(
353
+ ArchiveEntry.RECORDS,
354
+ typed_metadata_paths=_CANONICAL_OPERATION_PARAMETER_PATHS,
355
+ ),
356
+ "records",
357
+ "records",
358
+ )
359
+ record_bodies: dict[str, dict[str, Any]] = {}
360
+ complete_coverage = True
361
+ for descriptor in records_document:
362
+ record_id = descriptor.get("record_id")
363
+ if not isinstance(record_id, str):
364
+ raise InvalidArchiveStructureError("Archive records require a string record_id.")
365
+ body = record_body(descriptor)
366
+ record_bodies[record_id] = body
367
+ expected_digest = descriptor.get("record_digest")
368
+ observed_digest = record_digest(body)
369
+ if expected_digest != observed_digest:
370
+ raise IntegrityMismatchError(
371
+ f"Record '{record_id}' has a record digest mismatch: expected "
372
+ f"{expected_digest}; observed {observed_digest}.",
373
+ layer="record",
374
+ )
375
+ inventory_descriptor = inventory.get(f"records/{record_id}")
376
+ if not isinstance(inventory_descriptor, Mapping):
377
+ complete_coverage = False
378
+ continue
379
+ if inventory_descriptor.get("record_digest") != observed_digest:
380
+ raise IntegrityMismatchError(
381
+ f"Record '{record_id}' disagrees with its inventory digest.",
382
+ layer="record",
383
+ )
384
+ record_digests = integrity_data.get("record_digests")
385
+ expected_record_digests = {
386
+ record_id: record_digest(body) for record_id, body in sorted(record_bodies.items())
387
+ }
388
+ if not isinstance(record_digests, Mapping) or dict(record_digests) != expected_record_digests:
389
+ raise IntegrityMismatchError(
390
+ "Archive record digest index does not match records.json.",
391
+ layer="record",
392
+ )
393
+ return complete_coverage
394
+
395
+
396
+ def _validate_integrity_revision_roots(
397
+ documents: ArchiveDocuments,
398
+ integrity_data: Mapping[str, Any],
399
+ ) -> None:
400
+ """Validate revision snapshots, roots, head identity, and field bindings."""
401
+ revision_bodies = integrity_data.get("revision_bodies")
402
+ revision_roots = integrity_data.get("revision_roots")
403
+ if not isinstance(revision_bodies, Mapping) or not isinstance(
404
+ revision_roots,
405
+ Mapping,
406
+ ):
407
+ raise InvalidArchiveStructureError("Archive integrity revision roots are incomplete.")
408
+ revision_ids = [str(revision.revision_id) for revision in documents.revisions]
409
+ if set(revision_bodies) != set(revision_ids) or set(revision_roots) != set(revision_ids):
410
+ raise InvalidArchiveStructureError(
411
+ "Archive integrity revision snapshots do not cover revision history."
412
+ )
413
+ inventory = integrity_data.get("inventory")
414
+ revision_log = (
415
+ inventory.get(ArchiveEntry.REVISIONS.value) if isinstance(inventory, Mapping) else None
416
+ )
417
+ if not isinstance(revision_log, Mapping) or revision_log.get("revision_ids") != (revision_ids):
418
+ raise IntegrityMismatchError(
419
+ "Archive revision-log inventory does not match revision history.",
420
+ layer="revision",
421
+ )
422
+ for revision in documents.revisions:
423
+ revision_id = str(revision.revision_id)
424
+ body = revision_bodies.get(revision_id)
425
+ if not isinstance(body, Mapping):
426
+ raise InvalidArchiveStructureError(
427
+ f"Archive integrity omits revision snapshot '{revision_id}'."
428
+ )
429
+ expected_root = revision_digest(body)
430
+ stored_root = revision_roots.get(revision_id)
431
+ if stored_root != expected_root or revision.revision_root != expected_root:
432
+ raise IntegrityMismatchError(
433
+ f"Revision '{revision_id}' has a revision root mismatch: expected "
434
+ f"{expected_root}; observed {stored_root}.",
435
+ layer="revision",
436
+ )
437
+ _validate_revision_body_identity(revision, body)
438
+ head_revision_id = integrity_data.get("head_revision_id")
439
+ if head_revision_id != revision_ids[-1]:
440
+ raise InvalidArchiveStructureError(
441
+ "Archive integrity head revision does not match revision history."
442
+ )
443
+ manifest_body = integrity_data.get("manifest_body")
444
+ if manifest_body != revision_bodies[revision_ids[-1]]:
445
+ raise IntegrityMismatchError(
446
+ "Archive integrity manifest body does not match the head snapshot.",
447
+ layer="revision",
448
+ )
449
+ head_body = revision_bodies[revision_ids[-1]]
450
+ if head_body.get("inventory") != integrity_data.get("inventory"):
451
+ raise IntegrityMismatchError(
452
+ "Archive integrity inventory does not match the head snapshot.",
453
+ layer="revision",
454
+ )
455
+
456
+
457
+ def _validate_integrity_inventory_keys(
458
+ archive: BoundedZipReader,
459
+ manifest: Manifest,
460
+ documents: ArchiveDocuments,
461
+ integrity_data: Mapping[str, Any],
462
+ ) -> bool:
463
+ """Require one inventory item for each physical and logical archive item."""
464
+ inventory = integrity_data.get("inventory")
465
+ if not isinstance(inventory, Mapping):
466
+ raise InvalidArchiveStructureError("Archive integrity inventory is incomplete.")
467
+ expected = {
468
+ entry_path
469
+ for entry_path in archive.entry_paths
470
+ if entry_path != ArchiveEntry.INTEGRITY.value
471
+ }
472
+ record_ids = {str(record.record_id) for record in documents.checkpoints}
473
+ expected.update({f"records/{record_id}" for record_id in record_ids})
474
+ expected.update({f"provenance/{record_id}" for record_id in record_ids})
475
+ expected.update({f"revisions/{revision.revision_id}" for revision in documents.revisions})
476
+ expected.add(ArchiveEntry.REVISIONS.value)
477
+ revision_bodies = integrity_data.get("revision_bodies", {})
478
+ if isinstance(revision_bodies, Mapping):
479
+ for revision_id, body in revision_bodies.items():
480
+ if not isinstance(body, Mapping):
481
+ continue
482
+ selections = body.get("active_selections", {})
483
+ if isinstance(selections, Mapping):
484
+ expected.update(
485
+ f"active/{revision_id}/{checkpoint_id}" for checkpoint_id in selections
486
+ )
487
+ for source in manifest.sources.values():
488
+ source_id = source.source_id
489
+ if source_id is not None and source.entry is None:
490
+ expected.add(f"source-records/{source_id}")
491
+ expected.update(f"features/required/{feature}" for feature in manifest.required_features)
492
+ if set(inventory) != expected:
493
+ missing = sorted(expected - set(inventory))
494
+ extra = sorted(set(inventory) - expected)
495
+ physical_expected = {
496
+ entry_path for entry_path in expected if entry_path in archive.entry_paths
497
+ }
498
+ missing_physical = sorted(set(missing) & physical_expected)
499
+ if missing_physical or extra:
500
+ detail = f"missing={missing!r}, extra={extra!r}"
501
+ raise InvalidArchiveStructureError(
502
+ f"Archive integrity inventory is not closed: {detail}."
503
+ )
504
+ return False
505
+ return True
506
+
507
+
508
+ def _validate_new_metadata_documents(documents: ArchiveDocuments) -> None:
509
+ """Validate typed metadata before integrity checks consume the document."""
510
+ for fields in documents.metadata_by_run.values():
511
+ if not isinstance(fields, Mapping):
512
+ raise InvalidArchiveStructureError("Canonical metadata by run must be an object.")
513
+ for field_data in fields.values():
514
+ _deserialise_metadata_field(field_data)
515
+
516
+
517
+ def _validate_revision_body_identity(
518
+ revision: Revision,
519
+ body: Mapping[str, Any],
520
+ ) -> None:
521
+ """Validate the revision fields mirrored in its canonical snapshot."""
522
+ expected_fields = {
523
+ "revision_id": str(revision.revision_id),
524
+ "archive_id": str(revision.archive_id),
525
+ "parent_revision_id": (
526
+ str(revision.parent_revision_id) if revision.parent_revision_id is not None else None
527
+ ),
528
+ "parent_root": revision.parent_root,
529
+ "active_selections": dict(revision.active_selections),
530
+ "events": [dict(event) for event in revision.events],
531
+ "created_at": revision.created_at.isoformat(),
532
+ }
533
+ for field_name, expected in expected_fields.items():
534
+ if body.get(field_name) != expected:
535
+ raise IntegrityMismatchError(
536
+ f"Revision '{revision.revision_id}' body field '{field_name}' "
537
+ "does not match the revision log.",
538
+ layer="revision",
539
+ )