Skip to content

Re/03b phase block - #4

Merged
AdamCorrao merged 14 commits into
refactor/multi-enginefrom
re/03b-phase-block
Oct 8, 2026
Merged

AdamCorrao merged 14 commits into
refactor/multi-enginefrom
re/03b-phase-block

Conversation

@AdamCorrao

@AdamCorrao AdamCorrao commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

re/03b-phase-block: one phase block, symmetry-aware refinement intent, strict core validation

Base: refactor/multi-engine (4b61201) ← head: re/03b-phase-block (b446577), 14 commits.
Core only: no gateway runtime changes, no frozen GSAS-II code touched, examples/ untouched.
Suite: 1311 → 1518 passed, 4 skipped (default env); easydiffraction env 55 passed, unchanged.

Overview

re/03b replaces core schema 1.0.0's structure-only phase model before its
first release. A phase is now one block: the space group, the unit cell
and the atoms, where every structural quantity is a refinable parameter
([value, flag], or [value, flag, min, max] in engines with bounds).
Engine schemas (re/04–06) subclass it and add their own phase fields flat
next to core's.

The most important change is what a recipe means and what validation is
allowed to do with it
:

  1. Refinement intent must agree with the symmetry. Parameters tied by
    symmetry are one parameter. Every member is stated, and all members
    carry the same flag and consistent bounds. A parameter fixed by symmetry
    is never refined and has no bounds. Inconsistent intent is an error at
    the offending field
    . Before, it was silently resolved ("group-OR",
    KI-02) or ignored.
  2. One rule for every value the symmetry determines. The recipe is the
    record of the refinement intent:
    • the first member of each tie group is the free parameter and is kept
      exactly as written;
    • every other value is derived from the symmetry, and the validated
      model holds the derived value, so every tie holds exactly and every
      engine receives the same structure;
    • a constant with an exact decimal form is written exactly: 0.5, not
      0.4999999; 0, not 1e-9;
    • a constant without one (a third) is stated to 6 decimals: 0.333333;
    • a value derived from another stated value (y = 2x, U12 = U22/2,
      a tied member's bounds) is read from the precision of its kind;
    • anything else is an error, and the message gives the value to write.
  3. No silent interpretation anywhere in the phase:
    • the space group must be gemmi's one canonical name;
    • names follow a rule every engine can use;
    • engine subclasses cannot replace, skip or rewrite core's validation;
    • a phase model must declare its parameter type.

Everything downstream reads the validated model, never the raw JSON. That
model holds exactly one structure, and it is the one every engine receives.

Change of intent and logic (what reviewers should check first)

1. One phase block with refinable values (A93, A94, A96, A100)

  • Shape:
    • Phase[P], UnitCell[P], Atom[P], UanisoTensor[P] are generic over
      the parameter type P (src/powderline/schema_core.py:593).
    • Cell, src/powderline/schema_core.py:619; ADP pairing, :670; atom,
      :677; phase, :763.
  • Explicit:
    • nothing is null and nothing has a default; occupancy must be stated;
    • ADP selects which of Uiso/Uaniso is required, stated in the
      JSON Schema too (:670);
    • the phase name is the key of the payload's phases dict, so there is
      no phase_name field.
  • Removed: PhaseStructure and the Structure*RefinableParameter
    variants with null values (A71's structure part). Nothing at runtime used
    them; core 1.0.0 is unreleased.
  • Readable JSON Schema: CoreJsonSchema and CoreModel (:134, :152)
    name parametrized models Atom/UnitCell instead of
    Atom_RefinableParameter_.

2. Refinement intent follows the symmetry (A94, A95, A98, A104, A106)

  • Rules (_tie_rules, src/powderline/schema_core.py:1256):
    • F1: members of one tie group carry the same flag.
    • F2: a symmetry-fixed parameter has flag false.
    • A106: a fixed parameter has no bounds.
    • F3: different groups are independent.
    • F4: with bounds, each member's bounds follow the tie from the first
      member's. The tie is affine: member = k·rep + c maps [min, max] to
      [k·min + c, k·max + c], swapped for k < 0. An open side stays open.
  • Locations: each error sits at the parameter (unit_cell.b,
    atoms.O1.y, atoms.O1.Uaniso.U12). For F1 there is one error per member
    that differs from the group's first member, and each message names the
    whole group and the fix.
  • Where the ties come from (src/powderline/symmetry.py):
    • cell_tie_groups (:756) covers every crystal system, including
      rhombohedral axes :R (:772). The legacy cell_constraints keeps its
      :R refusal.
    • coupling_groups (:838, _tie_groups :796) groups coordinate and
      Uij ties by connectivity of the orthogonal projector. Coordinate
      relations are affine, with the offset taken from the stated position;
      Uij relations are linear. TieGroup.relation is at :693.
  • Co-located atoms are independent (A104): their flags are never tied.
  • Proof:
    • Engine-free: all 564 gemmi settings and every distinct special site:
      tests/test_symmetry_core.py:272; cell ties against the metric: :387.
    • Against GSAS-II GetCSxinel/GetCSuinel, 2,928 sites:
      tests/test_symmetry_characterization.py:299. 17 known GSAS-II misreads
      are pinned as still differing (:308).

3. One rule for symmetry-determined values (A115, A120, A121, A123)

  • Before (draft and re/03):

    • special positions within 5e-6 were replaced by exact values;
    • Uij within 1e-6 Ų were adjusted;
    • tied bounds within tolerance were snapped (A108);
    • each change was reported only as a warning, which was lost if the
      validated recipe was validated again.
  • Now (Phase._sites_check, src/powderline/schema_core.py:940; the
    rule is _derive, :1178, with one tolerance family per kind, :1159-1170):

    • The free parameter: the first member of each tie group (x before y
      before z; U11 before U22…) is kept exactly as written.
    • Derived values: every other value is computed from the space group
      and those parameters in exact rational arithmetic (Fraction) and
      rounded once. The validated model holds it (A120 option A, extended by
      A121), so every tie holds exactly in the model.
    • How closely the recipe must state a derived value:
      • a constant of the site (a fixed coordinate, a Uij fixed at 0) with
        an exact decimal form: exactly, to floating-point precision
        (0.4999999 → error "write x = 0.5"; U13 = 1e-9 → error
        "write U13 = 0.0"). The maintainer's rule: a value that can be written
        exactly is never snapped, although most crystallographic programs do;
      • a constant without one (a third, _has_decimal_form, :1131): to
        at least 6 decimals (within 5e-6, A73), read as the exact value;
      • a value derived from another stated value (a coupled coordinate, a
        coupled Uij, a tied member's bounds): within 5e-6 for coordinates (A73)
        or 1e-6 Ų for Uij (A78), read as the derived value, whatever the
        relation (y = 2x, y = x + 1/2, y = x + 1/3);
      • the cell (A123, :968): tied lengths and angles equal the first
        member's and fixed angles are exactly 90 or 120, within A77's 1e-9
        (relative on lengths). The model holds b = a exactly. The error
        states each stated value and the value to write:
        stated b = 4.2, but by symmetry b = a; write b = 4.15692.
    • The 5e-6 to 2e-3 "ambiguous position" band error is unchanged.
    • Reading carries no warning: within these tolerances the recipe has only
      that meaning. A dump of the model validates as itself.
  • Why derived values are read but constants are not: a constant's
    decimal form is fixed by the space group. A derived value's depends on how
    the first member was typed. The ideal C14 (MgZn₂-type) 6h position
    x, y = 0.833333, 0.666667 has y = 2x − 1 = 0.666666. Requiring that
    exactly would reject a correct 6-decimal statement.

  • How PowderLine reads typical statements (the table users get in
    docs/SCHEMA_HISTORY.md:143, pinned by
    tests/test_schema_core.py:2318-2336):

    Stated Symmetry Validated model
    x = 0.4999999 at (1/2, 0, 0) x fixed = 1/2 error: write x = 0.5
    0.333333, 0.666667 at (1/3, 2/3, 1/4) fixed thirds 1/3, 2/3
    0.3333333333333, 0.6666666666667 fixed thirds 1/3, 2/3
    0.833333, 0.666667 at 6h (x, 2x, 1/4) y = 2x − 1 0.833333, 0.666666
    0.1, 0.2000001 at (x, 2x, 0) y = 2x 0.1, 0.2
    0.066667, 0.4 at (x, x + 1/3, 1/6) y = x + 1/3 0.066667, 0.40000033…
    U13 = 1e-9 at 6h U13 = 0 error: write U13 = 0.0
    U12 = 0.006174, U22 = 0.012347 at 6h U12 = U22/2 U12 = 0.0061735
  • Why "no decimal form" means a third: across all 564 settings, every
    distinct special site and lattice translates:

    • fixed coordinates have denominators 1, 2, 3, 4, 6, 8, 12;
    • tie factors are −1, ½, 1, 2;
    • coordinate offsets have denominators 1–4, and Uij offsets are 0;
    • so only thirds lack a decimal form.
  • Representation:

    • the validated model holds the float nearest each derived value (1/3, not
      0.333333);
    • a fuzz over 10,353 site×translate cases gives that float in every case;
    • the user's recipe file is never rewritten;
    • the caller's cell and atom objects are never modified (:985, :1037);
    • Phase.site(label) returns the site's symmetry (multiplicity, ties). Its
      canonical position is not the engines' structure; the model is
      (src/powderline/symmetry.py:426).
  • Warnings:

    • core emits no special_position_adjusted, adp_symmetry_adjusted or
      tied_bounds_adjusted codes;
    • Phase.warnings() (:1047) holds only informative findings that change
      nothing. Today that is one: a non-positive-definite Uaniso (A118,
      :1023). It is accepted, like a negative Uiso, because it may
      compensate for an unmodeled effect;
    • the warning gives the Cartesian principal mean-square displacements
      (_principal_msd, :1106). The raw Uij matrix only has the right signs:
      an isotropic hexagonal −0.01 read −0.015, −0.01, −0.005. A near-zero
      value counts as 0, so a semidefinite tensor always warns.
  • Effect on the committed examples: none of their 67 atoms is affected.

4. One spelling of the space group (A105, A110)

  • Rule: space_group must equal gemmi's canonical extended H-M name
    exactly ("P m -3 m", "C 1 2/m 1", "R -3 m:H", "F d -3 m:2").
    Implemented in canonical_space_group, src/powderline/symmetry.py:152.
  • Errors give the canonical name:
    • a symbol without a setting lists both :H/:R or :1/:2;
    • a short monoclinic symbol ("P21/c") lists every setting by unique
      axis (:191) instead of gemmi's silent pick.
  • gemmi pinned to ==0.7.5 (pixi.toml, pyproject.toml). The 564
    names are committed in tests/data/canonical_space_groups.txt and compared
    with the installed gemmi (tests/test_schema_core.py:1488).
  • Legacy paths keep the lenient resolve_space_group until re/07.

5. Engine subclasses cannot replace, skip or rewrite core (A93, A109, A114, A122)

Checked in Phase.__pydantic_init_subclass__
(src/powderline/schema_core.py:837), as a TypeError when the class is
defined:

  1. Names: no class in the MRO besides core's own, mixins included,
    may:

    • redeclare space_group, unit_cell or atoms;
    • reuse the name of a core validator, private attribute or method
      (computed from Phase, _reserved_phase_members, :1056);
    • define a pydantic entry point (__init__, model_post_init,
      model_validate, …; :1070, :887).

    pydantic collects validators by name, so a gsasii _space_group silently
    replaced the canonical-name check, and a mixin placed before Phase[P]
    could do the same.

  2. Identity: every core validator must still be core's own function
    (_core_decorators, :1088, :895).

  3. Check, never rewrite: an engine's own validators on core fields and on
    the model are 'after' validators (:900). A 'before', 'wrap' or
    'plain' one could rewrite the recipe before core reads it, or skip core.
    An engine 'before' validator on its own field is allowed.

  4. Canary (tests/test_phase_canary.py): core's must-fail recipes and a
    valid recipe run through every engine phase model, and the structural
    guard is re-run on each, so even a mixin that overrides the hook is
    caught. re/04–06 register their models in ENGINE_PHASES; a completeness
    test fails until they do.

Also:

  • extra='forbid' cannot be relaxed (tests/test_schema_core.py:1579).
  • A phase model must have its parameter type (:921): a subclass that
    forgets [P] fails when defined, unless it declares Generic[P]
    (tests/test_schema_core.py:1557-1564). A parametrization with any other
    type (Phase[float]) is a TypeError (:863).
  • Engine validators with their own names run in addition to core's
    (tests/test_schema_core.py:1586). Tests for the guard layers:
    :2438-2532.

6. Names and value domains (A111, A112)

  • Names: atom labels (and, through the exported CoreName, engine phase
    names) match ^[A-Za-z][A-Za-z0-9_]*$, are not Python keywords, and are at
    most 64 characters. Phase names, and the atom labels of a phase, must also
    differ by more than case (:956).
    Code: src/powderline/schema_core.py:190-262.
  • Why:
    • GSAS-II renames a phase with spaces or non-ASCII characters, and the
      parameterization then silently skips it;
    • easydiffraction rejects other atom ids;
    • TOPAS writes names into its input;
    • phase names become report file names, which ignore case on Windows and
      macOS;
    • error paths are .-joined.
  • Value domains: a stated cell bound must lie in the cell's domain:
    lengths > 0, angles strictly between 0 and 180 (:633, :643). null
    still means no bound. Negative ADPs are not blocked (A111).

7. Monoclinic unique axis fix (A102), the one legacy-visible change

  • Fix: _monoclinic_unique_axis (src/powderline/symmetry.py:218; the
    |trace| = 1 check is at :228) now uses the 2-fold axis or the
    mirror/glide normal
    . Before, it raised for every Pm, Pc, Cm and Cc
    setting (space groups 6–9) since v0.1.1.
  • Who notices: the legacy TOPAS writer and the easydiffraction
    policy/builder no longer refuse these groups. easydiffraction gives a
    clear build-time error for non-short spellings. Whether TOPAS accepts them
    is checked in re/05.
  • Tests: tests/test_topas_symmetry.py:63, :73.

Commits

SHA What
6c29497 monoclinic unique axis from the 2-fold axis or the mirror normal (A102)
a0c1a15 one phase block, Phase[P], canonical space groups (A93–A96, A100, A105)
af9d2ec cell tie groups (incl. :R) and coupling groups with affine relations
8f080c2 flag and bound rules F1–F4 (A95, A98, A104, A106; A108 later superseded)
ec261a8 SCHEMA_HISTORY: core 1.0.0 phase block, pre-release revision
0eb3d31 ADP selects a required thermal parameter, stated in the schema
8bd674a gemmi pinned exactly (A110)
6afa7e2 subclass guard, name rule, cell-bound domain, monoclinic symbols (A109–A112)
abb6b64 validation never changes a recipe; parameter type required (A114, A115)
0b06b09 non-positive-definite Uaniso accepted with a warning (A118)
5994fd0 thirds stated to 6 decimals; decimal-form values exactly (A120)
4f41bbf docs: a recipe file is never rewritten
6af30c9 SiteAnalysis.canonical is not the engines' structure; band message to 6 decimals
b446577 one rule for symmetry-determined values (A121), engine guard layers and canary (A122), cell values (A123); Cartesian warning values; atom labels unique ignoring case

Tests and verification

  • Default env: 1311 passed, 4 skipped at 4b61201 → 1518 passed, 4
    skipped
    at b446577.
    • tests/test_schema_core.py was reworked. The tests of the removed
      Structure* classes are gone. test_phase_structure_* is renamed
      test_phase_*. The adjust/snap tests became *_not_exact_rejected, and
      the N4 re-check tests were removed.

    • The PR review renamed three tests whose behaviour A121/A123 changed
      (tied bounds and coupled Uij are now read):

      • test_phase_uaniso_hexagonal_6h_not_exact_rejected →
        …_6h_coupled_member_read_within_1e_6;
      • test_tied_coordinate_bounds_not_exact_rejected →
        test_tied_coordinate_bounds_read_within_6_decimals;
      • test_tied_cell_bounds_equal_exactly →
        test_tied_cell_bounds_follow_the_first_member.

      No ID was removed.

    • The full list of removed and renamed IDs, with reasons, is in the devkit
      records.

  • New coverage:
    • F1–F4 for every crystal system plus :R and Pm/Pc/Cm/Cc
      (tests/test_schema_core.py:1884-2130);
    • affine ties at P-42₁m 4e and P4₂/mnm 4f (:2040, :2048);
    • the behaviour table (:2318-2336); thirds (:1095, :1143); fixed Uij
      exactly 0 (:1294); cell values (:2365); fixed angles in all 564
      settings (:2382);
    • the subclass guard layers (:1499-1586, :2438-2532) and the canary
      (tests/test_phase_canary.py);
    • names (:2166-2210, case :2427); cell bound domain (:2230);
    • the non-positive-definite Uaniso and its Cartesian values (:2260,
      :2292).
  • Fuzz (not committed): 10,353 site×translate cases over all 564
    settings, with decimals written exactly and thirds and derived members to
    6 decimals. 0 rejections, and the exact derived float in every case.
  • easydiffraction env (tests/test_easydiff_*): 55 passed, identical
    IDs.
  • Engine-free guard: the import-block test runs the new symmetry code
    and a full Phase validation with GSAS-II and easydiffraction blocked
    (tests/test_gateways.py:479-490).
  • Hygiene:
    • undefined-name probe 0; UTF-8/LF;
    • no kicker.py, gateways/gsasii/, _code_hash.json or examples/
      change.
  • Lock: pixi.lock changes only powderline's own requires_dist copy of
    the gemmi pin. pixi lock --check reports it up to date.

Deliberate behavior changes

  • Legacy TOPAS writer and easydiffraction policy/builder accept
    Pm/Pc/Cm/Cc in every setting (A102).
  • gemmi is pinned to exactly 0.7.5, where it was >=0.7,<0.8. A pip
    install next to another gemmi 0.7.x now conflicts.
  • Core 1.0.0 itself is unreleased, so its revisions are not version
    changes. docs/SCHEMA_HISTORY.md records them as a pre-release revision
    (:25). The user-facing rules are documented at:
    • :65 names;
    • :91 space group;
    • :104 unit cell;
    • :120 symmetry-determined values, with the table at :143;
    • :173 refinement intent.

What re/04–06 build on

  • Engine schemas subclass the phase model: Phase[RefinableParameter]
    for gsasii (no bounds, A84), Phase[BoundedRefinableParameter] for TOPAS
    and easydiffraction.
  • Each engine schema:
    • uses CoreName + name_keyed + check_names_unique_ignoring_case for
      its phases;
    • gives its validators their own names and 'after' mode (A109, A122);
    • registers its phase model in tests/test_phase_canary.py
      ENGINE_PHASES;
    • reads the validated model, never the JSON or Phase.site().canonical.
  • Each gateway emits one engine parameter per tie group:
    • GSAS-II's coarser cell groups are an engine-layer rule;
    • TOPAS writes = 1/3; and x + 1/3 from core's exact fractions;
    • easydiffraction frees the representative.

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-sonnet-5-5

…ror normal (A102)

`_monoclinic_unique_axis` looked only for a proper 2-fold, so
`cell_constraints` raised "could not determine the monoclinic unique axis"
for every setting of the mirror-only groups Pm, Pc, Cm and Cc (space groups
6-9, 41 gemmi settings) since v0.1.1. The unique axis is now the invariant
direction of det(R)*R for any operation with |trace| = 1: the 2-fold axis or
the mirror/glide normal. One shared helper for every cell rule (the re/03b
cell tie groups, re/04's coarse groups, re/05/re/06).

Deliberate change (devkit tasks/deliberate-changes.md #4): the legacy TOPAS
writer and the easydiffraction policy/builder no longer refuse these groups
early.

Tests: Pm/Pc/Cm/Cc and P2, P2_1/c, C2/c in a-, b- and c-unique settings and
other cell choices, plus every monoclinic setting in gemmi's table (115).

Assisted-by: claude-code:claude-opus-5-5
…ical space groups (A93-A96, A100, A105)

The core phase model is now one block: `Phase[P]`, `UnitCell[P]` and
`Atom[P]`, generic over the parameter type P (RefinableParameter, or
BoundedRefinableParameter for engines that honor bounds; one P for cell and
atoms, review N6). Every structural quantity (cell a..gamma; atom x, y, z,
occupancy, Uiso, U11..U23) is `[value, refine_flag]`; element, ADP, a stated
Multiplicity and the space group stay plain values. Engine schemas subclass
`Phase[TheirType]` and add their own fields flat (gsasii: scale,
peak_broadening).

- Value rules unchanged in substance, now on `.value` (A63, A69, A73,
  A76-A78); canonicalization replaces `.value` and keeps the flag and bounds.
- No null and no defaults (A96): occupancy is required; Multiplicity, Uiso and
  Uaniso may be left out but never null; a dump leaves them out, so it
  validates again.
- No phase_name: the phases dict key is the name (A100).
- `space_group` must be gemmi's canonical xhm() name exactly (A105); any other
  spelling is an error naming the canonical form (both settings / origins for
  a bare rhombohedral / two-origin symbol). New engine-free
  `symmetry.canonical_space_group`; the lenient resolve_space_group stays for
  the legacy paths.
- Reserved names: an engine subclass that redeclares space_group, unit_cell or
  atoms raises TypeError at class definition.
- `CoreModel` base + `CoreJsonSchema`: readable $defs names (`Atom`, not
  `Atom_RefinableParameter_`); qualified fallback if two parameter types ever
  share a document (devkit tasks/re03b-spike-generics.md).
- Removed: PhaseStructure, StructureRefinableParameter,
  StructureBoundedRefinableParameter and the null-value schema note (A71's
  structure part is superseded by A93/A96).

Flag and bound rules (F1-F4) follow in a later commit. Nothing at runtime
consumes the phase model yet; the legacy 0.26.0 schema is unchanged.

Tests: test_schema_core reworked by ID (17 Structure*-only tests and 14
Structure* numeric parametrizations removed, 22 test_phase_structure_* renamed
test_phase_*), plus new phase-block tests.

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-sonnet-5-5
…ith affine relations (A94, A95, A98)

- `cell_tie_groups(space_group)`: which cell parameters are one parameter
  (cubic {a,b,c}; tetragonal/hexagonal {a,b},{c}; `:R` {a,b,c} and
  {alpha,beta,gamma}; monoclinic unique-axis angle via the A102 helper) and
  which angles are fixed. `cell_constraints` is unchanged and keeps its `:R`
  refusal for the legacy TOPAS/easydiffraction callers (deliberate change #2).
- `coupling_groups(space_group, xyz)`: coordinate and Uij tie groups at the
  canonical site, as connected components of the orthogonal projector onto
  the allowed subspace (the oblique Reynolds average gives wrong Uij groups on
  hexagonal axes). Each group exposes its exact relation `member = k*rep + c`:
  affine for coordinates, with `c` from the canonical values (P-42_1m 4e
  y = x + 1/2; P4_2/mnm y = -x + 1), linear for Uij (U12 = U22/2,
  U23 = -U13). Review B1.

Proof:
- engine-free, all 564 gemmi settings, every distinct special site on the
  1/24 grid (3,451): fixed sets match analyze_site; moving along any
  coordinate group's relation stays fixed by the whole stabilizer; a Uij
  tensor along any group's relation is invariant (2,792 coordinate groups);
- vs GSAS-II GetCSxinel/GetCSuinel, the 522 settings GSAS-II reads (2,928
  sites): partition, fixed set, one DOF per group and relation coefficients
  agree everywhere GSAS-II names the site; its 17 known defects (9 R-centred
  2-fold sites read as general, EB-40; 8 'sp' sites, EB-05) are listed and
  asserted to still differ (review S2);
- every gemmi setting: cell groups agree with the metric (a group moved
  together stays symmetric, one member alone or a fixed angle breaks it) and,
  off `:R`, with cell_constraints.

Assisted-by: claude-code:claude-opus-5-5
…A104, A106, A108)

The phase validator now checks refine flags and bounds against the symmetry
ties (cell_tie_groups, coupling_groups), after the values are valid and
canonical:
- F1: the members of one tie group carry the same flag. Reported at every
  member whose flag differs from the group's first member, naming the whole
  group (review N1), e.g. "cubic cell ('P m -3 m'): a, b, c are one
  parameter; set the same refine flag on all three".
- F2: a symmetry-fixed parameter has flag false; A106: and no bounds.
- F3: different groups are independent. A104: co-located atoms are never
  compared.
- F4 (with bounds): member bounds follow the tie from the first member's,
  affine for coordinates ([k*min + c, k*max + c], swapped for k < 0, open
  sides map to the matching side). A108: within the family's value tolerance
  (coordinates 5e-6, Uij 1e-6 A^2, cell CELL_TOL) the bounds are set exactly,
  with a `tied_bounds_adjusted` warning; beyond, an error at the member.
- Review N4: a value moved by canonicalization (A73/A78) is re-checked against
  its final bounds.

All problems are reported together in one ValidationError (A70), each at its
parameter (`unit_cell.b`, `atoms.<label>.y`, `atoms.<label>.Uaniso.U12`).

Tests: every crystal system incl. `:R` and Pm/Pc/Cm/Cc cells, special
positions, multi-group and negative Uij ties, affine ties at P-42_1m 4e and
P4_2/mnm 4f, open bounds, snapping vs errors, A106, N4, exact messages and
locations. The engine-import-block test now runs the new symmetry code and a
full phase validation with GSAS-II and easydiffraction blocked.

Assisted-by: claude-code:claude-opus-5-5
SCHEMA_HISTORY's core 1.0.0 entry now describes the phase block that replaced
the structure-only draft before release (noted as a pre-release revision, not
a new version): one block keyed by the phase name, every structural quantity a
parameter, no null and no defaults, gemmi's canonical space-group name (A105),
and refinement intent that follows the symmetry (tied members share a flag;
fixed parameters have flag false and no bounds; bounds follow affine ties,
snapped within tolerance; co-located atoms independent).

Assisted-by: claude-code:claude-opus-5-5
…hema and errors at the field

Review follow-up to item 4 of the re/03b record. `Uiso`/`Uaniso` were never
optional in effect (ADP selects exactly one), but the JSON Schema listed
neither as required and the pairing errors were reported at the atom, not at
the field.

- `Uaniso` is now a `UanisoTensor[P]` model with all six of U11..U23
  required: a missing or unknown component is reported at it
  (`atoms.<label>.Uaniso.U23`). Same JSON shape as before.
- The ADP check reports a missing selected parameter as `missing` and the
  other one, if given, as `extra_forbidden`, each at its own field
  (`atoms.<label>.Uiso`), with a message naming the ADP choice.
- The Atom JSON Schema states the rule: if ADP is "Uiso", Uiso is required
  and Uaniso forbidden; if "Uaniso", the reverse.
- Multiplicity is unchanged: derived, so optional; never null.

Tests: the six test_atom_adp_* tests keep their IDs and now pin location,
error type and message; new tests for the location inside a phase and the
schema rule. SCHEMA_HISTORY updated.

Assisted-by: claude-code:claude-opus-5-5
Core's space-group contract is gemmi's canonical names (A105), and the
symmetry rules (special positions, tie groups, unique axis) are characterized
against this gemmi version. A gemmi release that renames a symbol or changes
an operation would change which recipes are valid, so gemmi is widened only
deliberately, after the symmetry tests and the committed name list
(tests/data/canonical_space_groups.txt, next commit) are re-checked.

pixi.lock: only powderline's own requires_dist copy changes (gemmi 0.7.5 was
already the locked build); `pixi lock --check` reports it up to date.

Re/03b review B4.

Assisted-by: claude-code:claude-opus-5-5
…n; monoclinic symbols

Follow-ups from the independent re/03b review (maintainer answers 2026-10-07).

- Engine subclasses of Phase may not reuse a core validator, private
  attribute or method name (_space_group, _sites_check, _sites, _warnings,
  site, warnings). pydantic collects validators by name, so a same-named
  engine validator silently replaced core's: e.g. a gsasii `_space_group`
  check let "Pm-3m" through, and overriding `_sites_check` dropped every
  symmetry, flag and bound rule. A subclass must also keep extra='forbid'
  (A19). Both raise TypeError when the subclass is defined. (Review A1.)
- Atom labels (and, through the exported CoreName, engine phase names) follow
  one rule every engine can use: an ASCII letter, then ASCII letters, digits
  or '_'. Evidence: GSAS-II renames a phase with surrounding spaces or
  non-ASCII characters and the parameterization then skips it silently;
  easydiffraction rejects any other atom label; TOPAS writes the names into
  its input; phase names become report file names; field paths are
  '.'-joined. check_names_unique_ignoring_case() is for engine payloads'
  phase names (case-insensitive file systems). JSON Schema states the rule
  as propertyNames. (Review C6.)
- In engines with bounds, a stated cell bound must lie where the parameter
  exists: a length's min > 0, an angle's min/max strictly between 0 and 180;
  null still means no bound. (Review C5.)
- A short monoclinic symbol ("P21/c", "C2/m") states neither the unique axis
  nor the cell choice; the error now lists every setting of that space group
  by unique axis instead of suggesting gemmi's silent pick. (Review B3.)
- tests/data/canonical_space_groups.txt pins gemmi's 564 canonical names; a
  test fails if the installed gemmi differs. (Review B4.)
- SCHEMA_HISTORY: the name rule, the bound domain, the monoclinic message and
  the pinned name list.

Suite (default env): see the devkit record.

Assisted-by: claude-code:claude-opus-5-5
… parameter type

Second round of the re/03b review follow-ups (maintainer answers 2026-10-07).

- A115, validation never changes a recipe. The recipe is the record of the
  refinement intent, so validation only accepts or rejects. A value that is
  near the exact one but not equal to it (to floating-point precision) is an
  error that gives the value to write:
  - a special position within 5e-6 (was: replaced by the exact values,
    A73); the 5e-6..2e-3 band error is unchanged;
  - a Uaniso within 1e-6 A^2 of symmetric (was: adjusted, A78);
  - tied bounds near the mapped ones (was: snapped, A108).
  The suggestion keeps each tie group's first member as stated and derives
  the others ("write y = 0.2468", "write U12 = 0.0061735"); fixed values are
  exact. Every value is the repr of the float, so "0.3333333333333333" for
  1/3. N4 is moot. Phase.warnings() and the codes special_position_adjusted,
  adp_symmetry_adjusted and tied_bounds_adjusted are removed. None of the
  67 atoms in the committed examples is affected.
- A114, a phase model needs its parameter type. An engine subclass that
  forgets [P] is a TypeError when it is defined, unless it declares
  Generic[P]. Validating with an open type is a TypeError. Unparametrized,
  one phase could mix [value, flag] and [value, flag, min, max], and F4 then
  crashed.
- A112 amended: names are also not Python keywords and are at most 64
  characters (phase names become file names).
- Tests: removed test_bounded_phase_canonicalization_keeps_flag_and_bounds
  and the two N4 tests. Renamed the adjust/snap tests to *_not_exact_rejected,
  test_tied_cell_bounds_equal_exactly and
  test_phase_holds_the_callers_atoms_unchanged. Added tests for the A114
  guards and the keyword and length rules.
- SCHEMA_HISTORY: exactness, suggested values, the 1/3 spelling, names.

Assisted-by: claude-code:claude-opus-5-5
… (A118)

A Uaniso whose symmetric matrix has a principal value <= 0 describes no
thermal ellipsoid. Like a negative Uiso (A111), it can show that the
parameter compensates for something the model lacks, so it is accepted as
stated. It gets a structured warning, uaniso_not_positive_definite, at
atoms.<label>.Uaniso.

The sign test needs no cell: the Cartesian tensor is a congruence transform
of the Uij matrix (Sylvester's law of inertia). Phase.warnings() returns, for
informative findings only. The warning never comes with a changed value
(A115), so validating the same recipe again gives the same warning.

GSAS-II 5.7.9 has no such check (devkit EB-50).

Assisted-by: claude-code:claude-opus-5-5
…tly (A120)

This refines A115, following the maintainer's rule. A value that has an
exact decimal form is written exactly: 0.5, 0.25, 0.125, coupled
coordinates such as y = 2x, every Uij relation and every cell tie.
0.4999999 is an error ("write 0.5").

A value with no exact decimal form cannot be written exactly. It is stated
to at least 6 decimals (within A73's 5e-6) and read as the exact value. In
crystallographic symmetry this always means a third: 0.333333 = 1/3,
0.166667 = 1/6, or a coupled member y = x + 1/3 and its bounds. This was
checked over all 564 settings and every distinct special site: fixed
denominators are 1, 2, 3, 4, 6, 8 and 12; tie factors are +-1, +-2 and
+-1/2; offset denominators are 1 to 4; Uij offsets are 0.

- The rule is applied per coordinate, from the stated tie-group
  representative, which is never changed.
- The reading carries no warning: it is the documented meaning of the
  recipe. A dump (0.3333333333333333) means the same thing and validates the
  same way.
- The caller's atom is untouched; the phase holds a copy.
- The 5e-6..2e-3 band error is unchanged.
- Errors give the values to write: exact values with repr; thirds to 6
  decimals.

Assisted-by: claude-code:claude-opus-5-5
…/3 in full (A120)

Option A of A120, chosen by the maintainer. The validated model holds the
float nearest 1/3, so tie relations hold exactly and every engine starts from
the same structure. The user's file is never rewritten. Only a Python dump of
the model shows 0.3333333333333333, which is the same value.

Assisted-by: claude-code:claude-opus-5-5
…band message to 6 decimals

re/03b PR review S5 and N3.

- SiteAnalysis.canonical is the stated position moved onto the special
  position by the least (orthogonal) change. It is used to find the
  stabilizer and the tie offsets. It is not what the engines receive: the
  validated core Phase holds those coordinates (A120), and keeps a tie
  group's first member as stated where canonical may move it (by ~1e-7 at a
  y = x + 1/3 tie). The docstring said gateways use it.
- The ambiguous-band message printed the nearby special position's free
  coordinates with .6g, which gives fewer than 6 decimals once |x| >= 1. It
  now prints 6 decimals.

Assisted-by: claude-code:claude-opus-5-5
…rs (A121-A123)

Fixes from the re/03b PR review, as decided by the maintainer. They share
the functions of Phase's validation, so they are one commit.

One rule for symmetry-determined values (A121, option B; S1, S3, Q1, N1-N3,
N7). The first member of each tie group is the independent parameter, kept
exactly as stated. Every other value is derived from the space group and
those parameters in exact rational arithmetic (Fraction), rounded once, and
the validated model holds the derived value, so every tie holds exactly.
How closely the recipe must state a derived value:
- a constant of the site (a fixed coordinate, a Uij fixed at 0) with a
  decimal form exactly (0.5, not 0.4999999; 0, not 1e-9); a third to at
  least 6 decimals (A120, unchanged);
- a value derived from another stated value (a coupled coordinate or Uij,
  a tied member's bounds) within its family's tolerance, coordinates 5e-6
  (A73) and Uij 1e-6 A^2 (A78), read as the derived value.
The ideal C14 6h position (0.833333, 0.666667, 1/4), y = 2x - 1, was an
error ("write y = 0.666666"); it is now read. A third written to 13 digits
is held as 1/3. A value and its bounds are derived together, so a value at
its bound stays within it. One message per parameter set gives the values
derived from the first member, and names equal ties (z = y, U22 = U11).
docs/SCHEMA_HISTORY.md shows users the rule as a table of cases.

Cell (A123; S4). Checked from its tie groups within A77's CELL_TOL (1e-9,
kept), with the metric check behind it. The model holds the first member's
values and exact fixed angles (b = a exactly). The error states the stated
values and the values to write (repr, not %g).

Engine phase guard (A122; S2, N5). A mixin placed before Phase[P] could
replace _space_group or _sites_check silently, and a 'wrap' validator could
skip core. Now:
1. no class in the MRO besides core's own, mixins included, may redeclare a
   core field, reuse a core name, or define a pydantic entry point;
2. every core validator must still be core's own function;
3. an engine's own validators are 'after' validators on core fields and the
   model (a 'before' one could rewrite the recipe before core reads it);
4. tests/test_phase_canary.py runs core's must-fail recipes and a valid
   recipe through every engine phase model (re/04-06 register theirs) and
   re-runs the structural guard on each.
The parameter type must be RefinableParameter or BoundedRefinableParameter
(Phase[float] is a TypeError).

Also:
- the uaniso_not_positive_definite warning gives the Cartesian principal
  mean-square displacements; a near-zero one counts as 0 (A118 amended;
  N4);
- atom labels must differ by more than case (A112 amended; N8).

Tests: 3 renamed (behaviour changed), 6 updated, 38 new IDs; none removed.
Default suite 1518 passed, 4 skipped; easydiff 55 passed. The fuzz over all
564 settings and translates (10,353 sites) gives 0 rejections and the exact
derived float in every case.

Assisted-by: claude-code:claude-opus-5-5
@AdamCorrao
AdamCorrao merged commit 2810c82 into refactor/multi-engine Oct 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant