xtalate 0.1.0.dev0__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.
- xtalate/__init__.py +10 -0
- xtalate/capabilities/__init__.py +19 -0
- xtalate/capabilities/registry.py +126 -0
- xtalate/cli/__init__.py +11 -0
- xtalate/cli/main.py +431 -0
- xtalate/cli/render.py +126 -0
- xtalate/conversion/__init__.py +48 -0
- xtalate/conversion/engine.py +560 -0
- xtalate/conversion/preflight.py +127 -0
- xtalate/conversion/report.py +74 -0
- xtalate/discovery/__init__.py +28 -0
- xtalate/discovery/engine.py +202 -0
- xtalate/discovery/report.py +50 -0
- xtalate/discovery/sniffer.py +95 -0
- xtalate/exporters/__init__.py +34 -0
- xtalate/exporters/extxyz.py +140 -0
- xtalate/exporters/poscar.py +180 -0
- xtalate/exporters/xyz.py +67 -0
- xtalate/parsers/__init__.py +31 -0
- xtalate/parsers/_common.py +57 -0
- xtalate/parsers/extxyz.py +452 -0
- xtalate/parsers/poscar.py +428 -0
- xtalate/parsers/xyz.py +286 -0
- xtalate/py.typed +0 -0
- xtalate/recovery/__init__.py +41 -0
- xtalate/recovery/engine.py +321 -0
- xtalate/recovery/scenarios.py +86 -0
- xtalate/registry.py +28 -0
- xtalate/schema/__init__.py +40 -0
- xtalate/schema/arrays.py +115 -0
- xtalate/schema/elements.py +150 -0
- xtalate/schema/models.py +283 -0
- xtalate/schema/paths.py +88 -0
- xtalate/schema/presence.py +152 -0
- xtalate/sdk/__init__.py +27 -0
- xtalate/sdk/capabilities.py +57 -0
- xtalate/sdk/plugins.py +72 -0
- xtalate/sdk/results.py +45 -0
- xtalate/validation/__init__.py +25 -0
- xtalate/validation/engine.py +585 -0
- xtalate/validation/report.py +48 -0
- xtalate/validation/rethreshold.py +122 -0
- xtalate/validation/tolerance.py +125 -0
- xtalate-0.1.0.dev0.dist-info/METADATA +161 -0
- xtalate-0.1.0.dev0.dist-info/RECORD +49 -0
- xtalate-0.1.0.dev0.dist-info/WHEEL +4 -0
- xtalate-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- xtalate-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- xtalate-0.1.0.dev0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""The Conversion Engine (MASTER_SPEC Part 4 §1–2).
|
|
2
|
+
|
|
3
|
+
Orchestrates a single conversion: pre-flight diff → Recovery Engine → `write_plan` export →
|
|
4
|
+
Conversion Report → Validation Engine, with the completeness invariant asserted at finalization
|
|
5
|
+
(review §4.5). It delegates every format decision to the parsers/exporters via their
|
|
6
|
+
`capabilities()` declarations — there is no per-(source, target) logic here (Part 3 §4.3, the
|
|
7
|
+
O(n) design) — and every *recovery* decision to the Recovery Engine (Part 4 §3), mapping that
|
|
8
|
+
engine's plain result types onto the report's `Assumption`/`SuppliedEntry`/`RemovedEntry`.
|
|
9
|
+
|
|
10
|
+
**`write_plan` discipline (Part 4 §1 rules 1–4).** The engine does not pass a side-channel
|
|
11
|
+
list to the exporter; it *materializes* the plan as a filtered Canonical Object — the
|
|
12
|
+
`canonical′` of the sequence diagram — in which every field the plan excludes is set to
|
|
13
|
+
`None`. Handed that object, an exporter honoring the absence convention (it "never fabricates
|
|
14
|
+
values for absent fields", rule 2) writes exactly the plan and nothing more. `canonical′` is the
|
|
15
|
+
precise *expected object* the Validation Engine diffs the re-parsed output against (Part 5 §1).
|
|
16
|
+
|
|
17
|
+
**Recovery and refusal (Part 4 §3–4).** A conversion whose pre-flight diff detects a scenario
|
|
18
|
+
(target-required field absent, or `frame_count > max_frames`) is routed through the Recovery
|
|
19
|
+
Engine with the caller's presets. If a needed choice is missing the conversion is *refused* — a
|
|
20
|
+
completed outcome with `status="refused"`, not an error. Strict mode additionally refuses on
|
|
21
|
+
unacknowledged bulk loss or parse warnings (Part 4 §4).
|
|
22
|
+
|
|
23
|
+
**Validation (Part 5).** Every completed conversion is validated as an unconditional final step;
|
|
24
|
+
the resulting `ValidationReport` rides on `ConversionResult.validation`. There is no switch to
|
|
25
|
+
skip it — an unvalidated conversion is exactly the artifact this project exists to abolish.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from datetime import UTC, datetime
|
|
33
|
+
from io import BytesIO
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from xtalate import __version__
|
|
37
|
+
from xtalate.capabilities import Registry
|
|
38
|
+
from xtalate.conversion.preflight import PreflightDiff, build_preflight
|
|
39
|
+
from xtalate.conversion.report import (
|
|
40
|
+
Assumption,
|
|
41
|
+
ConversionReport,
|
|
42
|
+
RemovedEntry,
|
|
43
|
+
SuppliedEntry,
|
|
44
|
+
)
|
|
45
|
+
from xtalate.recovery import RecoveryEngine
|
|
46
|
+
from xtalate.schema import (
|
|
47
|
+
AtomsBlock,
|
|
48
|
+
CanonicalObject,
|
|
49
|
+
Cell,
|
|
50
|
+
ConversionRecord,
|
|
51
|
+
Dynamics,
|
|
52
|
+
Electronic,
|
|
53
|
+
Frame,
|
|
54
|
+
SimulationMetadata,
|
|
55
|
+
TrajectoryMetadata,
|
|
56
|
+
UserMetadata,
|
|
57
|
+
)
|
|
58
|
+
from xtalate.sdk import ParseIssue
|
|
59
|
+
from xtalate.validation import ToleranceProfile, ValidationEngine, ValidationReport
|
|
60
|
+
|
|
61
|
+
_SIMULATION_FIELDS = (
|
|
62
|
+
"source_code",
|
|
63
|
+
"calculator",
|
|
64
|
+
"xc_functional",
|
|
65
|
+
"pseudopotentials",
|
|
66
|
+
"thermostat",
|
|
67
|
+
"md_ensemble",
|
|
68
|
+
"temperature",
|
|
69
|
+
"extra",
|
|
70
|
+
)
|
|
71
|
+
_DERIVED_PATHS = frozenset({"atoms.atomic_numbers"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CompletenessInvariantError(AssertionError):
|
|
75
|
+
"""The final/refused report failed the completeness invariant (Part 4 §2) — a source-present
|
|
76
|
+
path unaccounted for (silent loss, P1) or a supplied path that was present on the source
|
|
77
|
+
(silent fabrication, P4). Never legitimate: raised always, in dev and in production."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class ConversionResult:
|
|
82
|
+
"""Everything a caller (CLI, API, validation) needs from one conversion."""
|
|
83
|
+
|
|
84
|
+
report: ConversionReport
|
|
85
|
+
output: bytes | None # None iff refused.
|
|
86
|
+
# The write_plan-filtered object handed to the exporter — the Validation Engine's expected
|
|
87
|
+
# object (Part 5 §1). None iff refused.
|
|
88
|
+
canonical_out: CanonicalObject | None
|
|
89
|
+
# Exactly one ValidationReport per completed conversion (Part 5 §3); None iff refused (a
|
|
90
|
+
# refused conversion produces no output file and therefore nothing to validate).
|
|
91
|
+
validation: ValidationReport | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class ConversionEngine:
|
|
95
|
+
def __init__(self, registry: Registry) -> None:
|
|
96
|
+
self._registry = registry
|
|
97
|
+
self._recovery = RecoveryEngine()
|
|
98
|
+
self._validation = ValidationEngine(registry)
|
|
99
|
+
|
|
100
|
+
def preflight(
|
|
101
|
+
self,
|
|
102
|
+
source: CanonicalObject,
|
|
103
|
+
*,
|
|
104
|
+
source_format_id: str,
|
|
105
|
+
target_format_id: str,
|
|
106
|
+
source_filename: str | None = None,
|
|
107
|
+
source_sha256: str | None = None,
|
|
108
|
+
target_filename: str | None = None,
|
|
109
|
+
mode: str = "permissive",
|
|
110
|
+
) -> ConversionReport:
|
|
111
|
+
"""The draft Conversion Report shown before conversion runs (Part 3 §4.3)."""
|
|
112
|
+
matrix = self._registry.capability_matrix()
|
|
113
|
+
diff = build_preflight(source, matrix, target_format_id)
|
|
114
|
+
status = "awaiting_recovery" if diff.unresolved else "completed"
|
|
115
|
+
report = self._assemble(
|
|
116
|
+
stage="preflight",
|
|
117
|
+
status=status,
|
|
118
|
+
mode=mode,
|
|
119
|
+
source=source,
|
|
120
|
+
source_format_id=source_format_id,
|
|
121
|
+
source_filename=source_filename,
|
|
122
|
+
source_sha256=source_sha256,
|
|
123
|
+
target_format_id=target_format_id,
|
|
124
|
+
target_filename=target_filename,
|
|
125
|
+
preserved=diff.preserved,
|
|
126
|
+
removed=diff.removed,
|
|
127
|
+
warnings=diff.warnings,
|
|
128
|
+
)
|
|
129
|
+
_assert_completeness(report, source)
|
|
130
|
+
return report
|
|
131
|
+
|
|
132
|
+
def convert(
|
|
133
|
+
self,
|
|
134
|
+
source: CanonicalObject,
|
|
135
|
+
*,
|
|
136
|
+
source_format_id: str,
|
|
137
|
+
target_format_id: str,
|
|
138
|
+
source_filename: str | None = None,
|
|
139
|
+
source_sha256: str | None = None,
|
|
140
|
+
target_filename: str | None = None,
|
|
141
|
+
mode: str = "permissive",
|
|
142
|
+
recovery_choices: dict[str, dict[str, Any]] | None = None,
|
|
143
|
+
parse_issues: list[ParseIssue] | None = None,
|
|
144
|
+
acknowledge_loss: bool = False,
|
|
145
|
+
acknowledge_parse_warnings: bool = False,
|
|
146
|
+
tolerance_profile: str = "default",
|
|
147
|
+
) -> ConversionResult:
|
|
148
|
+
"""Run the conversion end to end and produce the final report (Part 4 §1)."""
|
|
149
|
+
recovery_choices = recovery_choices or {}
|
|
150
|
+
parse_issues = parse_issues or []
|
|
151
|
+
matrix = self._registry.capability_matrix()
|
|
152
|
+
diff = build_preflight(source, matrix, target_format_id)
|
|
153
|
+
|
|
154
|
+
# --- Recovery (Part 4 §3) --------------------------------------------------------
|
|
155
|
+
recovered = source
|
|
156
|
+
assumptions: list[Assumption] = []
|
|
157
|
+
supplied: list[SuppliedEntry] = []
|
|
158
|
+
recovery_removed: list[RemovedEntry] = []
|
|
159
|
+
write_plan = set(diff.write_plan)
|
|
160
|
+
|
|
161
|
+
if diff.unresolved:
|
|
162
|
+
outcome = self._recovery.resolve(source, diff.unresolved, recovery_choices)
|
|
163
|
+
if outcome.canonical is None:
|
|
164
|
+
return self._refuse(
|
|
165
|
+
source=source,
|
|
166
|
+
source_format_id=source_format_id,
|
|
167
|
+
source_filename=source_filename,
|
|
168
|
+
source_sha256=source_sha256,
|
|
169
|
+
target_format_id=target_format_id,
|
|
170
|
+
target_filename=target_filename,
|
|
171
|
+
mode=mode,
|
|
172
|
+
diff=diff,
|
|
173
|
+
refusal={
|
|
174
|
+
"code": "RECOVERY_REQUIRED",
|
|
175
|
+
"message": "conversion needs recovery decisions that were not supplied; "
|
|
176
|
+
"provide them as recovery_choices presets, or choose a target that does "
|
|
177
|
+
"not require the missing fields",
|
|
178
|
+
"unresolved_scenarios": [
|
|
179
|
+
{"scenario": s.scenario, "path": s.path, "detail": s.detail}
|
|
180
|
+
for s in outcome.unresolved
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
)
|
|
184
|
+
recovered = outcome.canonical
|
|
185
|
+
for applied in outcome.assumptions:
|
|
186
|
+
assumptions.append(
|
|
187
|
+
Assumption(
|
|
188
|
+
id=applied.id,
|
|
189
|
+
scenario=applied.scenario,
|
|
190
|
+
choice=applied.choice,
|
|
191
|
+
parameters=applied.parameters,
|
|
192
|
+
origin=applied.origin, # type: ignore[arg-type]
|
|
193
|
+
description=applied.description,
|
|
194
|
+
)
|
|
195
|
+
)
|
|
196
|
+
for sup in applied.supplied:
|
|
197
|
+
supplied.append(
|
|
198
|
+
SuppliedEntry(path=sup.path, from_assumption=applied.id, detail=sup.detail)
|
|
199
|
+
)
|
|
200
|
+
write_plan.add(sup.path) # a fabricated field must be in the write_plan.
|
|
201
|
+
for drop in applied.removed:
|
|
202
|
+
recovery_removed.append(
|
|
203
|
+
RemovedEntry(path=drop.path, reason=drop.reason, detail=drop.detail)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
removed = [*diff.removed, *recovery_removed]
|
|
207
|
+
|
|
208
|
+
# --- Strict-mode gating (Part 4 §4) ----------------------------------------------
|
|
209
|
+
if mode == "strict":
|
|
210
|
+
if removed and not acknowledge_loss:
|
|
211
|
+
return self._refuse(
|
|
212
|
+
source=source,
|
|
213
|
+
source_format_id=source_format_id,
|
|
214
|
+
source_filename=source_filename,
|
|
215
|
+
source_sha256=source_sha256,
|
|
216
|
+
target_format_id=target_format_id,
|
|
217
|
+
target_filename=target_filename,
|
|
218
|
+
mode=mode,
|
|
219
|
+
diff=diff,
|
|
220
|
+
removed=removed,
|
|
221
|
+
supplied=supplied,
|
|
222
|
+
assumptions=assumptions,
|
|
223
|
+
refusal={
|
|
224
|
+
"code": "UNACKNOWLEDGED_LOSS",
|
|
225
|
+
"message": "strict mode: reductive loss must be acknowledged "
|
|
226
|
+
"(acknowledge_loss=True) before this conversion will proceed",
|
|
227
|
+
"unresolved_scenarios": [],
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
parse_warnings = [i for i in parse_issues if i.severity == "warning"]
|
|
231
|
+
if parse_warnings and not acknowledge_parse_warnings:
|
|
232
|
+
return self._refuse(
|
|
233
|
+
source=source,
|
|
234
|
+
source_format_id=source_format_id,
|
|
235
|
+
source_filename=source_filename,
|
|
236
|
+
source_sha256=source_sha256,
|
|
237
|
+
target_format_id=target_format_id,
|
|
238
|
+
target_filename=target_filename,
|
|
239
|
+
mode=mode,
|
|
240
|
+
diff=diff,
|
|
241
|
+
removed=removed,
|
|
242
|
+
supplied=supplied,
|
|
243
|
+
assumptions=assumptions,
|
|
244
|
+
refusal={
|
|
245
|
+
"code": "UNACKNOWLEDGED_PARSE_WARNINGS",
|
|
246
|
+
"message": "strict mode: parse warnings must be acknowledged "
|
|
247
|
+
"(acknowledge_parse_warnings=True) before this conversion will proceed",
|
|
248
|
+
"unresolved_scenarios": [],
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# --- Export (Part 4 §1) ----------------------------------------------------------
|
|
253
|
+
recovered = _append_recovery_records(
|
|
254
|
+
recovered, assumptions, source_format_id, target_format_id
|
|
255
|
+
)
|
|
256
|
+
canonical_out = _apply_write_plan(recovered, write_plan, target_format_id)
|
|
257
|
+
exporter = self._registry.get_exporter(target_format_id)
|
|
258
|
+
buffer = BytesIO()
|
|
259
|
+
exporter.export(canonical_out, buffer)
|
|
260
|
+
output = buffer.getvalue()
|
|
261
|
+
|
|
262
|
+
# Warnings echo parse warnings (Part 3 §5 rule 5) alongside capability caveats (already in
|
|
263
|
+
# diff.warnings). Export-time transformation warnings would be added here if the exporter
|
|
264
|
+
# changed representation; the v0.1 POSCAR exporter writes canonical Cartesian unchanged.
|
|
265
|
+
warnings = [*diff.warnings, *_parse_warnings(parse_issues)]
|
|
266
|
+
|
|
267
|
+
report = self._assemble(
|
|
268
|
+
stage="final",
|
|
269
|
+
status="completed",
|
|
270
|
+
mode=mode,
|
|
271
|
+
source=source,
|
|
272
|
+
source_format_id=source_format_id,
|
|
273
|
+
source_filename=source_filename,
|
|
274
|
+
source_sha256=source_sha256,
|
|
275
|
+
target_format_id=target_format_id,
|
|
276
|
+
target_filename=target_filename,
|
|
277
|
+
preserved=diff.preserved,
|
|
278
|
+
removed=removed,
|
|
279
|
+
supplied=supplied,
|
|
280
|
+
assumptions=assumptions,
|
|
281
|
+
warnings=warnings,
|
|
282
|
+
)
|
|
283
|
+
_assert_completeness(report, source)
|
|
284
|
+
|
|
285
|
+
# --- Validation (Part 5) — the unconditional final step --------------------------
|
|
286
|
+
validation = self._validation.validate(
|
|
287
|
+
expected=canonical_out,
|
|
288
|
+
output=output,
|
|
289
|
+
target_format_id=target_format_id,
|
|
290
|
+
conversion_report=report,
|
|
291
|
+
tolerance=ToleranceProfile.named(tolerance_profile),
|
|
292
|
+
)
|
|
293
|
+
return ConversionResult(
|
|
294
|
+
report=report, output=output, canonical_out=canonical_out, validation=validation
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _refuse(
|
|
298
|
+
self,
|
|
299
|
+
*,
|
|
300
|
+
source: CanonicalObject,
|
|
301
|
+
source_format_id: str,
|
|
302
|
+
source_filename: str | None,
|
|
303
|
+
source_sha256: str | None,
|
|
304
|
+
target_format_id: str,
|
|
305
|
+
target_filename: str | None,
|
|
306
|
+
mode: str,
|
|
307
|
+
diff: PreflightDiff,
|
|
308
|
+
refusal: dict[str, Any],
|
|
309
|
+
removed: list[RemovedEntry] | None = None,
|
|
310
|
+
supplied: list[SuppliedEntry] | None = None,
|
|
311
|
+
assumptions: list[Assumption] | None = None,
|
|
312
|
+
) -> ConversionResult:
|
|
313
|
+
"""Assemble a refused Conversion Report (a completed outcome, not an error; Part 4 §4).
|
|
314
|
+
|
|
315
|
+
The full pre-flight `preserved`/`removed` prediction rides along so a pipeline has
|
|
316
|
+
everything it needs to decide whether to supply presets and retry."""
|
|
317
|
+
report = self._assemble(
|
|
318
|
+
stage="final",
|
|
319
|
+
status="refused",
|
|
320
|
+
mode=mode,
|
|
321
|
+
source=source,
|
|
322
|
+
source_format_id=source_format_id,
|
|
323
|
+
source_filename=source_filename,
|
|
324
|
+
source_sha256=source_sha256,
|
|
325
|
+
target_format_id=target_format_id,
|
|
326
|
+
target_filename=target_filename,
|
|
327
|
+
preserved=diff.preserved,
|
|
328
|
+
removed=removed if removed is not None else diff.removed,
|
|
329
|
+
supplied=supplied or [],
|
|
330
|
+
assumptions=assumptions or [],
|
|
331
|
+
warnings=diff.warnings,
|
|
332
|
+
refusal=refusal,
|
|
333
|
+
)
|
|
334
|
+
_assert_completeness(report, source)
|
|
335
|
+
return ConversionResult(report=report, output=None, canonical_out=None, validation=None)
|
|
336
|
+
|
|
337
|
+
def _assemble(
|
|
338
|
+
self,
|
|
339
|
+
*,
|
|
340
|
+
stage: str,
|
|
341
|
+
status: str,
|
|
342
|
+
mode: str,
|
|
343
|
+
source: CanonicalObject,
|
|
344
|
+
source_format_id: str,
|
|
345
|
+
source_filename: str | None,
|
|
346
|
+
source_sha256: str | None,
|
|
347
|
+
target_format_id: str,
|
|
348
|
+
target_filename: str | None,
|
|
349
|
+
preserved: list[Any],
|
|
350
|
+
removed: list[Any],
|
|
351
|
+
warnings: list[Any],
|
|
352
|
+
supplied: list[SuppliedEntry] | None = None,
|
|
353
|
+
assumptions: list[Assumption] | None = None,
|
|
354
|
+
refusal: dict[str, Any] | None = None,
|
|
355
|
+
) -> ConversionReport:
|
|
356
|
+
return ConversionReport(
|
|
357
|
+
report_id=str(uuid.uuid4()),
|
|
358
|
+
stage=stage, # type: ignore[arg-type]
|
|
359
|
+
status=status, # type: ignore[arg-type]
|
|
360
|
+
mode=mode, # type: ignore[arg-type]
|
|
361
|
+
created_at=_utc_now(),
|
|
362
|
+
source={
|
|
363
|
+
"format_id": source_format_id,
|
|
364
|
+
"filename": source_filename,
|
|
365
|
+
"sha256": source_sha256,
|
|
366
|
+
"schema_version": source.schema_version,
|
|
367
|
+
},
|
|
368
|
+
target={"format_id": target_format_id, "filename": target_filename},
|
|
369
|
+
preserved=list(preserved),
|
|
370
|
+
removed=list(removed),
|
|
371
|
+
supplied=list(supplied or []),
|
|
372
|
+
assumptions=list(assumptions or []),
|
|
373
|
+
warnings=list(warnings),
|
|
374
|
+
refusal=refusal,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _utc_now() -> str:
|
|
379
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _parse_warnings(issues: list[ParseIssue]) -> list[Any]:
|
|
383
|
+
from xtalate.conversion.report import ReportWarning
|
|
384
|
+
|
|
385
|
+
return [
|
|
386
|
+
ReportWarning(code=i.code, message=i.message, source="parse")
|
|
387
|
+
for i in issues
|
|
388
|
+
if i.severity == "warning"
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _append_recovery_records(
|
|
393
|
+
canonical: CanonicalObject,
|
|
394
|
+
assumptions: list[Assumption],
|
|
395
|
+
source_format_id: str,
|
|
396
|
+
target_format_id: str,
|
|
397
|
+
) -> CanonicalObject:
|
|
398
|
+
"""Append one ``ConversionRecord(operation="recovery")`` per applied Assumption (Part 4 §3.2;
|
|
399
|
+
§2 provenance mirroring), so the object stays independently self-explanatory."""
|
|
400
|
+
if not assumptions:
|
|
401
|
+
return canonical
|
|
402
|
+
records = [
|
|
403
|
+
ConversionRecord(
|
|
404
|
+
timestamp=_utc_now(),
|
|
405
|
+
operation="recovery",
|
|
406
|
+
source_format=source_format_id,
|
|
407
|
+
target_format=target_format_id,
|
|
408
|
+
tool_version=__version__,
|
|
409
|
+
parser_version=None,
|
|
410
|
+
assumptions=[a.id],
|
|
411
|
+
)
|
|
412
|
+
for a in assumptions
|
|
413
|
+
]
|
|
414
|
+
return canonical.model_copy(
|
|
415
|
+
update={
|
|
416
|
+
"provenance": canonical.provenance.model_copy(
|
|
417
|
+
update={"history": [*canonical.provenance.history, *records]}
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def build_expected_object(
|
|
424
|
+
source: CanonicalObject, write_plan: set[str], target_format_id: str
|
|
425
|
+
) -> CanonicalObject:
|
|
426
|
+
"""Materialize the *expected object* (``canonical′``) from a source object and a container-level
|
|
427
|
+
``write_plan`` — the public entry the offline full re-parse re-validation uses to reconstruct
|
|
428
|
+
the Validation Engine's reference from a source file and a Conversion Report's path lists
|
|
429
|
+
(Part 5 §4.5). Identical construction to the Conversion Engine's own filtering step."""
|
|
430
|
+
return _apply_write_plan(source, write_plan, target_format_id)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _apply_write_plan(
|
|
434
|
+
source: CanonicalObject, plan: set[str], target_format_id: str
|
|
435
|
+
) -> CanonicalObject:
|
|
436
|
+
"""Materialize the write_plan as ``canonical′``: a copy of ``source`` with every field the
|
|
437
|
+
plan excludes set to ``None`` (Part 4 §1). ``atoms.symbols``/``atoms.positions`` (and the
|
|
438
|
+
derived ``atomic_numbers``) are always kept — every format writes them, and they are the
|
|
439
|
+
universal ``required_fields``. A convert-operation record is appended to provenance (§3.9)."""
|
|
440
|
+
frames = [_filter_frame(frame, plan) for frame in source.frames]
|
|
441
|
+
|
|
442
|
+
trajectory = source.trajectory
|
|
443
|
+
if trajectory is not None and "trajectory.timestep" not in plan:
|
|
444
|
+
# Keep the container (it carries multi-frame semantics) but drop the value.
|
|
445
|
+
trajectory = TrajectoryMetadata(timestep=None)
|
|
446
|
+
|
|
447
|
+
simulation = None
|
|
448
|
+
if source.simulation is not None:
|
|
449
|
+
kept = {
|
|
450
|
+
name: getattr(source.simulation, name)
|
|
451
|
+
for name in _SIMULATION_FIELDS
|
|
452
|
+
if f"simulation.{name}" in plan
|
|
453
|
+
}
|
|
454
|
+
simulation = SimulationMetadata(**kept) if kept else None
|
|
455
|
+
|
|
456
|
+
um = source.user_metadata
|
|
457
|
+
user_metadata = UserMetadata(
|
|
458
|
+
tags=um.tags if "user_metadata.tags" in plan else [],
|
|
459
|
+
annotations=um.annotations if "user_metadata.annotations" in plan else {},
|
|
460
|
+
custom_global=um.custom_global if "user_metadata.custom_global" in plan else {},
|
|
461
|
+
custom_per_atom=um.custom_per_atom if "user_metadata.custom_per_atom" in plan else {},
|
|
462
|
+
custom_per_frame=um.custom_per_frame if "user_metadata.custom_per_frame" in plan else {},
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
provenance = source.provenance.model_copy(
|
|
466
|
+
update={
|
|
467
|
+
"history": [
|
|
468
|
+
*source.provenance.history,
|
|
469
|
+
ConversionRecord(
|
|
470
|
+
timestamp=_utc_now(),
|
|
471
|
+
operation="convert",
|
|
472
|
+
source_format=source.provenance.source_format,
|
|
473
|
+
target_format=target_format_id,
|
|
474
|
+
tool_version=__version__,
|
|
475
|
+
parser_version=None,
|
|
476
|
+
assumptions=[],
|
|
477
|
+
),
|
|
478
|
+
]
|
|
479
|
+
}
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return CanonicalObject(
|
|
483
|
+
schema_version=source.schema_version,
|
|
484
|
+
frames=frames,
|
|
485
|
+
trajectory=trajectory,
|
|
486
|
+
simulation=simulation,
|
|
487
|
+
provenance=provenance,
|
|
488
|
+
user_metadata=user_metadata,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _filter_frame(frame: Frame, plan: set[str]) -> Frame:
|
|
493
|
+
atoms = AtomsBlock(
|
|
494
|
+
symbols=list(frame.atoms.symbols),
|
|
495
|
+
positions=frame.atoms.positions,
|
|
496
|
+
masses=frame.atoms.masses if "atoms.masses" in plan else None,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
cell = None
|
|
500
|
+
if frame.cell is not None and "cell.lattice_vectors" in plan:
|
|
501
|
+
cell = Cell(
|
|
502
|
+
lattice_vectors=frame.cell.lattice_vectors,
|
|
503
|
+
pbc=frame.cell.pbc,
|
|
504
|
+
space_group=frame.cell.space_group if "cell.space_group" in plan else None,
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
dynamics = Dynamics(
|
|
508
|
+
velocities=frame.dynamics.velocities if "dynamics.velocities" in plan else None,
|
|
509
|
+
forces=frame.dynamics.forces if "dynamics.forces" in plan else None,
|
|
510
|
+
constraints=frame.dynamics.constraints if "dynamics.constraints" in plan else None,
|
|
511
|
+
)
|
|
512
|
+
electronic = Electronic(
|
|
513
|
+
total_energy=frame.electronic.total_energy if "electronic.total_energy" in plan else None,
|
|
514
|
+
stress=frame.electronic.stress if "electronic.stress" in plan else None,
|
|
515
|
+
charges=frame.electronic.charges if "electronic.charges" in plan else None,
|
|
516
|
+
magnetic_moments=(
|
|
517
|
+
frame.electronic.magnetic_moments if "electronic.magnetic_moments" in plan else None
|
|
518
|
+
),
|
|
519
|
+
total_spin=frame.electronic.total_spin if "electronic.total_spin" in plan else None,
|
|
520
|
+
)
|
|
521
|
+
return Frame(
|
|
522
|
+
index=frame.index,
|
|
523
|
+
time=frame.time if "frame.time" in plan else None,
|
|
524
|
+
atoms=atoms,
|
|
525
|
+
cell=cell,
|
|
526
|
+
dynamics=dynamics,
|
|
527
|
+
electronic=electronic,
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _assert_completeness(report: ConversionReport, source: CanonicalObject) -> None:
|
|
532
|
+
"""The completeness invariant (Part 4 §2), asserted at finalization (review §4.5).
|
|
533
|
+
|
|
534
|
+
Every source-present/`mixed` path (bar the derived mirror) must appear in `preserved` ∪
|
|
535
|
+
`removed`; every `supplied` path must be absent on the source and trace to an Assumption.
|
|
536
|
+
A violation is silent loss (P1) or silent fabrication (P4) — the two defects this whole
|
|
537
|
+
project exists to make impossible — so it raises unconditionally, never merely logs."""
|
|
538
|
+
presence = source.field_presence()
|
|
539
|
+
accounted = {e.path for e in report.preserved} | {e.path for e in report.removed}
|
|
540
|
+
for entry in presence.entries:
|
|
541
|
+
if entry.status in ("present", "mixed") and entry.path not in _DERIVED_PATHS:
|
|
542
|
+
if entry.path not in accounted:
|
|
543
|
+
raise CompletenessInvariantError(
|
|
544
|
+
f"source-present path {entry.path!r} is in neither preserved nor removed — "
|
|
545
|
+
"silent loss (P1)"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
source_present = set(presence.present_paths())
|
|
549
|
+
assumption_ids = {a.id for a in report.assumptions}
|
|
550
|
+
for supplied in report.supplied:
|
|
551
|
+
if supplied.path in source_present:
|
|
552
|
+
raise CompletenessInvariantError(
|
|
553
|
+
f"supplied path {supplied.path!r} was present on the source — silent "
|
|
554
|
+
"fabrication (P4)"
|
|
555
|
+
)
|
|
556
|
+
if supplied.from_assumption not in assumption_ids:
|
|
557
|
+
raise CompletenessInvariantError(
|
|
558
|
+
f"supplied path {supplied.path!r} references unknown assumption "
|
|
559
|
+
f"{supplied.from_assumption!r}"
|
|
560
|
+
)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The pre-flight diff — presence × write-capability (MASTER_SPEC Part 3 §4.3).
|
|
2
|
+
|
|
3
|
+
The mechanical realization of **P5**: before any bytes are written, intersect what the
|
|
4
|
+
*source object contains* (`field_presence()`, Part 2 §3.11) with what the *target format can
|
|
5
|
+
write* (the Capability Matrix, Part 3 §4). Each source-present path is classified once:
|
|
6
|
+
|
|
7
|
+
* target capability ``FULL`` → **Preserved**;
|
|
8
|
+
* ``PARTIAL`` → **Preserved**, with the declared condition (`notes`) surfaced as the entry
|
|
9
|
+
``detail`` *and* a `capability`-source Warning — the condition is always shown, never
|
|
10
|
+
silently assumed to hold (in v0.1 the condition is not evaluated per-object; DECISIONS.md D19);
|
|
11
|
+
* ``NONE`` → **Removed**, with the `notes`/generated reason.
|
|
12
|
+
|
|
13
|
+
Two further triggers detect the need for the Recovery Engine (Part 4 §3), which M4 only
|
|
14
|
+
*detects* (resolution is M5): a target ``required_field`` absent on the source, and
|
|
15
|
+
``frame_count > max_frames``. The result is the raw material for the Conversion Report, shared
|
|
16
|
+
by the pre-flight draft and the final report so the two are structurally comparable (§2).
|
|
17
|
+
|
|
18
|
+
This module is pure: it reads presence + capabilities and returns data. It never mutates the
|
|
19
|
+
object, calls an exporter, or resolves a recovery.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
from xtalate.capabilities import CapabilityMatrix
|
|
27
|
+
from xtalate.conversion.report import PreservedEntry, RemovedEntry, ReportWarning
|
|
28
|
+
from xtalate.recovery import UnresolvedScenario
|
|
29
|
+
from xtalate.schema import CanonicalObject
|
|
30
|
+
from xtalate.sdk import CapabilityLevel
|
|
31
|
+
|
|
32
|
+
# `atoms.atomic_numbers` is a derived mirror of `atoms.symbols` (Part 2 §3.3), not independent
|
|
33
|
+
# source information, so it is excluded from the diff and the completeness invariant — a format
|
|
34
|
+
# that writes symbols reconstitutes it. Provenance is already excluded upstream (presence §3.11).
|
|
35
|
+
_DERIVED_PATHS = frozenset({"atoms.atomic_numbers"})
|
|
36
|
+
|
|
37
|
+
# A target required-field that is absent on the source maps to the recovery scenario that can
|
|
38
|
+
# supply it (Part 4 §3.3). Only the fabricative one exists in v0.1 (review §4.4 trim).
|
|
39
|
+
_REQUIRED_FIELD_SCENARIOS = {"cell.lattice_vectors": "missing_lattice"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class PreflightDiff:
|
|
44
|
+
preserved: list[PreservedEntry] = field(default_factory=list)
|
|
45
|
+
removed: list[RemovedEntry] = field(default_factory=list)
|
|
46
|
+
warnings: list[ReportWarning] = field(default_factory=list)
|
|
47
|
+
# Container-level canonical paths the exporter is cleared to write (the write_plan, Part 4
|
|
48
|
+
# §1). Custom_* containers are all-or-nothing at this granularity; per-key entries are still
|
|
49
|
+
# reported individually in `preserved`/`removed`.
|
|
50
|
+
write_plan: set[str] = field(default_factory=set)
|
|
51
|
+
unresolved: list[UnresolvedScenario] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def capability_path(presence_path: str) -> str:
|
|
55
|
+
"""Map a presence path to the capability key it is governed by.
|
|
56
|
+
|
|
57
|
+
Dynamic custom keys arrive as ``user_metadata.custom_per_frame['xyz:comment']`` (per §3.11)
|
|
58
|
+
but capabilities are declared at the container level ``user_metadata.custom_per_frame``
|
|
59
|
+
(Part 3 §4.1) — so the ``['key']`` suffix is stripped for the capability lookup while the
|
|
60
|
+
per-key path is kept for the report entry.
|
|
61
|
+
"""
|
|
62
|
+
bracket = presence_path.find("[")
|
|
63
|
+
return presence_path[:bracket] if bracket != -1 else presence_path
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def build_preflight(
|
|
67
|
+
source: CanonicalObject, matrix: CapabilityMatrix, target_format_id: str
|
|
68
|
+
) -> PreflightDiff:
|
|
69
|
+
"""Compute the pre-flight diff of ``source`` against the target's write capabilities."""
|
|
70
|
+
caps = matrix.get(target_format_id, "write")
|
|
71
|
+
presence = source.field_presence()
|
|
72
|
+
diff = PreflightDiff()
|
|
73
|
+
|
|
74
|
+
for entry in presence.entries:
|
|
75
|
+
path = entry.path
|
|
76
|
+
if entry.status not in ("present", "mixed") or path in _DERIVED_PATHS:
|
|
77
|
+
continue
|
|
78
|
+
container = capability_path(path)
|
|
79
|
+
cap = matrix.field_capability(target_format_id, "write", container)
|
|
80
|
+
detail = _frame_detail(entry.status, entry.present_frames)
|
|
81
|
+
|
|
82
|
+
if cap.level == CapabilityLevel.FULL:
|
|
83
|
+
diff.preserved.append(PreservedEntry(path=path, detail=detail))
|
|
84
|
+
diff.write_plan.add(container)
|
|
85
|
+
elif cap.level == CapabilityLevel.PARTIAL:
|
|
86
|
+
diff.preserved.append(PreservedEntry(path=path, detail=cap.notes or detail))
|
|
87
|
+
diff.write_plan.add(container)
|
|
88
|
+
if cap.notes:
|
|
89
|
+
diff.warnings.append(
|
|
90
|
+
ReportWarning(code="PARTIAL_CAPABILITY", message=cap.notes, source="capability")
|
|
91
|
+
)
|
|
92
|
+
else: # NONE
|
|
93
|
+
reason = cap.notes or f"Target format {target_format_id!r} cannot store {container}."
|
|
94
|
+
diff.removed.append(RemovedEntry(path=path, reason=reason, detail=detail))
|
|
95
|
+
|
|
96
|
+
# lossy_notes → Warnings (Part 3 §4.3 rule 5).
|
|
97
|
+
for note in caps.lossy_notes:
|
|
98
|
+
diff.warnings.append(
|
|
99
|
+
ReportWarning(code="FORMAT_LOSSY_NOTE", message=note, source="capability")
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Recovery triggers (Part 3 §4.3 rules 3–4) — detected here, resolved in M5.
|
|
103
|
+
# Frame selection is ordered before missing_lattice (a bounding box is computed on the
|
|
104
|
+
# selected frame — the dependency of Part 4 §3.3).
|
|
105
|
+
if caps.max_frames is not None and source.frame_count > caps.max_frames:
|
|
106
|
+
diff.unresolved.append(
|
|
107
|
+
UnresolvedScenario(
|
|
108
|
+
scenario="frame_selection",
|
|
109
|
+
detail=f"{source.frame_count} frames → target holds at most {caps.max_frames}",
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
for required in caps.required_fields:
|
|
113
|
+
if presence.status_of(required) == "absent":
|
|
114
|
+
diff.unresolved.append(
|
|
115
|
+
UnresolvedScenario(
|
|
116
|
+
scenario=_REQUIRED_FIELD_SCENARIOS.get(required, "missing_required_field"),
|
|
117
|
+
path=required,
|
|
118
|
+
detail=f"target requires {required}, absent on source",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
return diff
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _frame_detail(status: str, present_frames: list[int] | None) -> str | None:
|
|
125
|
+
if status == "mixed" and present_frames is not None:
|
|
126
|
+
return f"present in frames {present_frames}"
|
|
127
|
+
return None
|