aind-metadata-reader 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.
@@ -0,0 +1,478 @@
1
+ """Explicit, replayable field changes with source-qualified derivation identities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Iterable
7
+ from dataclasses import dataclass, field, fields, replace
8
+ from datetime import datetime
9
+ from importlib.metadata import version
10
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar
11
+
12
+ from aind_metadata_reader._wire import canonical, digest, object_fields, parse_json, string, string_array, strings
13
+ from aind_metadata_reader.diagnostics import FieldState, MetadataError
14
+ from aind_metadata_reader.document import Document, Snapshot, SnapshotReference, Source, _segments
15
+ from aind_metadata_reader.formats import ReaderRecord
16
+
17
+ if TYPE_CHECKING:
18
+ from aind_metadata_reader.applicability import RuleEvaluation
19
+
20
+ _Record = TypeVar("_Record")
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Bundle:
25
+ """Named input snapshots with attributed consistency evidence, never inferred authority."""
26
+
27
+ entries: tuple[tuple[str, Document], ...]
28
+ consistency: Literal["unknown", "asserted", "established"] = "unknown"
29
+ evidence: tuple[str, ...] = ()
30
+
31
+ def __post_init__(self) -> None:
32
+ """Require unique names and explicit evidence for stronger consistency claims."""
33
+ object.__setattr__(self, "entries", tuple(self.entries))
34
+ object.__setattr__(self, "evidence", strings(self.evidence, code="invalid_bundle"))
35
+ if len({name for name, _ in self.entries}) != len(self.entries):
36
+ raise MetadataError("Bundle names must be unique", code="ambiguous")
37
+ if self.consistency not in ("unknown", "asserted", "established"):
38
+ raise MetadataError("Invalid bundle consistency", code="invalid_bundle")
39
+ if self.consistency != "unknown" and not self.evidence:
40
+ raise MetadataError("Bundle consistency needs explicit evidence", code="missing_evidence")
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Change:
45
+ """An attributed replacement, bound to an exact input and optional supporting inputs."""
46
+
47
+ operation_id: str
48
+ rule_id: str
49
+ rule_revision: str
50
+ implementation: str
51
+ kind: Literal["correction", "inference", "processing_frame"]
52
+ source_digest: str
53
+ pointer: str
54
+ before_state: str
55
+ before_json: str
56
+ after_json: str
57
+ author: str
58
+ reason: str
59
+ evidence: tuple[str, ...]
60
+ scope: str
61
+ assumptions: tuple[str, ...] = ()
62
+ source: Source = field(default_factory=Source)
63
+ target: SnapshotReference | None = None
64
+ references: tuple[SnapshotReference, ...] = ()
65
+ readers: tuple[ReaderRecord, ...] = ()
66
+
67
+ def __post_init__(self) -> None:
68
+ """Detach mutable caller sequences without weakening application-time checks."""
69
+ object.__setattr__(self, "evidence", strings(self.evidence, code="invalid_change"))
70
+ object.__setattr__(self, "assumptions", strings(self.assumptions, code="invalid_change"))
71
+ object.__setattr__(self, "references", tuple(self.references))
72
+ object.__setattr__(self, "readers", tuple(self.readers))
73
+
74
+ @classmethod
75
+ def propose(
76
+ cls,
77
+ document: Document,
78
+ pointer: str,
79
+ value: Any,
80
+ *,
81
+ operation_id: str,
82
+ rule_id: str,
83
+ rule_revision: str,
84
+ implementation: str,
85
+ kind: Literal["correction", "inference", "processing_frame"],
86
+ author: str,
87
+ reason: str,
88
+ evidence: tuple[str, ...],
89
+ scope: str,
90
+ assumptions: tuple[str, ...] = (),
91
+ references: tuple[SnapshotReference, ...] = (),
92
+ readers: tuple[ReaderRecord, ...] = (),
93
+ ) -> Change:
94
+ """Capture source preconditions without modifying the recorded snapshot."""
95
+ before = document.field(pointer)
96
+ replacement = Document({"value": value}).raw["value"]
97
+ return cls(
98
+ operation_id,
99
+ rule_id,
100
+ rule_revision,
101
+ implementation,
102
+ kind,
103
+ document.snapshot.content_digest,
104
+ before.pointer,
105
+ before.state.value,
106
+ json.dumps(before.value, allow_nan=False),
107
+ json.dumps(replacement, allow_nan=False),
108
+ author,
109
+ reason,
110
+ evidence,
111
+ scope,
112
+ assumptions,
113
+ document.snapshot.source,
114
+ replace(document.reference(), pointer=before.pointer),
115
+ references,
116
+ readers,
117
+ )
118
+
119
+ def record(self, *, legacy: bool = False) -> dict[str, Any]:
120
+ """Serialize replacements as JSON text while retaining absent/null preconditions."""
121
+ result = {
122
+ f.name: getattr(self, f.name)
123
+ for f in fields(self)
124
+ if f.name not in ("source", "target", "references", "readers")
125
+ }
126
+ result["evidence"] = list(self.evidence)
127
+ result["assumptions"] = list(self.assumptions)
128
+ result["source"] = self.source.record(legacy=legacy)
129
+ if not legacy:
130
+ result.update(
131
+ target=None if self.target is None else self.target.record(),
132
+ references=[ref.record() for ref in self.references],
133
+ readers=[reader.record() for reader in self.readers],
134
+ )
135
+ return result
136
+
137
+ @classmethod
138
+ def from_record(cls, value: Any, *, legacy_document: Document | None = None, legacy: bool = False) -> Change:
139
+ """Restore a proposal strictly; v1 targets derive from verified manifest inputs."""
140
+ names = {f.name for f in fields(cls)}
141
+ legacy = legacy or legacy_document is not None
142
+ data = dict(object_fields(value, names - {"target", "references", "readers"} if legacy else names))
143
+ data["evidence"] = string_array(data["evidence"])
144
+ data["assumptions"] = string_array(data["assumptions"])
145
+ data["source"] = Source.from_record(data["source"], legacy=legacy)
146
+ string(data["pointer"], empty=True)
147
+ if legacy_document is not None:
148
+ data["target"] = replace(legacy_document.reference(), pointer=data["pointer"])
149
+ elif not legacy:
150
+ data["target"] = SnapshotReference.from_record(data["target"])
151
+ data["references"] = _records(data["references"], SnapshotReference.from_record)
152
+ data["readers"] = _records(data["readers"], ReaderRecord.from_record)
153
+ change = cls(**data)
154
+ _validate(change, allow_unbound=legacy and legacy_document is None)
155
+ return change
156
+
157
+
158
+ def _records(value: Any, factory: Callable[[Any], _Record]) -> tuple[_Record, ...]:
159
+ if not isinstance(value, list):
160
+ raise MetadataError("Expected a saved-record array", code="invalid_manifest")
161
+ return tuple(factory(item) for item in value)
162
+
163
+
164
+ def _application_record(
165
+ document: Document,
166
+ changes: tuple[Change, ...],
167
+ evaluations: tuple[RuleEvaluation, ...],
168
+ applied_at: str,
169
+ writer_version: str,
170
+ ) -> dict[str, Any]:
171
+ return {
172
+ "format": "metadata-interpretation/2",
173
+ "input": document.reference().record(),
174
+ "changes": [c.record() for c in changes],
175
+ "evaluations": [e.record() for e in evaluations],
176
+ "applied_at": applied_at,
177
+ "writer_version": writer_version,
178
+ }
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class Interpretation:
183
+ """Original and derived views, with linked decisions and applied operations."""
184
+
185
+ original: Document
186
+ derived: Document
187
+ changes: tuple[Change, ...]
188
+ changed: tuple[bool, ...]
189
+ applied_at: str
190
+ evaluations: tuple[RuleEvaluation, ...] = ()
191
+ writer_version: str = "unknown"
192
+
193
+ @property
194
+ def interpretation_id(self) -> str:
195
+ """Bind the derivation to its inputs, decisions, operations, time, and writer."""
196
+ return digest(
197
+ "metadata-interpretation-v1",
198
+ _application_record(self.original, self.changes, self.evaluations, self.applied_at, self.writer_version),
199
+ )
200
+
201
+ def manifest(self) -> dict[str, Any]:
202
+ """Export an independently versioned application record, not a source-schema file."""
203
+ record = _application_record(
204
+ self.original, self.changes, self.evaluations, self.applied_at, self.writer_version
205
+ )
206
+ links = {
207
+ change.operation_id: evaluation.evaluation_id
208
+ for evaluation in self.evaluations
209
+ for change in evaluation.changes
210
+ if change in self.changes
211
+ }
212
+ return {
213
+ **record,
214
+ "interpretation_id": self.interpretation_id,
215
+ "changed": list(self.changed),
216
+ "result_digest": self.derived.snapshot.content_digest,
217
+ "result_snapshot_id": self.derived.snapshot.snapshot_id,
218
+ "applied_operations": links,
219
+ }
220
+
221
+
222
+ def _validate(change: Change, *, allow_unbound: bool = False) -> None:
223
+ for value in (
224
+ change.operation_id,
225
+ change.rule_id,
226
+ change.rule_revision,
227
+ change.implementation,
228
+ change.author,
229
+ change.reason,
230
+ change.scope,
231
+ change.source_digest,
232
+ ):
233
+ string(value, code="invalid_change")
234
+ if not change.evidence:
235
+ raise MetadataError("A change needs attributed evidence", code="missing_evidence")
236
+ if change.kind not in ("correction", "inference", "processing_frame"):
237
+ raise MetadataError("Unknown change kind", code="invalid_change")
238
+ if change.kind == "inference" and not change.assumptions:
239
+ raise MetadataError("An inference must record its assumptions", code="missing_assumptions")
240
+ if change.before_state not in tuple(state.value for state in FieldState):
241
+ raise MetadataError("Invalid source field state", code="invalid_change")
242
+ if not _segments(change.pointer):
243
+ raise MetadataError("Whole-document replacement is not a field correction", code="invalid_change")
244
+ if not (allow_unbound and change.target is None) and (
245
+ not isinstance(change.target, SnapshotReference)
246
+ or (
247
+ change.target.source != change.source
248
+ or change.target.content_digest != change.source_digest
249
+ or change.target.pointer != change.pointer
250
+ )
251
+ ):
252
+ raise MetadataError("Proposal must identify its exact target snapshot and field", code="invalid_change")
253
+ if any(not isinstance(ref, SnapshotReference) for ref in change.references):
254
+ raise MetadataError("Expected structured evidence references", code="invalid_change")
255
+ if any(
256
+ not isinstance(reader, ReaderRecord) or reader.document not in change.references for reader in change.readers
257
+ ):
258
+ raise MetadataError("Reader provenance must identify a referenced input", code="invalid_change")
259
+ parse_json(change.before_json)
260
+ parse_json(change.after_json)
261
+
262
+
263
+ def _replace(data: dict[str, Any], pointer: str, value: Any) -> None:
264
+ segments = _segments(pointer)
265
+ parent: Any = data
266
+ for segment in segments[:-1]:
267
+ if isinstance(parent, dict) and segment in parent:
268
+ parent = parent[segment]
269
+ elif (
270
+ isinstance(parent, list)
271
+ and segment.isascii()
272
+ and segment.isdigit()
273
+ and str(int(segment)) == segment
274
+ and int(segment) < len(parent)
275
+ ):
276
+ parent = parent[int(segment)]
277
+ else:
278
+ raise MetadataError("Correction parent is absent", code="absent", pointer=pointer)
279
+ leaf = segments[-1]
280
+ if isinstance(parent, dict):
281
+ parent[leaf] = value
282
+ elif (
283
+ isinstance(parent, list)
284
+ and leaf.isascii()
285
+ and leaf.isdigit()
286
+ and str(int(leaf)) == leaf
287
+ and int(leaf) < len(parent)
288
+ ):
289
+ parent[int(leaf)] = value
290
+ else:
291
+ raise MetadataError(
292
+ "Cannot replace this field without inferring parent structure", code="invalid_change", pointer=pointer
293
+ )
294
+
295
+
296
+ def _apply_operations(document: Document, operations: tuple[Change, ...]) -> tuple[dict[str, Any], tuple[bool, ...]]:
297
+ data = document.snapshot.raw
298
+ ids: set[str] = set()
299
+ targets: list[str] = []
300
+ changed = []
301
+ for change in operations:
302
+ _validate(change)
303
+ if change.source_digest != document.snapshot.content_digest:
304
+ raise MetadataError("Change targets another snapshot revision", code="stale_source", pointer=change.pointer)
305
+ if change.source != document.snapshot.source:
306
+ raise MetadataError(
307
+ "Change targets another source identity", code="conflicting_source", pointer=change.pointer
308
+ )
309
+ if change.target is None or change.target.snapshot_id != document.snapshot.snapshot_id:
310
+ raise MetadataError("Change targets another snapshot capture", code="stale_source", pointer=change.pointer)
311
+ if (
312
+ document.pointer
313
+ and change.pointer != document.pointer
314
+ and not change.pointer.startswith(document.pointer + "/")
315
+ ):
316
+ raise MetadataError("Change is outside the selected document", code="outside_focus", pointer=change.pointer)
317
+ if change.operation_id in ids or any(
318
+ change.pointer == p or change.pointer.startswith(p + "/") or p.startswith(change.pointer + "/")
319
+ for p in targets
320
+ ):
321
+ raise MetadataError(
322
+ "Conflicting changes require explicit resolution", code="conflicting_changes", pointer=change.pointer
323
+ )
324
+ before = parse_json(change.before_json)
325
+ after = parse_json(change.after_json)
326
+ original = document.snapshot.field(change.pointer)
327
+ if original.state != change.before_state or canonical(original.value) != canonical(before):
328
+ raise MetadataError(
329
+ "Source value precondition does not match", code="stale_precondition", pointer=change.pointer
330
+ )
331
+ _replace(data, change.pointer, after)
332
+ changed.append(original.state == FieldState.ABSENT or canonical(before) != canonical(after))
333
+ ids.add(change.operation_id)
334
+ targets.append(change.pointer)
335
+ return data, tuple(changed)
336
+
337
+
338
+ def _check_evaluations(
339
+ document: Document, operations: tuple[Change, ...], evaluations: tuple[RuleEvaluation, ...]
340
+ ) -> None:
341
+ seen: set[str] = set()
342
+ proposals: dict[str, Change] = {}
343
+ for evaluation in evaluations:
344
+ if evaluation.evaluation_id in seen:
345
+ raise MetadataError("Repeated evaluation", code="conflicting_evaluations")
346
+ seen.add(evaluation.evaluation_id)
347
+ if not any(
348
+ ref.snapshot_id == document.snapshot.snapshot_id and ref.pointer == document.pointer
349
+ for ref in evaluation.inputs
350
+ ):
351
+ raise MetadataError("Evaluation does not identify this input view", code="conflicting_evaluations")
352
+ for change in evaluation.changes:
353
+ if change.operation_id in proposals:
354
+ raise MetadataError("Evaluations have ambiguous operation identities", code="conflicting_evaluations")
355
+ proposals[change.operation_id] = change
356
+ for operation in operations:
357
+ if operation.operation_id in proposals and operation != proposals[operation.operation_id]:
358
+ raise MetadataError("Applied operation differs from evaluated proposal", code="conflicting_evaluations")
359
+
360
+
361
+ def _apply(
362
+ document: Document,
363
+ operations: tuple[Change, ...],
364
+ evaluations: tuple[RuleEvaluation, ...],
365
+ applied_at: str,
366
+ writer_version: str,
367
+ ) -> Interpretation:
368
+ try:
369
+ # Python 3.10 does not accept the UTC Z suffix directly. Preserve the
370
+ # recorded spelling in manifests while checking the equivalent offset.
371
+ parsed_time = (
372
+ applied_at[:-1] + "+00:00" if isinstance(applied_at, str) and applied_at.endswith("Z") else applied_at
373
+ )
374
+ timestamp = datetime.fromisoformat(parsed_time)
375
+ if timestamp.utcoffset() is None:
376
+ raise ValueError("Timezone required")
377
+ except (ValueError, TypeError) as error:
378
+ raise MetadataError("Record an ISO application time with timezone", code="invalid_timestamp") from error
379
+ string(writer_version)
380
+ _check_evaluations(document, operations, evaluations)
381
+ data, changed = _apply_operations(document, operations)
382
+ identity = digest(
383
+ "metadata-interpretation-v1", _application_record(document, operations, evaluations, applied_at, writer_version)
384
+ )
385
+ source = Source(derived_from=(document.snapshot.snapshot_id,), interpretation_id=identity)
386
+ derived = Document.from_snapshot(Snapshot.from_mapping(data, source), document.pointer)
387
+ return Interpretation(document, derived, operations, changed, applied_at, evaluations, writer_version)
388
+
389
+
390
+ def apply_changes(
391
+ document: Document, changes: Iterable[Change], *, applied_at: str, evaluations: Iterable[RuleEvaluation] = ()
392
+ ) -> Interpretation:
393
+ """Apply explicit proposals within this view, retaining any supplied rule decisions.
394
+
395
+ Supply evaluations even for unknown and negative decisions to retain their
396
+ evidence. Applied operations are linked to matching evaluated proposals;
397
+ proposed operations may be deliberately left unapplied.
398
+ """
399
+ return _apply(document, tuple(changes), tuple(evaluations), applied_at, version("aind-metadata-reader"))
400
+
401
+
402
+ def _check_result(result: Interpretation, manifest: dict[str, Any], *, legacy: bool = False) -> None:
403
+ flags = manifest["changed"]
404
+ if not isinstance(flags, list) or any(type(flag) is not bool for flag in flags):
405
+ raise MetadataError("Changed flags must be booleans", code="invalid_manifest")
406
+ if list(result.changed) != flags or result.derived.snapshot.content_digest != manifest["result_digest"]:
407
+ raise MetadataError("Manifest result does not match recorded output", code="manifest_mismatch")
408
+ if not legacy and result.manifest() != manifest:
409
+ raise MetadataError("Manifest identities or operation links do not match", code="manifest_mismatch")
410
+
411
+
412
+ def _replay_v1(document: Document, manifest: dict[str, Any]) -> Interpretation:
413
+ object_fields(
414
+ manifest,
415
+ {
416
+ "format",
417
+ "source_digest",
418
+ "focus_pointer",
419
+ "source",
420
+ "original_digest",
421
+ "changes",
422
+ "changed",
423
+ "result_digest",
424
+ "applied_at",
425
+ },
426
+ )
427
+ if (
428
+ manifest["source_digest"] != document.snapshot.content_digest
429
+ or manifest["focus_pointer"] != document.pointer
430
+ or manifest["original_digest"] != document.snapshot.original_digest
431
+ ):
432
+ raise MetadataError("Manifest targets another source or object", code="stale_source")
433
+ if Source.from_record(manifest["source"], legacy=True) != document.snapshot.source:
434
+ raise MetadataError("Manifest source identity does not match", code="conflicting_source")
435
+ operations = _records(manifest["changes"], lambda item: Change.from_record(item, legacy_document=document))
436
+ result = apply_changes(document, operations, applied_at=manifest["applied_at"])
437
+ _check_result(result, manifest, legacy=True)
438
+ return result
439
+
440
+
441
+ def replay(document: Document, manifest: dict[str, Any]) -> Interpretation:
442
+ """Apply recorded replacements to exact archived inputs; never rerun reader/rule code.
443
+
444
+ Historical v1 records are read explicitly, not relabeled as v2 inputs. Their
445
+ verified target is bound to the current source-qualified identity on replay;
446
+ newly exported results use v2 and do not invent historical reader provenance.
447
+ """
448
+ from aind_metadata_reader.applicability import RuleEvaluation
449
+
450
+ if not isinstance(manifest, dict):
451
+ raise MetadataError("Expected an interpretation manifest object", code="invalid_manifest")
452
+ if manifest.get("format") == "metadata-interpretation/1":
453
+ return _replay_v1(document, manifest)
454
+ if manifest.get("format") != "metadata-interpretation/2":
455
+ raise MetadataError("Unsupported interpretation manifest", code="unsupported_manifest")
456
+ object_fields(
457
+ manifest,
458
+ {
459
+ "format",
460
+ "input",
461
+ "changes",
462
+ "evaluations",
463
+ "applied_at",
464
+ "writer_version",
465
+ "interpretation_id",
466
+ "changed",
467
+ "result_digest",
468
+ "result_snapshot_id",
469
+ "applied_operations",
470
+ },
471
+ )
472
+ if SnapshotReference.from_record(manifest["input"]) != document.reference():
473
+ raise MetadataError("Manifest targets another snapshot or view", code="stale_source")
474
+ operations = _records(manifest["changes"], Change.from_record)
475
+ evaluations = _records(manifest["evaluations"], RuleEvaluation.from_record)
476
+ result = _apply(document, operations, evaluations, manifest["applied_at"], manifest["writer_version"])
477
+ _check_result(result, manifest)
478
+ return result
@@ -0,0 +1 @@
1
+