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.
Files changed (49) hide show
  1. xtalate/__init__.py +10 -0
  2. xtalate/capabilities/__init__.py +19 -0
  3. xtalate/capabilities/registry.py +126 -0
  4. xtalate/cli/__init__.py +11 -0
  5. xtalate/cli/main.py +431 -0
  6. xtalate/cli/render.py +126 -0
  7. xtalate/conversion/__init__.py +48 -0
  8. xtalate/conversion/engine.py +560 -0
  9. xtalate/conversion/preflight.py +127 -0
  10. xtalate/conversion/report.py +74 -0
  11. xtalate/discovery/__init__.py +28 -0
  12. xtalate/discovery/engine.py +202 -0
  13. xtalate/discovery/report.py +50 -0
  14. xtalate/discovery/sniffer.py +95 -0
  15. xtalate/exporters/__init__.py +34 -0
  16. xtalate/exporters/extxyz.py +140 -0
  17. xtalate/exporters/poscar.py +180 -0
  18. xtalate/exporters/xyz.py +67 -0
  19. xtalate/parsers/__init__.py +31 -0
  20. xtalate/parsers/_common.py +57 -0
  21. xtalate/parsers/extxyz.py +452 -0
  22. xtalate/parsers/poscar.py +428 -0
  23. xtalate/parsers/xyz.py +286 -0
  24. xtalate/py.typed +0 -0
  25. xtalate/recovery/__init__.py +41 -0
  26. xtalate/recovery/engine.py +321 -0
  27. xtalate/recovery/scenarios.py +86 -0
  28. xtalate/registry.py +28 -0
  29. xtalate/schema/__init__.py +40 -0
  30. xtalate/schema/arrays.py +115 -0
  31. xtalate/schema/elements.py +150 -0
  32. xtalate/schema/models.py +283 -0
  33. xtalate/schema/paths.py +88 -0
  34. xtalate/schema/presence.py +152 -0
  35. xtalate/sdk/__init__.py +27 -0
  36. xtalate/sdk/capabilities.py +57 -0
  37. xtalate/sdk/plugins.py +72 -0
  38. xtalate/sdk/results.py +45 -0
  39. xtalate/validation/__init__.py +25 -0
  40. xtalate/validation/engine.py +585 -0
  41. xtalate/validation/report.py +48 -0
  42. xtalate/validation/rethreshold.py +122 -0
  43. xtalate/validation/tolerance.py +125 -0
  44. xtalate-0.1.0.dev0.dist-info/METADATA +161 -0
  45. xtalate-0.1.0.dev0.dist-info/RECORD +49 -0
  46. xtalate-0.1.0.dev0.dist-info/WHEEL +4 -0
  47. xtalate-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  48. xtalate-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
  49. 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