The agent skill

The operating protocol ships with the package as an Agent Skill, a directory holding a SKILL.md an agent reads whole when the task calls for it and reference files it loads only when it needs one. The protocol is what to free in what order, what to check before believing a number, and how to read an abstention.

The format is an open standard rather than one vendor’s feature, so the same directory is read natively by Claude Code, Codex, Cursor, Copilot, Gemini CLI, opencode, Goose, Cline, Amp, Kiro, Devin, Zed, Junie, Qwen Code and OpenHands, among others. Nothing in it is specific to one of them.

This chapter is the whole skill, rendered from the files the package ships, so it cannot drift from what an agent actually reads.

For agents

Do not read this chapter. Read the skill itself: rietx skill --path prints the directory, capabilities().skill_path is the same answer from Python, and your harness has probably loaded it already. This page exists so a person can see what the machine was told.

Getting it

rietx skill --path              # where this build keeps it
rietx skill --print             # the body, as text
rietx skill --print diagnostics # one reference file
rietx skill --install           # into this repository, for every harness

rietx skill --install [DIR] writes one real copy to DIR/.agents/skills/rietx/, the directory the specification recommends and most harnesses scan. Each harness that reads somewhere else is pointed at it with a relative symlink, so the link survives the project being moved or cloned elsewhere. --agent NAME (repeatable) chooses which, and --list-agents prints the harness table with the source and date each row’s directories were read from. --user installs for the user rather than for a project. --copy copies instead of linking, and Windows gets that in any case.

The command prints, and never writes, the two lines that name the skill in a project’s AGENTS.md or CLAUDE.md. Those files are the project’s own instructions to its agents, and appending to one uninvited would be editing something the package does not own.

For a harness that reads no skills at all, rietx skill --print all is the whole tree as plain text, for a --read flag or a system prompt.

The protocol

Audience: an agent driving rietx on real data. Not a tutorial and not an API reference. A protocol: what to do, in what order, what to check before believing a number, and where the package will tell you your answer is wrong even though it looks right.

Rwp is not the objective function of your job. A refinement can converge, report an excellent Rwp, and return displacement parameters biased by 100 %, phase fractions wrong by 5 wt % and a cell that is right for the wrong reason. Every rule below exists because one of those happened and was measured. Sections 1-4 are ordinary Rietveld discipline that would apply in any code; sections 5-10 are specific to running it with no human at the plot.

Load these when the task calls for them

This file is the judgement core: what holds for every fit. The lookup tables, and the rules one task shape needs, live beside it, loaded on demand, one file each. The user manual holds the object model this protocol drives and is not restated here; a page named x below is https://rietx.org/using/x.html.

When

Load

Manual page

you are about to call rietx: entry points, constructors, the four answer types and their fields, the report

references/api.md

quickstart, model, refining, results, agents

you were handed another program’s input file, not a pattern

references/api.md § In

recipe, files

§7/§7g/§7h — a Diagnostic fired and you need its row: every engine code, a foreign project file’s import-time codes, and what a file you wrote back could not carry

references/diagnostics.md, references/diagnostics-projects.md, references/diagnostics-gsas.md

results

§6 — something declined to answer: abstentions, caveats, gate failures, best_or_none() returning None

references/abstention.md

report

§5 — you are about to quote a number: which field carries which fact, and read numbers rather than pixels

references/numbers.md

report, results

§4/§4b — a judging or deliverable rule needs its measurement, before you override one

references/judging.md

report, qpa, constraints

§8 — the fit did something that makes no sense: twenty-one measured results that contradict an intuition

references/surprises.md

refining

§7b-7f — the phase is unknown, or you want the peaks themselves and no cell: peak picking, fitting peaks you name, indexing, the closed loop, the extinction screen

references/diagnostics-indexing.md

indexing

§9 — one fit is not the answer: the trajectory, and the history DAG as a search structure

references/history.md

history

§9b — an in-situ ramp, a sweep or a tray: chaining N patterns, and checking the chain both ways

references/series.md

series

§9c, deciding: ranking, differencing, auditing, identifiability

references/batch.md

history, series

§9c, operating: budget, cost, timing, the log, inventory, fault tolerance

references/batch-operating.md

history, series

§9d — a human may be watching this fit, or you want to hand one a window onto a long run; also reading a finished run off disk

references/watching.md

cli, refining, files

writing the answer out: CIF, QPA table, reflection table, plots

references/api.md § Out

exports


1. Before you refine: what the method can and cannot do

Rietveld refinement fits a structural model you already believe to a whole powder pattern. It is a local, gradient-based optimisation of a strongly non-convex, strongly correlated problem. It is not structure solution, not phase identification, and not a search.

Preconditions, all of which must hold before fit() is meaningful:

Requirement

How to satisfy it

If you cannot

Every crystalline phase present is in the model

Structure.from_cif per phase

An unmodelled phase’s peaks land in the residual; Layer 0’s unmatched_obs list is how you find them

The starting cell is within ~1 %

from the CIF, or from index_pattern when the phase is unknown (§7d)

The peaks are outside their frozen evaluation windows and the refinement cannot walk there; Layer 2 says so with reindex_or_recheck_cell rather than reporting a small shift (§6)

The wavelength is right

from the beamline .prm, the file header, or Instrument.bragg_brentano(radiation=...)"CrKa", "FeKa", "CoKa", "CuKa", "MoKa", "AgKa", or any of them suffixed 1 for a Kα1-only monochromated beam

Every cell you report is wrong by the same scale factor and nothing in the fit will tell you. Do not hand-enter a wavelength from a textbook to “match” one of these: the table is one scale end to end (§8.11) and mixing scales is a ~100 ppm cell error

The geometry is right

Instrument.debye_scherrer vs .bragg_brentano

The aberration model is wrong; displacement/transparency/roughness/absorption are geometry-gated and silently absent

The intensities are un-manipulated counts, with esds if available

read_pattern reads the file’s esd column when present

Weights are wrong ⇒ every esd and every χ² is wrong

The starting peak width is within a factor of ~2

measure it: median FWHM of the dozen most prominent peaks, then W (FWHM/2)², X FWHM

ProfileTCHZ’s W = 1e-3 deg² default is a synchrotron line (FWHM ≈ 0.03°). On lab data with 0.15-0.40° peaks the frozen evaluation windows are an order of magnitude narrower than the lines, and nothing recovers from that — see §2 and §6

Never subtract a background before refining. Subtraction invalidates the counting-statistics weights and can make intensities negative. Hold an estimated background additively (BackgroundFixedPlusChebyshev) or co-refine it under a smoothness penalty (BackgroundPSpline). rx.auto_background(data) does the right thing.


2. The turn-on order, and why it is not negotiable

Free parameters in groups, cumulatively, in a stable order (McCusker, Von Dreele, Cox, Louër & Scardi, 1999, J. Appl. Cryst. 32, 36), each group running to convergence before the next is freed. The reason is not tradition: the correlations between groups are severe, and a simultaneous release from a poor starting point walks into a local minimum that a staged release avoids. Toby (2024, J. Appl. Cryst. 57, 175, the “recipe problem”): once parameters have refined to unphysical values, adding more parameters no longer lets the fit recover.

The plans encode this. Use them; do not hand-roll a free set unless you have a reason you can state. The staged discipline is what is not negotiable; the preset sequence is a default, because the right next group depends on the data and the current values — and ref.suggest(data) answers that at the current state, one analytic-Jacobian evaluation ranking every held parameter by predicted Δχ², with no fit and no mutation.

plan="mccusker_default"      # scale+bkg → zero → cell → W → U,V,X,Y      (profile only)
plan="mccusker_structural"   # …then coordinates → displacement → PO → extinction → roughness
plan="lab_bragg_brentano"    # …with sample displacement, Kα2 ratio, FCJ axial
plan="lab_calibrate"         # instrument calibration on a standard, certified cell HELD
plan="lab_sample_refine"     # sample against a frozen calibrated instrument
plan="profile_only"          # Le Bail
plan="pawley_default"        # Pawley

rx.PLAN_INFO carries a title, description, modes and when-to-use for each.

Three ordering rules. None is in the guidelines; each is this package’s own measured finding.

  1. Widths last among the profile terms, W before U,V,X,Y. W is the constant term; freeing the tanθ and 1/cosθ terms first lets them absorb a constant offset and then fight it.

  2. Intensity-scaling corrections go last, after the structure has settled. Preferred orientation, extinction and surface roughness rescale intensities in a Q-dependent way, and so do the scale, the occupancies and the displacement parameters. Freeing a correction early lets it eat structure that belongs to the structure.

  3. Anisotropic strain is freed inside the sample-broadening stage, not after. A Stephens block locks lor_strain — its isotropic direction is that column — so deferring it leaves the isotropic width unrefined right up to the moment fifteen correlated coefficients turn on at once.

Structure-free first when you can. Le Bail (mode="lebail") extracts intensities from the data instead of computing them, so it converges the cell, zero and profile with no structural assumption. Do that first, then switch to Rietveld with the converged cell and profile: it is the single most reliable way to avoid a structural minimum that is really a profile error. Its cell is the weaker half of that: where reflections are dense, arbitrary intensities can index one pattern more than one way, so check a Le Bail cell against even a rough structural model before quoting it (Peterson 2005). The variable is reflections per FWHM, so on a resolved pattern skip the check. Two rules about it the API does not tell you, both measured on third-party lab data.

  1. Iterate the whole plan to a fixed point; one fit() is not enough, and keep the best pass, not the last. The extracted per-hkl intensities are frozen inside each least-squares run, so intensities and profile converge only by alternating, and the alternation is not a descent on one objective, so a later pass can come back worse.

  2. Seed the background before the first pass, always. auto_background starts every coefficient at 0.0, so the first lebail_update runs before the background has ever been fitted, is handed the whole pedestal, and gives it to the Bragg reflections. Seed the constant term from a low percentile of y_obs.

Both measurements, and multi-phase Le Bail’s one surviving caveat: references/judging.md.


3. The degeneracies. Memorise these

Almost every wrong-but-good-looking Rietveld result is one of these. They are not bugs; they are the geometry of the problem.

Degenerate group

Their angular signatures

Consequence of getting it wrong

zero shift · sample displacement · cell

const · cosθ · tanθ

Over a narrow 2θ range these are collinear. A cell “refined” against a free zero on 20° of data is not measured. Bragg-Brentano only — the two flat-plate aberrations are held fixed on any other geometry.

zero shift · the two capillary offsets · cell

const · sin2θ · cos2θ · tanθ

The same trap in Debye-Scherrer’s own shapes (McCusker eq 4, §8.18). Separable over 5–160°, not over 5–25°: the unit-column Gram’s smallest eigenvalue is 5.2e-2 against 1.1e-5, a factor of ~4600.

crystallite size · microstrain

1/cosθ · tanθ

Williamson-Hall separability. Over a short range they are one parameter, not two.

phase scale · Biso/ADPs · background · absorption · surface roughness · extinction

all smooth in Q

This is the big one. Every member depresses or lifts intensity as a smooth function of angle. Any of them can absorb any other.

capillary µR · phase scale · Biso

exp(c·sin²θ) — exactly

Not “correlated”: singular. µR is computed from the specimen and never refined, and the fit is identical with and without it (§8.1).

flat-plate µt · phase scale · Biso

mostly, but not exactly

60–99 % absorbable, so it is also computed rather than refined — but the remainder does move Rwp, and a wrong thickness lands partly in the fit and partly in the ADPs (§8.12).

preferred orientation · site occupancy

both rescale specific hkl

An occupancy refined against uncorrected texture is a texture measurement.

overlapped reflection intensities (Pawley/Le Bail)

identical

The sum is determined; the split is not.

a declared extra peak · the reflection under it

identical where they overlap

An extra peak on a reflection is a scale/intensity degeneracy by construction: two terms, one peak, and Rwp cannot say which owns the counts. EXTRA_PEAK_ON_REFLECTION fires. Legitimate when the intruder is real; never a way to make a misfitting reflection go away.

  1. Do not free the second member of a group without checking the first is pinned by something outside the fit. lab_calibrate exists for this: refining a certified standard with its cell held fixed is what decorrelates zero from displacement from cell, because the cell is supplied rather than fitted.

  2. A correlation of 0.98+ means you refined one parameter and reported two. HIGH_CORRELATION fires for you. The right response is almost never “widen the bounds”; it is to fix one, or to extend the data range until the signatures separate.

  3. Where chemistry says two quantities are one quantity, constrain them rather than refining both. ref.tie_equal([paths]) makes an equality group, ref.tie(path, source, scale=, offset=) the general affine form (occ₁ = 1 occ₀ on a mixed site is scale=-1, offset=1), ref.untie releases them.

    A constraint removes a parameter, unlike a restraint, which adds a weighted observation and leaves the count alone, so it is the one move that raises the observation-to-parameter ratio. The two cases worth reaching for are McCusker’s: equal displacement parameters across atoms in the same environment, and occupancies summing to a known total. Measured on fluorapatite’s three phosphate oxygens, tying them gives a B(O) tighter than the best free value.

    Check the premise before you tie, and not with Rwp — it moved by 0.05 % of itself there. The check is in the free refinement: if each free value lies within its own esd of the others, the data does not contradict the claim that they are one parameter. Where they disagree by more than their esds, the atoms are saying they are not in the same environment, and tying them replaces a measurement with an assumption. Symmetry always outranks a user tie, and a refused tie says so by name.


4. Judging a fit — and what Rwp is actually for

Rwp compares your model to the data you have, weighted by counting statistics. It is dominated by the strongest peaks and by the background level: a useful relative number between two fits of the same data over the same channels, and a nearly useless absolute one. Measured, 18 refinements of one identical PbSO₄ dataset returned Rwp 8.2–20.0 % (Hill, 1992, J. Appl. Cryst. 25, 589), and Toby (2006, Powder Diffr. 21, 67) finds “no simple way to distinguish a good fit from one that is just plain wrong based on R factors”.

Judge a fit in this order. print(result) renders steps 9 and 17 (per-stage status, every diagnostic, provenance, agreement indices last), and ref.summary(deliverable=…) adds the rows that need the compiled model. The measured evidence behind each rule is references/judging.md.

  1. Status and guards outrank every statistic. result.status, then result.diagnostics. statistics.max_shift_over_esd is the measured quantity behind “converged” (McCusker §7 converges at ≤ 0.1). A converged solve satisfies it a fortiori, so read it where a stage stopped on STAGE_MAX_ITER: its magnitude says how far the solve was still moving, in esd units.

  2. Read the shape of the difference curve region by region, not its size. report.regions carries per-region local Rwp and χ² share, and cumulative_chi2_breakpoints locates where the model starts failing.

  3. Read the unmatched peaks. report.unmatched with kind="unmatched_obs" is an impurity or a missing phase; "unmatched_calc" is a phase you modelled that is not there, or an absence error. Read unmatched_calc in Rietveld mode only. Le Bail and Pawley extraction takes away most of the residual it looks for, so there it fires on noise near a tick.

  4. Ask whether the refined values are physically possible — negative Biso, occupancies above 1, a cell that moved 0.5 %, an ADP tensor that is not an ellipsoid — and ask it of the structure too. result.geometry is a GeometryTable of bonds, contacts and angles, which McCusker §11 ranks with the profile fit and above every R value, so read it before step 16. Nothing scores it: a Si–O at 1.75 Å or a 60° O–M–O is yours to recognise. The number of rows naming an atom is its coordination number, and a None esd means no covariance behind the row or fixed by symmetry, never zero.

  5. Quote no esd without its inflation. statistics.esd_inflation is the Bérar-Lelann factor for serial correlation, and it has an expected value of ≈1.51 even for perfectly white residuals, so it is an upper bound on the damage rather than a measurement of it. report.identifiability carries the trio to pass on with any esd — raw χ²_red, the inflation, Durbin-Watson — plus the δR line. Scaling variances by GoF² alone is “highly questionable” (Schwarzenbach, 1989).

  6. Ask whether the converged answer is the only one, and settle it by a swap. report.identifiability.exchanges and .soft_modes outrank the statistics, and the verdict that licenses is ambiguous, not converged.

    They are about what “converged” means: converged is a statement about the free set, while an exchangeable=True row says a held parameter’s signature is reproducible inside the fitted span and that a fitted partner stands many σ from its null.

    The swap resolves it and is a measurement: fit each member of the pair alone, the other held at its null, and compare χ² — two warm fits, seconds, and rx.report.compare_rivals(ref, data, finding) runs exactly that. R² cannot stand in for it. Read the outcome on RIVAL_DECISIVE_MIN_CHI2_RATIO (= 1.10, rietx.report), the losing rival’s χ² over the winner’s: at or above it the data has chosen, and you quote the winner without caveat, since hedging a won swap is a measured failure rather than caution; below it the pair is genuinely unresolved and the resolution is protocol or a declared ambiguity. No sentence converts a tie into an answer. The licence also travels as result.statistics.identifiability_clause.

    What you must not do is free the held parameter alongside its partner and refit: both free lands on §3’s degenerate ridge and reports the unconstrained combination at a better Rwp — the most common misreading of the clause.

  7. Read what the background is doing before you read Rwp, because it decides how to read Rwp. In report.background, worst_absorption (with worst_absorption_path) is how much of a structural parameter the background column span can reproduce, and off_region_chi2_reduced with off_region_durbin_watson is whether the residual between the peak regions is systematic. Layer 0’s regions are peak clusters, so that second failure lands in no report.regions entry and step 10 cannot see it.

  8. Only then Rwp and GoF, and never alone — as a pair with background.rwp_background_subtracted. The raw number is flattered by whatever the background carries. The subtracted one is what separates two fits of the same data.

  9. Read the structure R factors last, and never in isolation. result.phase_agreement carries r_bragg (R_B) and r_f (R_F) per phase. A powder pattern does not measure individual reflection intensities, so I(obs) is the pattern partitioned in proportion to I(calc): a wrong model receives the intensity it predicted and both flatter it (Toby 2006: R_Bragg “has no statistical validity”). Watch R_B fall as you improve a model; never read it as evidence a correction helped. Absent in Le Bail and Pawley, where the intensities are the fit. Do not compare a trace phase’s R_B with the major phase’s: neither is weighted, and a minor phase’s windows sit under the major phase’s peaks.

Adding parameters: use ΔBIC, not Hamilton’s R-ratio. Measured at 7251 channels, Hamilton’s test blesses a 0.13 % χ² improvement that is physically inert; ΔBIC carries the sample-size penalty powder channel counts need.

Comparing against another code means adopting its protocol, not just reading its numbers. Mirror its refined-parameter set, its held parameters and its excluded regions, then check the channel count matches before believing any Rwp comparison. Measured: guessing a plausible protocol on the GSAS-II fluorapatite tutorial gave Rwp 16 % and a +390 ppm cell, while mirroring the converged .EXP gave 9.73 % against GSAS’s 10.05 % on an identical 5750 channels.


4b. Declare the deliverable — “good enough” is a question about purpose

Much real work is non-ideal by construction — nanoparticle broadening, intensity error from unknown pore contents. No bar moves for such data: the gates auto-scale to information content, and “good enough” is a different question answered exactly, not a relaxed standard. What changes is which report rows decide your deliverable. Declare it, then read its rows: the report is purpose-neutral and will not infer yours. ref.summary(deliverable=…) prints them for one fit, and a chain’s are on SeriesResult.summary(deliverable="series").

Deliverable

The rows that decide it

Stop when

Phase ID — which phases are present?

report.unmatched (unmatched_obs), report.lebail_gap.ratio: ≫ 1 → every line indexed, safe at any Rwp

no strong unmatched observed peaks and the gap readable, whatever Rwp says. An abstained_kind="resolution_limited" does not block this deliverable

QPA — how much of each?

report.background.absorption, then absorption geometry, then impossible values. lebail_gap inverted: large ratio → wrong fractions

fractions stable under a background-flexibility change, worst_absorption below its threshold, and no unresolved scale- or ZMV-family diagnostic. Never “Rwp stopped falling”, which here points the wrong way

Trajectory — a parameter against T, t, p or composition

SeriesResult.summary(deliverable="series"): SEQUENTIAL_PATH_DEPENDENT, SEQUENTIAL_PERSISTENT_FINDING, SEQUENTIAL_DISCONTINUITY, PHASE_UNCONSTRAINED; 2θ anchor, precision/accuracy split, QPA check every point

every number you quote names the one thing that would have to be wrong for it to be wrong, and that thing has been checked

Microstructure — how big are the domains, how strained?

result.microstructure: domain size (Å), Δd/d, esd or MicrostructureTerm.unavailable; separable, size_agreement (1 = one), SIZE_UNUSUALLY_SMALL/STRAIN_UNUSUALLY_LARGE/BOUND_HIT

separable is True, both readings agree, and you quote the size as an order of magnitude with the scherrer_k the block carries. A separable=False is not a smaller number to quote, it is a wider 2θ range to collect

Structure — where are the atoms?

Above, plus report.texture, report.strain, restraint tension, ADP positive-definiteness, report.identifiability.exchanges/.soft_modes. Le Bail gap: blocker, intensity model carries the claim

§10’s full ladder, with no exchangeable row unaddressed. Addressed means the swap was run and either won (adopt the winner and quote it without caveat) or tied (resolve by protocol). Never by freeing the rival into the same fit

The QPA row outranks every statistic beside it: an over-flexible background wins on every agreement index while biasing displacement parameters to 0.958 and 0.000 Ų against a truth of 0.5, and worst_absorption (0.46 against 0.08) is the only row separating the two fits — the plot does not either.

resolution_limited is a stopping point, not a failure: the edit directions are indistinguishable on merged peaks, not the model wrong — a legitimate end state for phase-ID work; for structure-grade work, collect better data.

The capability floor. Verify before acting (rx.report.predict_then_verify, or a history branch), treat a capped confidence as an unresolved question rather than a low-priority instruction, and never execute a vetoed action. There is no ceiling: the report supplies evidence, judgement stays with the reader.

Every deliverable’s worked measurement, and the round robins’ two QPA rules: references/judging.md.


6. Abstention is a result. Do not convert it into a number

The package’s hardest rule is never return a confident wrong singleton. Several places will decline to answer. When they do, that is the answer.

  1. Propagate an abstention; do not paper over it. report.abstained_reason set means the global maturity gate refused Layer 1 — branch on abstained_kind first, and do not read attribution. INDEX_ABSTAINED means the candidates are there so you can see what was considered, not so you can pick one.

  2. Never take candidates[0] because it is ranked first. The ranking orders the hypotheses, the gate judges them, and the two are different questions. IndexingResult.best_or_none() returning None is the most likely outcome of a first indexing run and is not a failure: read each candidate’s confidence_caveats and act on the refuting ones first.

  3. A failed gate is not a cause. region.gates_passed is False means the coefficients are present for transparency only; read region.gate_failures, whose codes are closed and typed.

  4. Two collinear templates are not one answer. A trend reported non-separable, a PAWLEY_OVERLAP_UNRESOLVED group, an INDEX_GEOMETRIC_AMBIGUITY, an EXTINCTION_GROUPS_NOT_SEPARABLE: the information is absent from the measurement, not buried in noise. Extend the range, report both, or carry the whole list forward — a group’s sum is the datum, and no counting time separates space groups differing only by elements that produce no absences.

  5. A held or unquotable value is not a measurement. PHASE_UNCONSTRAINED, STEPHENS_STRAIN_NOT_POSITIVE, BOUND_HIT and HARMONIC_HELD each say a number in the result did not come from the data. Their esds do not make them measurements, and a good Rwp does not cover them: a parameter that does not move y_calc does not move Rwp either.

Every signal, its meaning and its correct response: references/abstention.md.


10. A worked default

A lab pattern, a CIF and no other information. Adapt, do not skip the checks — the right order depends on the data and the starting values (Toby, 2024).

import rietx as rx

data       = rx.read_pattern("sample.xy")
structure  = rx.Structure.from_cif("phase.cif")
instrument = rx.Instrument.bragg_brentano(radiation="CuKa",
                                          monochromator_two_theta=26.6)
instrument.background = rx.auto_background(data)

ref = rx.Refinement(structure, instrument, history="session.jsonl")

# 1. structure-free first: cell + profile without any structural assumption
ref.fit(data, mode="lebail", plan="profile_only")

# 2. Rietveld from the converged cell/profile
result = ref.fit(data, plan="lab_bragg_brentano")

# 3. the termination view: per-stage status, diagnostics, agreement indices last
print(result)

# 4. guards outrank statistics, and each one carries what to do about it
for d in result.diagnostics:
    print(d.level, d.code, d.where, d.message, "->", d.suggestion)

# 5. numbers, not pixels
report = ref.report(plan="lab_bragg_brentano")
if report.abstained_reason:
    print("Layer 1 abstained:", report.abstained_reason, report.abstained_kind)
else:
    for r in report.attribution:
        if r.gates_passed:
            print(r.two_theta_lo, r.two_theta_hi,
                  [(c.kind, c.value, c.stderr, c.share) for c in r.coefficients])

# 6. impurities / missing phases
print([u for u in report.unmatched if u.kind == "unmatched_obs"])

# 7. the whole judgement in one call, for a declared purpose
print(ref.summary(deliverable="structure"))

The three stop conditions. Stop refining when

  1. every diagnostic is understood and either resolved or reported as a caveat;

  2. Layer 1 attributes no remaining region above the significance gate; and

  3. adding the next parameter group fails a ΔBIC test or trips a guard.

Do not stop merely because Rwp stopped falling, and do not continue merely because it is still falling. These are the structure-grade conditions; §4b maps the earlier stopping points a declared phase-ID or QPA deliverable is entitled to.

What to report. The refined values with their (inflated) esds, the diagnostics you could not resolve named as systematics, the protocol you actually ran (plan, held parameters, excluded ranges, channel count), and the package version, backend and solver from result.provenance. A number without its protocol is not a measurement.


The API

There is one integration surface and it is the Python API. A caller runs a verb, reads the typed answer, and dumps it with model_dump(mode="json") when a file is wanted. A failure raises: there is no envelope and no error code.

A RefinementCancelled you did not request is not a bug in your call. Every fit records itself, and a human watching one can stop it from rietx watch. The stages that finished are kept. .completed_stages and .node_id say where the work stands, and that node id is a checkout target. Report where you got to. Do not re-run the fit as though it had failed.

Do not quote a signature from memory. rx.capabilities() says what this build supports, rx.help_for(path) says what a parameter is, and inspect.signature(obj) or help(obj) gives any call’s arguments. The names are references/api.md, every one of them checked against the installed package by test.

See also

  • The manual, Part 2 (theory): https://rietx.org/manual.html — every equation with its source, and the bibliography each author-year citation below resolves in

  • The repository, https://github.com/yue-here/rietx: README.md, docs/DESIGN.md (why the FitReport is shaped this way), tests/data/README.md (provenance of every bundled dataset); none ships in the wheel

Reference: 4/4b. The evidence behind the judging rules

Load it when a rule in §4 (judging a fit) or §4b (declaring the deliverable) needs its measurement — before overriding one, and before quoting a number whose trustworthiness the rule decides.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

Step 9 — what max_shift_over_esd separates

McCusker et al. (1999) §7 calls a refinement converged when max|Δθ|/esd ≤ 0.1. The band is quoted from the paper and gates nothing here. A converged solve satisfies it a fortiori, so the number earns its keep on the other branch: where a stage stopped on STAGE_MAX_ITER, its magnitude says how far the solve was still moving in esd units, which separates “nearly there” (just over the band) from a fit that stopped mid-flight — measured ≈14 on one starved iteration.

Step 12 — the geometry table’s esds

stderr is propagated through the whole covariance, which McCusker §10 requires of any derived quantity, and stderr_diagonal beside it is what ignoring the correlations would have given. Quote the first; use the pair when you need to say how much the correlations mattered (measured on 11-BM NAC, dropping them moves an esd by ×0.71 to ×1.15, in both directions, so a diagonal esd is not the conservative choice).

None covers all four ways a number is unavailable: no covariance behind the row (an evaluate-only pass), no free source, a quadratic form that reaches zero by cancelling, and a straight angle where linear propagation does not hold. An esd of 0 on a symmetry-fixed 90° angle would be a claim about precision rather than a statement about constraint.

write_refinement_cif writes the whole table as _geom_bond_*, _geom_contact_* and _geom_angle_* loops, with the symmetry codes resolvable against the _space_group_symop_operation_xyz loop it writes beside them.

§2 rules 4-5 — the two Le Bail measurements

Iterate to a fixed point, and keep the best pass. On PbSO4 pass 1 stops at Rwp 20.756 % with an unphysical Caglioti V = +0.0615; passes 2-4 reach 10.247 % with the curve sane. Re-run the plan until Rwp stops moving. Because the alternation is not a descent on one objective, a later pass can come back worse — seen on Tb2BaCoO5, 17.3 % → 18.7 %.

Seed the background first. auto_background chooses the knot spacing or the Chebyshev order but starts every coefficient at 0.0, so the modelled background is identically zero and the first lebail_update runs before the background has ever been fitted. The partition is then handed max(y_obs 0, 0) — the whole pedestal — and gives it to the Bragg reflections. Measured on a synthetic pattern whose background is 5× its strongest peak: cycle one claims 571× the true Bragg intensity.

The cell is the weaker half. Peterson (2005, Powder Diffr. 20, 14) fitted triclinic tricalcium silicate at NSLS X7A. The Le Bail fit won on both indices reported (χ² 16.46 against 22.81, R_p 2.34 against 3.52 at ambient) and its cell parameters wandered over a heating and cooling series, while the Rietveld cell from an admittedly imperfect model stayed consistent. Any single Le Bail fit there looked acceptable, and only the trend exposed it. The cause is reflection density rather than low symmetry alone, and the same disagreement has been reported on an I4/mmm structure. Peterson’s own scope is “particularly for high reflection density data”, and the abstract says so.

Measured at the other end of that variable, one range and one instrument treatment, three modes: 11-BM LaB₆, 2-40°, 55 reflections, mean FWHM 0.0097°, 0.014 reflections per FWHM. Rietveld a = 4.1568414(52) Å, Le Bail 4.1568425(51), Pawley 4.1568431(51) — a spread of 0.4 ppm, a third of one esd, with the esds equal to 2 %. The Le Bail fit also won on Rwp there (0.0861 against 0.0879) and its cell was still right, so the better Rwp is not itself the signal. Count reflections per FWHM: crowded, check the cell against a structural model; resolved, do not spend the fit.

Multi-phase Le Bail was broken until v1.0 and is now supported: the shares sum to 1 across all phases at every channel (measured Σ calculated / Σ observed excess 1.79 → 1.0000 on LaB₆ + CaF₂, single-phase path bit-identical). One caveat survives the fix and is about the method: the intensities of two phases whose reflections coincide are not separately determined by the data, since the partition splits them by the current model, which is a starting value and not a measurement. Treat a high multi-phase Le Bail Rwp as a reason to check the seeding above.

§3 rule 8 — what tying three oxygens actually bought

Fluorapatite’s three phosphate oxygens, tied as one displacement parameter: 20 → 18 free parameters, 287.5 → 319.4 observations per parameter, and B(O) 0.2763(1810) / 0.5279(1911) / 0.4149(1282) Ų free against 0.4138(899) Ų tied — tighter than the best of the three. Rwp moved by 0.05 % of itself, which is why it can tell you neither that the constraint helped nor that it hurt.

Every tie is recorded as a set_tie history node and restored by a checkout, so a constrained protocol replays as one. Symmetry always outranks a user tie: a cell axis the space group already ties, a coordinate behind its site-symmetry direction, and a lebail/pawley mode-fixed path are refused by name rather than silently ignored.

Step 13 — why the inflation is an upper bound, and why the trio travels

The Bérar-Lelann factor has an expected value of ≈1.51 even for perfectly white residuals. That is a house derivation rather than the paper’s: chance same-sign runs give E[χ²′]/χ² = 1 + 4/π (optimize.statistics.berar_lelann_factor, simulation-verified). So it is an upper bound on the damage, not a measurement of it.

report.identifiability quotes the qualifying trio side by side — raw χ²_red, the inflation (already in every quoted esd, dividable back out), Durbin-Watson — plus the δR line (delta_r_slope / delta_r_intercept: sorted Δ/σ against normal quantiles; slope ≈ 1 and intercept ≈ 0 on honest σ, slope > 1 when σ is underestimated).

The round robins measured why the ingredients matter: the same data refined under different protocols spread by up to ×17–25 of the quoted esds on cell dimensions (Hill, 1992; Hill & Cranswick, 1994, J. Appl. Cryst. 27, 802), whose explanation is §3’s first degeneracy row — the cell compensating 2θ-scale errors. Durbin-Watson is in the trio because serial correlation is precisely what makes the raw esds untrustworthy, and d stays discriminating where Rwp and GoF do not (Hill & Flack, 1987, J. Appl. Cryst. 20, 356).

Step 14 — the exchange row, the swap, and the ridge

The row and its verdict. An exchangeable=True row says a held parameter’s signature is reproducible inside the fitted span (r2 → 1) and that a fitted partner stands many σ from its null. An E2-shaped answer reads “converged, but the fitted zero_shift is exchangeable with the held sample_displacement — this fit cannot tell you which is physical”. Measured, the fit carrying a planted displacement inside a compensating zero and its clean reference differ in nothing but this row: χ²_red 1.012 against 1.010, R² identical to six decimals, and only the partner’s 128σ against 1.6σ separates them.

Why R² cannot decide it. R² is a geometric statement about column overlap and cannot say whether the counts in hand separate the pair. On real SRM 660c an R² of 0.9977 pair comes apart decisively: χ² 4.0752 (zero only) against 3.4890 (displacement only) on 5332 points, with the zero-only model biasing a by +100 ppm.

The decision band, and hedging a won swap. RIVAL_DECISIVE_MIN_CHI2_RATIO is 1.10, read on the losing rival’s χ² over the winning rival’s. On the round-3 eval’s solvable control (rivals decisive at 1.1679, the SRM 660c pair above) the agents that ran the swap recovered the true displacement and still declined or hedged the answer: the control went 0/7 valid. Below the band the pair is genuinely unresolved — the two real tie states measure 1.0075 and 1.0001.

Why the clause is on the statistics block as well as in the summary. Measured consumers pipe the answer to a file and grep the statistics back, and the summary string is what those greps drop: the licence reached agent context in 2 of 12 cells from the summary, and 4 of 4 from the statistics block once placed there. None there means no report was built or nothing crossed a comment threshold, never a verdict.

The ridge. Freeing the held parameter alongside its partner and refitting lands on §3’s degenerate ridge and reports the unconstrained combination at a better Rwp. Measured over 30 agent runs, seven of twenty position cells took it. The swap runs each rival alone; the ridge runs them together.

Step 16 — what background-subtracted Rwp separates

A sharp LaB₆ fit and one under 0.6° of broadening both report Rwp 0.0137, and background-subtracted they read 0.0490 and 0.0766. Raw Rwp is flattered by whatever the background carries — 89 % of the observed intensity in both — so the number that separates two fits is the subtracted one. The literature says the same twice: Toby (2006, Fig. 1) shows identical model discrepancies reading Rwp 23 % with no background and 3.5 % with one, and Hill’s 1992 round robin recommends quoting the background-subtracted forms for exactly this comparison. It is published on every report and deliberately never mentioned in summary, because every background-dominated pattern would trigger it, converged ones included.

Step 17 — a trace phase’s R_B

Neither index is weighted, so a reflection the fit barely constrains weighs as much as one that dominates it — the weighted R_WI of Cox & Papoular (1996, Mater. Sci. Forum 228–231, 233) exists to answer exactly this and is not computed here — and a minor phase’s windows sit under the major phase’s peaks, where the counts the major phase failed to describe are handed out too. Measured on 11-BM NAC with 1.35 wt % CaF₂: 0.052 for the major phase against 0.385 for the impurity, all of the latter in four reflections at I(obs)/I(calc) ≈ 2.2, each under a strong NAC peak. Read it beside qpa.phases[].weight_fraction, and treat a trace phase’s value as a question rather than a measurement.

write_refinement_cif writes them as _refine_ls_R_I_factor and _refine_ls_R_factor_all on each phase’s own block, beside a _pd_proc_ls_special_details that states the esd method in full — the base estimator √diag(χ²_red·(JᵀJ)⁻¹), then the Bérar-Lelann factor it was multiplied by, which §10 of the guidelines requires any publication to state.

§4b — Phase ID: the unmatched list and the Le Bail-gap read

report.unmatched’s kind="unmatched_obs" entries are the strong lines your phase set does not produce — an unmodelled phase’s signature, distinct from resolution-limited noise. report.lebail_gap is the structural-against-profile triage: it re-partitions the per-hkl intensities at the frozen converged state and reports both Rwp. A ratio ≫ 1 means positions and profile alone account for the pattern, so every line is indexed and phase identification is safe at any absolute Rwp — the structural model does not have to fit well for the phase list to be right.

unmatched_calc asks the same question from the other side and only answers it in Rietveld mode. Le Bail and Pawley extraction takes each intensity from max(y_obs y_bkg, 0), so a reflection with nothing under it is assigned nothing and most of the residual the detector looks for goes with it. What survives is a noise excursion near a tick. Measured on a synthetic LaB₆ pattern (WP-1024): 17 of the certified cell’s own 28 reflections and 94 of a doubled cell’s 153, 61 % either way, so it does not separate them at all. The count that does is LeBailValidation.predicted_but_absent, which integrates net intensity above the fitted background over each predicted position. The blind direction is the one de Wolff’s M₂₀ has, and it is why Oishi-Tomiyasu (2013, J. Appl. Cryst. 46, 1277) reversed the figure.

The clip at zero is rietx’s, and it changes what a bad background looks like. Unrectified, the method returns negative intensities wherever the background is overestimated, which David & Sivia (2002, Structure Determination from Powder Diffraction Data, ch. 8) found and Le Bail’s retrospection passes on. rietx partitions max(y_obs y_bkg, 0), so a background set too high hides in intensities pinned at nothing. A background driven too low is the documented pathology on the other side: absent_reflections found 0 of 163 absences on a wrong candidate whose co-refined background had gone negative.

§4b — the QPA background measurement in full

Fractions ride on scales, so the rows that decide a QPA are the ones that bias scales silently. report.background.absorption, keyed by parameter path, is the detector: the block projection R² of each structural parameter’s Jacobian column onto the background column span sees §3’s scale↔Biso↔background degeneracy that a pairwise ρ cannot. A negative Biso is that same error laundered through a scale. The Le Bail gap reads the other way here than for phase ID: a large ratio means the intensity model is wrong, and wrong intensities are wrong fractions.

LaB₆, broad peaks, same data both times. Fitted with a 1°-knot unpenalized spline the refinement reports Rwp 0.08852 and GoF 1.022, against 0.08969 and 1.025 with a correct Chebyshev-6 — the wrong background wins on every agreement index — and its displacement parameters come back 0.958 and 0.000 Ų against a truth of 0.5, one of them on its bound, where the correct background gives 0.691 and 0.327. worst_absorption reads 0.46 against 0.08. Nothing else in the report distinguishes these two fits, and the plot does not either: the over-flexible residual is white noise inside ±3σ.

The whole report.background.absorption table is published rather than only the entries over BACKGROUND_ABSORPTION_NOTABLE, because a fired/not-fired bit is a verdict and the diagnostic already carries the verdict. A pairwise ρ misses the effect entirely: ~0.2 per coefficient while the block absorbed 46 %.

§4b — the two QPA rules the round robins own

Madsen et al., 2001, J. Appl. Cryst. 34, 409; Scarlett et al., 2002, J. Appl. Cryst. 35, 383.

  • A Rietveld σ(W) reflects only the fit’s mathematical precision and is “not necessarily related to the accuracy”. Judge a fraction against the published participant spread, never against its own esd. (This is the policy the repository’s tests/data/README.md applies to the bundled qarr/ patterns, which are the round robin’s own samples.)

  • Microabsorption is the largest physical obstacle to X-ray QPA — “may prove to be insurmountable in some circumstances” — and a Brindley correction applied where none is needed reduces accuracy (their sample 1 and synthetic bauxite; BRINDLEY_OUTSIDE_REGIME).

§4b — the worked example that stops at GoF 2.97

LaB₆ pore proxy: a guest scatterer at the 1b site in the data only, host model refined to convergence. Rietveld Rwp 0.0405, GoF 2.97 — a “bad fit” by GoF. The report gives zero suggested actions; intensity carries 83 % of the misfit in per-region errors of 9–18 % with alternating sign ((100) low, (110) high, (111) low — structure-factor interference, which scale, ADP and texture cannot produce, and the summary names it as un-modelled scattering contents); and lebail_gap.rwp_lebail reads 0.0170 against 0.0405, a ratio of ×2.4.

Read by deliverable: phase ID is done — stop, at GoF 2.97. A structure determination is not, and its next move is chemistry (what occupies the pores), never finer profile corrections, which this evidence says cannot help.

§4b — why no bar moves for non-ideal data

The gates auto-scale to information content. Measured: a zero error read at confidence 0.997 on sharp data produces silence, GoF 1.02, on the same error under 0.6° of broadening. Pushing finer corrections into a fit whose attribution is resolution-limited changes numbers it cannot justify.

§4b — the trajectory deliverable, and where its rows come from

Four codes on SeriesResult.summary(deliverable="series") carry the chain’s own rows. SEQUENTIAL_PATH_DEPENDENT is an ordering artefact — only a direction="both" chain can produce one, so a one-way chain has not looked. SEQUENTIAL_PERSISTENT_FINDING states a persistent count no per-pattern code can, measured at 42 of 68 patterns on this ramp. SEQUENTIAL_DISCONTINUITY flags either the science or a chain failure, and verify_discontinuities=True tells them apart (below). PHASE_UNCONSTRAINED says the value is held, not measured, so its trajectory is the one you handed in — then the QPA deliverable’s own background check, applied at every point along the chain: an absent phase took 40–96 wt % at equal Rwp.

The row exists because an agent needed it and wrote it itself. Given 68 patterns of a variable-temperature ramp, rietx, and “tell me what the cell does, and flag anything you would not quote”, a run recorded in full built its own stopping rules and they were the right ones: model selection by ΔBIC and the rival χ² ratio rather than by Rwp; the step at 430 → 440 °C verified by an independent cold refit of both patterns; tan θ against cos θ to tell a cell change from a specimen-height jump; a CaF₂ impurity used as an internal standard to bound 2θ drift to ±27 ppm over 280 K; and the explicit split — precision on the shape of a(T) at ±0.00015 Å, no accuracy claim on the absolute beyond ~100 ppm, because nothing pinned the 2θ scale. About 34 of its 90 calls went into those checks. None of them is a fit statistic, and §4b named none of them.

Two are now the package’s:

  • The cold-refit check is verify_discontinuities=True. Each flagged step’s two patterns are refitted cold and independently, and the diagnostic’s value becomes the cold step over the chain’s — 1.0 in the data, 0 the chain’s own. Measured on that ramp, reproducing the run’s own protocol: the chain takes 11.6–12.0 s and the check adds 5 % (12.1–12.2 s) for four flagged steps over four patterns, and the real transition reproduces at 1.00. The cost scales with the patterns flagged, not with the series length.

  • The rows are SeriesResult.summary(deliverable="series"). In the same re-run, PHASE_UNCONSTRAINED fires on the impurity’s cell in 40 of 68 patterns and the trajectory of a held value is not a measurement — which is exactly the “would not quote” list the agent arrived at by hand.

The two the package cannot supply stay the caller’s, and the row says so rather than leaving them blank: nothing in a pattern file records what pinned the 2θ scale, and no esd can tell you it is a precision on the shape rather than an accuracy on the absolute.

Microstructure: what a domain size is, and what it is not

result.microstructure reads each of a phase’s four sample-broadening coefficients as the quantity behind it — a coherent domain size in Å from the two 1/cosθ terms, a dimensionless Δd/d from the two tanθ terms — with an esd, or a named reason there is none (MicrostructureTerm.unavailable: at_zero, no_wavelength, not_measured).

A domain size is not a particle size. Phase.particle_radius_um is a different quantity for a different correction, the absorption path through a grain; profile broadening measures the coherent domain inside it, which is smaller and unrelated. Reporting one as the other is the commonest way to misread this block.

It is not a two-figure number either. The Scherrer constant moves 10-20 % with crystallite shape, and the codes disagree about which constant and which measure of breadth: rietx reads the FWHM with K = 0.9, GSAS-II the FWHM with K = 1, FullProf the integral breadth — which for a pure Lorentzian is 1.571× rietx’s answer. Quote the size as an order of magnitude with the scherrer_k the block carries, and quote a Δd/d with its convention (rietx’s is the FWHM of the d-spacing distribution; GSAS-II’s mustrain is twice it, FullProf’s apparent strain half of it before the breadth factor).

Read separable before either number. Over a short 2θ range 1/cosθ and tanθ are one curve, so the fit trades a size against a strain at no cost in Rwp. PhaseMicrostructure.separable is the width trend’s own verdict and size_strain_collinearity the correlation it was decided on. False is not a smaller number to quote — it is a wider range to collect. Refining one of the pair and holding the other is not a workaround: the answer then depends on which was held.

Then read size_agreement. rietx registers the Gaussian and Lorentzian halves of each mechanism as independent columns, where GSAS-II refines one magnitude and a mixing coefficient. Nothing makes them agree, so the ratio is a measurement: far from 1 means the two describe different specimens and neither is quotable alone.

In a joint fit, read the size and never the degrees. A size coefficient is proportional to λ, so MultiHistogramRefinement normalises the shared column and reports it at histogram 0’s wavelength, saying so through SIZE_NORMALISED_ACROSS_WAVELENGTHS. Each histogram’s own structure copy carries the coefficient it needs; the crystallite behind them is one number.

SIZE_UNUSUALLY_SMALL and STRAIN_UNUSUALLY_LARGE flag a coefficient outside its usual range, and BOUND_HIT on the same path says why it stopped there: a width term pinned to a bound is a held value, not a measurement, and reads no differently in the table until you check for it.

§4b — Structure: why the intensity-model rows are not optional

Structure determination needs everything Phase ID, QPA, Trajectory and Microstructure check, plus the rows that speak to the intensity model directly: per-region intensity coefficients and their angular trends, report.texture and report.strain with their caveats, restraint tension, ADP positive-definiteness, and report.identifiability.exchanges with .soft_modes (step 14, above). Here a notable Le Bail gap is a blocker, not a comfort the way it can be for phase ID: the intensity model is the structural claim, and a large gap says that model does not carry the pattern, whatever the profile-only fit’s Rwp says.

Reference: 5. Read numbers, not pixels

Load it when a fit has converged and you are about to quote, compare or believe one of its numbers.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

This is the design premise of the package and the first thing that changes when the operator is an agent.

A human judges a fit by looking at it, especially at peak-shape misfit. A vision model cannot do that reliably: frontier VLMs fail precise value extraction from dense plots (the CharXiv, ChartMuseum and ExChart benchmarks), and one PNG costs ~1000–1600 tokens — about the same as 50 regions of exact numbers. All three prior agentic Rietveld systems (AgentBuild, Rongzai, guillemot — the repository’s docs/DESIGN.md § “Outputs & fit assessment” holds the survey) fed plot images to a VLM and all three report the same failure: locally bad, globally fine fits that the image hides.

So:

report = ref.report(plan="lab_bragg_brentano")   # the plan supplies the Layer-2 veto
  • Layer 0 — model-free, always trustworthy: regions, per-region χ² share, cumulative-χ² breakpoints, unmatched peaks. Use this when the fit is bad.

  • Layer 1 — gated linear attribution: per region, how much of the misfit is a position error, a width error, an intensity error, a mixing error, an asymmetry error, with esds and each term’s share of the explained misfit.

  • Layer 2 — typed suggested actions from a closed enum, each with a confidence, a rationale, alternatives, and vetoed_by.

The action vocabulary is closed (ActionKind, versioned by report_thresholds_version), and each kind is carried out one of three ways — how, quoted from the package’s own recipe table (report/apply.py) and stamped on every emitted action as SuggestedAction.execution (WP-1106), so a JSON consumer reads it beside the numbers rather than from this table alone: stage (one run_stage over the action’s globs), index (a search, not a stage), or advice (no verb — the note is the deliverable, and parameter_paths is empty by design, not by omission). The table is every member; emission conditions are the measured ones as of this writing and their moves are logged in the schema version history:

Kind

How

Emitted when

parameter_paths

refine_zero_shift

stage

the constant position template is significant (any geometry)

instrument.zero_shift

refine_sample_displacement

stage

the cos_theta position template is significant — Bragg-Brentano only (a capillary has no such aberration, WP-1073)

instrument.geometry.sample_displacement

refine_sample_transparency

stage

the sin_2theta position template is significant — Bragg-Brentano only

instrument.geometry.sample_transparency

refine_capillary_offset_along_beam

stage

the sin_2theta position template is significant — Debye-Scherrer only

instrument.geometry.capillary_offset_along_beam

refine_capillary_offset_across_beam

stage

the cos_2theta position template is significant — Debye-Scherrer only

instrument.geometry.capillary_offset_across_beam

refine_cell

stage

the tan_theta position template is significant (every geometry)

phases.*.cell.*

refine_profile_widths

stage

a width template is significant — always as the instrument-side peer of the sample action, at half its confidence, because a width trend alone cannot separate the two sides (the instrument’s Gaussian polynomial spans the same shapes; Toby 2024 §4’s U/V/W example). Try the sample terms first; reach for this when they leave the trend standing (measured: the sample proxy stalls at χ²_red 4.3 on a planted Gaussian deficit, this action takes the same state to the 1.01 noise floor — WP-1106)

instrument.profile.u, …v, …w — the Gaussian half only: a Lorentzian instrument width error is column-degenerate with phases.*.lor_size/…lor_strain, so the sample actions absorb it exactly

refine_sample_size_broadening

stage

the inv_cos_theta width template is significant

phases.*.lor_size

refine_sample_strain_broadening

stage

the tan_theta width template is significant

phases.*.lor_strain

refine_axial_asymmetry

stage

a significant asymmetry coefficient in gated regions below 2θ = 40°

instrument.geometry.axial_sl, …axial_hl

refine_biso

stage

the relative intensity error trends with sin²θ/λ² — the ADP signature

phases.*.atoms.*.biso

refine_preferred_orientation

stage

TextureAnalysis.detected with a best axis (both sides of the maturity gate); capped below a coexisting impurity call (§6’s caveat row)

phases.N.preferred_orientation.r — named even when the phase declares no such block, on purpose: the rationale says which axis to declare first, and freeing nothing rolls back

refine_scale

stage

the constant intensity template is significant — an angle-independent scale error

phases.*.scale

add_impurity_phase

advice

strong unmatched observed peaks (> 8σ) not explained by the position-error evidence; when every one matches that evidence it is still emitted, capped at 0.3 with reindex_or_recheck_cell first among alternatives (§6)

empty by design — no phase is named yet, so there is nothing to free; the note says what to do instead

increase_background_flexibility

advice

between-peak misfit is systematic (high off-region χ²_red at low Durbin-Watson) — the too-stiff detector; capped at 0.6 however strong the evidence (§7’s code block has why)

empty by design — the edit is to the background’s shape, not to the free set; instrument.background.* would read as “free the background”, which every plan already does

decrease_background_flexibility

advice

the background column span reproduces a notable share of a structural parameter (report.background.worst_absorption) — the too-flexible detector

empty by design, same reason

reindex_or_recheck_cell

index

validity-radius failures are widespread among the misfitting regions — and it survives abstention, where it matters most (§6)

phases.*.cell.*, but the verb is a search over cells, not a stage over parameters

collect_better_data

advice

the abstention classifier read the fit as resolution_limited (§6) — the one state whose remedy is the beamline, emitted at 0.5 so the data-quality reading outranks a phantom-impurity call. Its rationale carries the fork the evidence cannot resolve: instrumental breadth means better data exists; specimen breadth (nanocrystallites) means no re-measurement helps and the remedy is fewer free parameters and restraints. A PATTERN_UNDERSAMPLED-conditioned emission was measured and rejected — every bundled synthetic fixture trips that diagnostic beside converged GoF ≈ 1.01 fits (WP-1106)

empty by design — no parameter can be freed when the pattern itself is the limit

And read it at more than one state. A report describes the state it was built at, and the state a staged plan finishes in is routinely the least informative one in the run: a compensated fit arrives somewhere that looks converged and suggests nothing, because a real error has been absorbed into whatever parameter the plan did free. Measured on the WP-1053 fixtures — a −0.02 mm sample displacement, which no mccusker_default stage frees — the final report reads Rwp 0.0137 with an empty action list, while the same plan’s first stage names refine_sample_displacement at confidence 0.997. Nothing was hidden; only the last state was ever delivered. So take the trajectory (§9), and treat a rung’s high-confidence action as evidence about the specimen even when the final report is silent.

Images are secondary evidence. plot_for_vlm() exists and renders what VLMs can read (annotated multi-panel montage, worst regions auto-zoomed, Δ/σ panel, high contrast, never JPEG) — use it to sanity-check a conclusion you already reached from numbers, not to reach one. The Δ/σ panel is the literature’s own recommendation for human plots too (Toby, 2024: the weighted difference shows the weighting, stops intense regions dominating with statistically insignificant deviations, and sits on an absolute scale with expectation 1).

Reference: 6. Abstention is a result. Do not convert it into a number

Load it when something declined to answer — an abstention, a caveat, a failed gate, a best_or_none() that came back None.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

The package’s hardest rule is never return a confident wrong singleton. Several places will decline to answer. When they do, that is the answer — propagate it, do not paper over it.

Signal

Meaning

Correct agent response

report.abstained_reason is set

The global maturity gate refused Layer 1: the model is too far from converged for linearisation to mean anything

Branch on abstained_kind first (WP-1057). "immature" / "unreadable": fix the fit using Layer 0; do not read attribution. "resolution_limited": the basis explains the failing regions (median local R² ≳ 0.9) but its edit directions are indistinguishable on merged peaks — this is a statement about the data’s resolution, not evidence the model is wrong, and for phase-ID-grade work a legitimate stopping point (§4b). Either way the actions that survive abstention (WP-1054) are the model-free ones plus the position-family pointer: when most misfitting regions have offsets beyond the linearisation radius, reindex_or_recheck_cell leads the list with the calibration candidates in alternatives — the same signature comes from a wrong cell and a gross zero/displacement error, and the data has not chosen. An add_impurity_phase at its capped 0.3 with reindex_or_recheck_cell first among alternatives means every “unmatched” peak matches the position-error evidence (displaced pairs / residual lobes) — do not add a phase on it

TextureAnalysis.caveat is set

Strong unmatched observed peaks coexist with the texture detection, and the per-reflection extraction partitions un-modelled intensity onto its calculated neighbours — an impurity can manufacture exactly this signature

The detection measures the residual, not necessarily the specimen. Resolve the unmatched peaks first; the refine_preferred_orientation action is already capped below add_impurity_phase, and the axis/r/R² stay readable as evidence

region.gates_passed is False

This region’s coefficients failed resolvability / validity-radius / significance

Read region.gate_failures; the coefficients are present for transparency only and must not be read as causes

A trend is reported non-separable

Two angular templates (e.g. size vs strain) are collinear over this range

Do not pick one. Extend the range or report both

PAWLEY_OVERLAP_UNRESOLVED

A group’s summed intensity is determined; the split is not

Treat the group sum as the datum

STEPHENS_STRAIN_NOT_POSITIVE

σ²(M) went negative on some reflection — outside the physical cone

The S_HKL are not quotable, and this is never evidence of anisotropy. Re-run with solver="lm", which enforces the cone directly (measured on brucite: 12 of 43 reflections outside it under the default driver, 0 of 43 under lm, at a higher Rwp). That makes the answer admissible, not measured — vary the start and quote the coefficients only if they survive it (the lm result carries CONSTRAINT_ACTIVE when the cone actually bound)

ADP_NOT_POSITIVE_DEFINITE

The tensor is not an ellipsoid; its Debye-Waller factor diverges at high Q

Revert the site to isotropic biso

ROUGHNESS_UNCONSTRAINED

The refined correction depresses no modelled reflection by >1 %

Drop the block. The value it refined to is arbitrary

NO_PHASES

The model carries no phase at all — the fit was refused before it started, and no result exists to read. A pattern-only project is a legal state (WP-1207): peak picking and indexing work over it, the parameter table holds the instrument and the background, and only refining is refused

Do not retry the fit — nothing about it will change, and this is not the engine failing. Get a cell: task="index" over the pattern’s peaks, then adopt a candidate (§7d closes that loop); or supply one directly, where a space-group symbol and the cell parameters that setting leaves free are enough for a Le Bail scaffold. The refusal is on the verb, not on the model, so the same model is fine to hold, edit and index

STRAIN_UNUSUALLY_LARGE

A phase’s sample strain refined past 1.5 deg (the FWHM coefficient of tanθ, lor_strain’s own units; gauss_strain is a variance and is compared as its square root). where names the term, value is the width

A number to check, not a failure — this is the one code here that is not a refusal. The threshold is the top of our archive’s 606 solved TOPAS refinements (p99 1.21, hand-written ceilings topping out at 1.5); above it that corpus holds 8 records in 5 contexts and all 8 are divergence signatures rather than materials. So the fit is finished and may be right: confirm the broad lines are really this phase’s and not an unmodelled peak shape, an amorphous component or a second phase it is absorbing. If BOUND_HIT fired on the same path the width is held at the data-derived cap and is not a measurement. A finite Parameter.max is your claim and silences both tiers

SIZE_UNUSUALLY_SMALL

A phase’s sample size broadening refined to an apparent crystallite below 5 nm (from lor_size, or sqrt(gauss_size), via Scherrer at the pattern’s wavelength). where names the term, value is the size in nm

A number to check, not a failure — the size twin of STRAIN_UNUSUALLY_LARGE, and also not a refusal. Real nanocrystals reach a few nm, so the fit may be right — but note that below roughly 2 nm (a handful of unit cells) the scattering leaves the regime a broadened Bragg profile can model at all: the pattern becomes the Debye interference function of a cluster, peak positions shift off the Bragg angles, and the honest tool is total scattering/PDF, so a “size” refined down there no longer means size. In our archive of 606 refinements the smallest well-determined crystallite is ≈ 33 nm and every refined size below ~2 nm is a divergence or placeholder signature. Confirm the broad lines are really size broadening and not strain, an unmodelled peak shape, an amorphous component or a second phase it is absorbing. If BOUND_HIT fired on the same path the width is held at the 2 nm floor and is not a measurement. A finite Parameter.max on the size term is your claim and silences both tiers

PHASE_UNCONSTRAINED

This phase’s strongest modelled point sits below 1σ of the observation noise, so the data cannot distinguish it from absent. where lists its structural parameters and the message says what the run did with them: held for the stages it names (the usual case since v1.3 — they were not refined, and their values are the ones you supplied), or refined against it anyway. A third wording, no reflection of phase N lies in the fitted range, is the limit case: the range covers no line of the phase, so no scale could make it visible

Those values are not measurements either way, whatever their esds say: a held one is your own input, a refined one moved in a flat direction, since every structural parameter of a phase reaches the pattern only through scale × |F|² × profile. Do not read them, and do not read a converged status or a good Rwp as covering them — a parameter that does not move y_calc does not move Rwp either. Decide which case it is: the phase is not in this specimen and belongs out of the model; its scale is stuck at its floor and must be seeded before anything else of it is freed; or, for the third wording, the fitted range is wrong for the question. Nothing is owed in response to the hold itself — it costs nothing, it releases itself in the stage where the phase appears, and re-running to “free” the parameters would only reproduce it. On a series, expect this per pattern — see §7’s note on reading it across a run

SEQUENTIAL_PATH_DEPENDENT

A parameter’s trajectory differs between the forward and backward chains by more than their esds allow. Judged per pattern, and only where both chains measured an esd for that same pattern — so a parameter one chain held throughout, and one the two chains share no measured pattern for, is not judged at all

That trajectory is an artefact of the refinement order, not a measurement. Hold the parameter, restrain it, or quote the forward/backward spread as its uncertainty. Silence is not clearance: this code’s absence means the two chains agreed where they were comparable, which for a path one direction held throughout is nowhere. Two lengths cannot tell you which it was — equal lengths are consistent with zero shared patterns, and identical labels with not one pattern measured by both. Count the patterns actually judged: intersect series.trajectory(path).labels with series.backward.trajectory(path).labels (series.backward is the reverse chain’s own SeriesResult, set only when direction="both"), and keep those whose arrays() esd is finite in both. 0 patterns judged is “never examined”; a small count against a long series is “examined on a fraction of it”. tests/test_sequential.py is this recipe, run on both of those shapes

SEQUENTIAL_PERSISTENT_FINDING

A per-pattern code fired in more than half the patterns; where names the parameter and value is the fraction

Read it as a property of the model, not of any pattern. One BOUND_HIT is a pattern that hit a bound; a BOUND_HIT in most of them is a bound that is wrong. Change the model or the plan — refitting the individual patterns will reproduce it. This exists because no per-pattern diagnostic can say “42 of 68”: in the episode this comes from, 425 BOUND_HITs went unread for two hours while phases.3.cell.c was pinned in 42 of 68 patterns. The per-entry diagnostics still carry every occurrence

SEQUENTIAL_CANCELLED

The chain was cancelled: the pattern in flight was abandoned and the rest were never started

The reported entries are complete fits and stand on their own, but the trajectory is truncated, not finished — do not read its last point as the end of the ramp, and do not compare a slope over it with one over the whole series

SEQUENTIAL_UNRECOVERED

A pattern diverged and stayed diverged after every rung of the escalation ladder (entry.rungs_tried names them)

Read that point as a failed fit, not a datum — do not interpolate across it and do not quote its parameters. Its neighbours are unaffected: the chain stepped over it, so the successor warm-started from the last pattern that converged and the reseed median never saw it. Fit the pattern on its own to find out why (a specimen change the model lacks, a bad scan, a starting model that no longer suits this end of the series)

IndexingResult.best_or_none() returns None

No candidate cell reached the confidence gate

This is the most likely outcome of a first indexing run, and it is not a failure. Read each candidate’s confidence_caveats and act on the refuting ones first (§7c). Never take candidates[0] because it is ranked first — the ranking orders the hypotheses, the gate judges them, and the two are different questions. Since WP-1046 the order leads with corroboration (at least two engines found the lattice) and ranks the panel within that, so candidates[0] is now “corroborated, then best on the panel” rather than “best on the panel” — closer to the gate’s own reading, and still not it

INDEX_ABSTAINED

The result declined to name a cell, and says why

Propagate the abstention. The candidates are there so you can see what was considered, not so you can pick one

INDEX_GEOMETRIC_AMBIGUITY

Two distinct lattices explain the positions equally well (Mighell & Santoro 1975)

Do not pick one. The information is absent from the measurement, not buried in noise — collect to the 2θ in discriminating_two_theta and look for the reflections named there

INDEX_MULTIPLE_SOLUTIONS

More than one candidate satisfies the whole gate

Compare the panels and each lebail.rwp, and extend the 2θ range: two cells that both explain a range this wide are usually separated by one high-angle reflection

INDEX_SEARCH_INCOMPLETE

A budget expired before the domain did

Do not read “no cell found” as “no cell exists”. Only a completed exhaustive search says that, and search_complete[system] says which systems finished

INDEX_BUDGET_EXHAUSTED

The declared whole-run ceiling (total_budget_seconds) bound

The answer covers what was reached. Read the three states of systems_searched (§7c) before treating any absence — of a system, a candidate, or a validation — as evidence

INDEX_SYSTEMS_NOT_COVERED

Systems were not searched

Read a failure as “no cell in the systems searched”, never as a statement about the specimen — and in particular never as “this is multiphase” (§8.15)

ExtinctionScreen.best_or_none() returns None

No extinction class is separated from its rivals by these data

Read the ranked candidates and EXTINCTION_SYMBOL_AMBIGUOUS, which states every reason at once. The action is a longer count at the forbidden positions the leading classes disagree about, or a wider range — never the better Rwp, which always favours the class with more reflections

EXTINCTION_GROUPS_NOT_SEPARABLE

The leading class holds more than one space group

Nothing. This is not a data problem and no counting time fixes it: those groups differ only by elements that produce no absences. Carry the whole list forward and choose with chemistry (§7e)

Layer 1’s gates, for reference, are: resolvability on the scale-normalised Gram matrix, a 0.4·FWHM validity radius (a peak 5 FWHM off must trigger “re-detect”, never a confident small-offset reading), local χ²_red significance, and share-based global maturity. Confidence weights importance (share of χ²), not only statistical significance — at high counting statistics the second-order leakage of a peak shift into the width column is significant but carries a per-cent-level share.

The per-region refusals arrive typed — region.gate_failures is a list of GateFailure(code, message) since WP-1003, promoted from formatted strings precisely so a consumer can branch on the name; the message carries the measured numbers and is display-only. The vocabulary (GateCode) is closed, the global maturity gate has no entry here (it abstains the whole layer, abstained_reason/abstained_kind above), and one member is not a failure at all:

GateFailure.code

Meaning

Correct response

no_significant_misfit

this region’s local χ²_red is at the noise floor — there is nothing to attribute

Nothing. This is a region the model already fits, not a failure of the basis and not evidence about the model; a report full of these is what a good fit looks like

local_r2

the region’s misfit is real but the basis explained too little of it (low local R²)

Do not read the coefficients as causes. The misfit is not position/width/intensity/mixing/asymmetry-shaped — suspect what the basis cannot express: an unmatched peak (Layer 0), background shape, peak-shape misfit

gram_condition

the templates’ scale-normalised Gram matrix is ill-conditioned here: the edit directions are indistinguishable on these merged peaks

The resolution-limited signature, per region (abstained_kind="resolution_limited" is the same statement globally). Do not pick one coefficient — the separation is absent from the data at this resolution, so the fix is a wider range or better resolution, never a preference

outside_validity_radius

the fitted offset exceeds the 0.4·FWHM linearisation radius, and a linear fit pushed past it saturates

Re-detect, never shift: read the fitted offset as a lower bound, not a measurement. Widespread across misfitting regions, this is exactly what emits reindex_or_recheck_cell with the calibration candidates in alternatives (the abstention table’s first row, above)

Reference: 7. The diagnostics are the channel for “your answer is wrong although Rwp is fine”

Load it when a Diagnostic fires and you need the row: what it means, what to do, and what you must not do.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

Every code below is a structured Diagnostic on result.diagnostics with a level, a where list of parameter paths, a message and a suggestion. Branch on the code, not on the message text.

One namespace note, because two vocabularies share the same UPPER_SNAKE shape: every code in this document is the engine’s, a Diagnostic.code. The GUI server’s session codes (NOT_FOUND, RUN_IN_FLIGHT, STALE_REVISION, …) are a separate vocabulary with no rows here, so a code met outside result.diagnostics is the server’s, not the engine’s.

Code

What it means you must not do

HIGH_CORRELATION

Quote both members of the pair as independently measured. Deduplicated across a plan’s stages (worst |ρ| kept, every stage it fired in named in the message) and capped at 10 per fit, worst first — a HIGH_CORRELATION_OMITTED row past the cap gives the omitted count, never a truncated list with no notice

HIGH_CORRELATION_OMITTED

(info) Read it as “nothing was dropped from the fit” — only from this message. result.identifiability carries the full correlation matrix and top_correlations list for anything past the 10 shown

BOUND_HIT

Quote a parameter sitting on its bound as a measurement. Iterating result.parameters instead of cross-referencing this code by path? RefinedParameter.at_bound is the same finding on the row — but it is three-valued, so test is True, never truthiness: None means the row was not tested (it is tied, or the result came from replay), and that is not False. Check the ties before you widen a bound: a bound is not always the one the parameter declares — a tie hands its dependent’s limits back to its source, so a BOUND_HIT on a parameter whose own min/max are nowhere near the value is one of its dependents’ ceilings arriving through the tie, and the parameter to widen is that dependent (§ 8.22)

BACKGROUND_ABSORPTION

Quote ADPs, scales or QPA fractions from this fit — the background can imitate them, and Rwp improved while they biased. Read the number, not the bit: report.background.absorption is the same measurement for every screened parameter, fired or not, and result.identifiability.background_absorption carries it on the result. The R² names the victim, not the cause — it says what the background could reproduce, never what put it there, and a phase width can be the absorber: a phase broadened past the instrument stops being a set of peaks and becomes a second background, so read SIZE_UNUSUALLY_SMALL / STRAIN_UNUSUALLY_LARGE and result.microstructure on every phase before concluding the background function is the thing to change (measured on a four-phase in-situ scan, WP-1130 § Finding 2)

HUMP_TOO_NARROW

Quote anything from a fit whose declared hump has narrowed towards the instrumental resolution — the peak parameters, the displacement parameters it was supposed to protect, or the Rwp. A HumpComponent is three free parameters with an unconstrained position, so at a resolution-limited width it is a reflection with no cell and no structure factor behind it, and a free peak improves Rwp whether or not anything is there. value is the fitted width as a multiple of the instrumental FWHM at its own position; the feature is for diffuse or amorphous scattering and sample-environment contributions, whose widths come from disorder and run to tens of times the resolution (the case it was built for is ~20×). If there is a real unindexed line at that angle, the honest answers are a second phase or Layer 0’s unmatched-peak report — never a hump

EXTRA_PEAK_ON_REFLECTION

Quote the intensity split between the phase and the declared peak as if it were measured — it is the §3 degeneracy, two terms over one peak. Not wrong by itself: a holder line overlapping a sample peak is what the member is for. What it rules out is the shortcut reading, a component declared to make one misfitting reflection go away — check the phase’s scale, profile and preferred orientation first. If the overlap is real, quote the phase quantities and say they overlap

EXTRA_PEAK_NO_INTENSITY

Quote the declared peak’s centre, or its esd. The area refined onto its zero bound, so the data does not see the peak — and a peak reaches the pattern only through area × profile, so at zero area nothing constrains where it sits either. The centre is a walk, not a measurement: the zero-scale phase’s flat direction one rank down (§PHASE_UNCONSTRAINED), and why the area’s esd comes back absent rather than small. Either the feature is not in this specimen or the centre bounds do not bracket it. Drop the component or move its bounds

ROUGHNESS_ABSORPTION

Quote roughness and the displacement parameters as two separate results

ROUGHNESS_UNCONSTRAINED / ROUGHNESS_OUTSIDE_REGIME

Interpret the roughness parameters physically

PHASE_UNCONSTRAINED

Quote anything about this phase — its cell, its coordinates, its ADPs, its weight fraction — held or refined alike, and in particular do not read a held value as “it did not move, so it is right”. HIGH_CORRELATION on the same fit reports ρ≈1 between the phase’s cell and its scale, which is the symptom; this is the cause

ADP_NOT_POSITIVE_DEFINITE

Report the tensor as measured

STEPHENS_STRAIN_NOT_POSITIVE

Report any S_HKL

STRAIN_UNUSUALLY_LARGE

Quote the strain, the crystallite size derived from the same profile, or that phase’s weight fraction without saying the width is this large — a strain this far out reaches across the pattern and competes with the background and with any phase it overlaps. Unlike every other row here this is not an instruction to withhold the number, only to adjudicate it

SIZE_UNUSUALLY_SMALL

Quote the crystallite size, the strain, or that phase’s weight fraction without saying the crystallite is this small — a size this far out broadens every line and, like a large strain, competes with the background and with any phase it overlaps. Like STRAIN_UNUSUALLY_LARGE and unlike every other row here, this is not an instruction to withhold the number, only to adjudicate it

SIZE_NORMALISED_ACROSS_WAVELENGTHS

(info) Read a shared phases.N.lor_size / gauss_size from a joint fit as the coefficient your histogram shows. It is the coefficient at histogram 0’s wavelength; a size coefficient goes as λ (its Gaussian variance twin as λ²), so each histogram’s own copy — MultiHistogramRefinement.fitted_structures[h] — carries a different number and the message lists them all. The specimen quantity is the crystallite size, which is one number: read it with rietx.model.profiles.caglioti.apparent_size_from_size_coefficient and that histogram’s own λ, and the answers agree by construction. Nothing is wrong and nothing needs doing; a joint fit that shared the degrees instead would land the implied sizes a factor λ_max/λ_min apart while reporting converged

DISPERSION_NEGLECTED

Quote QPA weight fractions — unequal f′ across phases biases them directly (measured: RMS 2.26 → 0.69 wt % once applied)

NEUTRON_RESONANT_ABSORBER

Quote an occupancy or a displacement parameter on that site without first checking this wavelength is away from the resonance. The neutron twin of DISPERSION_NEGLECTED, and the one case where b_Sears.dat is incomplete rather than wrong: for Cd, Sm, Eu, Gd and Yb (and their listed nuclides) b is complex and wavelength-dependent near a nuclear resonance, and the table stores the real part of the thermal value alone. Away from the resonance the thermal value is the right number and nothing is owed, which is why this reports and never refuses. warning where the element itself absorbs ≥ 1000 barn (Cd 2520, Eu 4530, Sm 5923, Gd 49700 — a black absorber eats the beam as well), info otherwise (Yb 35, whose resonance lives in the minority ¹⁶⁸Yb at 2230). A wavelength-resolved fit may not use one thermal value at all

SPECIES_FALLBACK_NEUTRAL

Assume the model scatters like the ion its species label names. crystallography.scattering.normalize_species substitutes the neutral atom when an ion is missing from the Waasmaier-Kirfel table (12 of 111 chemically-real oxidation states — Y3+, S6+, N3- among them), reported rather than refused because refusing would break files that refine today. value is the fractional error on

HARMONIC_FRACTION

Transfer the fitted per cent to another histogram, or read it as a property of the specimen. It is a refined scale ratio between two wavelength components of one measurement on one monochromator, so it is not comparable across instruments and not a material constant. At warning level (past 15 %) do not read it as a measurement of the beam at all — the line is absorbing intensity the model puts nowhere, and the tick marks against the unfitted peaks are what to check

HARMONIC_ABSENT

Read it as a failure of the correction. It is a result: the declared λ/n line refined to nothing, which is what a monochromator whose nth order is extinct must give. Dropping the declaration reproduces the fit with one parameter fewer

HARMONIC_HELD

Quote the harmonic fraction at all — the weight never entered θ, so the number is the value the caller declared, not a measured one. Free instrument.source.lines.*.weight in a stage after the profile and the scale have settled

ABSORPTION_ESTIMATE_UNAVAILABLE

Assume the absorption correction ran. It did not; the fit has no specimen absorption correction

ABSORPTION_MU_R_OUT_OF_RANGE

Trust the magnitude of the correction — µR > 1 extrapolates the Rouse fit

ABSORPTION_THICKNESS_MATTERS

Quote displacement parameters without checking the flat specimen’s thickness — part of the correction is not absorbable by the scale and ADPs, so a wrong µt lands in both

ABSORPTION_PLATE_THICKNESS

(info) Read it as a fit problem — a transmission plate far from µt = 1 costs counts, not accuracy

WAVELENGTH_CALIBRATION

(info) Quote the cell without saying what pinned the scale the wavelength was measured against — the held cell in a single-histogram fit, or which histogram’s wavelength was held in a joint one. This fires whenever a wavelength was refined, and reports how far it moved from its declared value in ppm together with the shift over its own esd. The reference is the wavelength declared when the refinement was constructed, so the ppm is the cumulative move across successive λ-freeing stages (a branch inherits that reference, and a checkout to an earlier node never resets it) rather than a delta from the previous stage’s answer — only an instrument edit re-declares it. It is the only evidence the refinement offers for that move — deliberately not an Rwp comparison. Read a move inside its own esd as “this measured nothing”; read a move far past the beamline’s known calibration drift as a modelling error in that histogram (an unmodelled harmonic, a zero shift traded against λ) rather than as a calibration result. In a single-histogram fit λ is measured directly against the held cell; in a joint fit the refined cell sits on the scale of the held wavelength, so the held one is part of the number

CAPILLARY_OFFSET_UNAVAILABLE

(info) Read a cell from this fit as free of position systematics. The eq (4) offsets were held at zero for want of a goniometer radius, and an unmodelled offset moves every axis by the same fraction — so an axial ratio can agree to a few ppm while no axis does (measured: 143 ppm on both Cr2WO6 axes, c/a agreeing to 3.4 ppm). It fires on any capillary geometry with no radius, calibrated synchrotrons included (11-BM among them): there the 2θ scale is pinned and a certified cell reproduces to spec, so note it and move on; on a laboratory capillary the same held offset is a real accuracy gap

BRINDLEY_OUTSIDE_REGIME

Prefer the corrected fractions; past µR ≈ 0.05 the “correction” can be further from truth than none — the QPA round robin’s community-wide finding (Madsen et al. 2001; Scarlett et al. 2002: over-correction where none was needed cost more accuracy than the effect it corrects)

MICROABSORPTION_SKIPPED

Assume microabsorption was handled

PAWLEY_OVERLAP_UNRESOLVED

Use an individual reflection intensity from the group

DATA_SUPPORT_LOW

Read the esds as the whole story. There are fewer effective observations per structural parameter than the guidelines ask for (at least three, preferably five), and an over-parameterised Rietveld refinement does not fail — it reports large esds while the algorithm’s own N, the number of profile steps, hides the shortage. warning below three, info between three and five; the numbers are on result.data_support either way, and the message quotes the effective count, the raw reflection count and the parameter count it divided. The remedy is fewer parameters (start from result.identifiability), restraints, or a wider 2θ range — never more points across the same peaks, which raises N and nothing else

PATTERN_UNDERSAMPLED

Quote integrated intensities, ADPs or QPA fractions and expect the step size not to be the limit. The median resolved peak has fewer than five steps across its FWHM, so those intensities were never measured finely enough — and unlike everything else in this table, no choice made after the fact repairs it: the counts do not exist. The fit still runs and the number is on result.data_support’s companion PatternDiagnostics.steps_per_fwhm before any refinement, from diagnose(data). Above ten steps there is no code at all — oversampling costs beam time, not validity

LOW_ANGLE_UNMODELLED

(warning) Trust the background below the first reflection because PatternDiagnostics.air_scatter_gain did not fire — that test is a nested cubic-vs-cubic+1/x check on the envelope over the whole fitted range, so a tail a few tens of channels wide out of several hundred barely moves it (measured on Ba₂FeSbSe₅ 1.5 K: 0.019 against the 0.3 trigger). This code instead reads the fit’s own residual over [two_theta_min, first_tick 2·FWHM) — every phase, every emission line, so a peak’s own low-angle flank is never counted — and fires when its mean weighted-squared residual exceeds 3× the whole-pattern reduced χ² (value). Silent when the region holds fewer than ten channels (no reflections at all, or the first one sits at or near the low edge). The message never picks between its two remedies — raising the pattern’s lower limit to where the residual falls under 2σ, or adding the background’s air-scatter term — because only whoever is looking at the pattern can tell whether the region is genuinely outside the beam or the background model is locally wrong

RESTRAINT_TENSION

Silently accept the averaged compromise — the data and your prior disagree by >3σ

SEQUENTIAL_RESEED

Read this point of a series as evidence that the trajectory is continuous — its starting values did not come from its neighbour

SEQUENTIAL_DISCONTINUITY

Report the jump as physics without opening that pattern’s own fit; it is equally the signature of a chain failure. fit(verify_discontinuities=True) opens it for you — both patterns refitted cold and independently, and value is what that pair reproduces (1.0 in the data, 0 the chain’s own); measured cost 5 % of a 68-pattern chain

SEQUENTIAL_PATH_DEPENDENT

Quote that parameter’s per-pattern esd as its uncertainty — the between-chain spread is larger and is the honest one

SEQUENTIAL_CANCELLED

Read the shortened entries list as the series — it is where the chain stopped, not where the ramp ended

SEQUENTIAL_UNRECOVERED

Read that point’s values as a measurement, or its failure as evidence about its neighbours — nothing was chained through it

CONSTRAINT_ACTIVE

(info, solver="lm" only) Read the constrained coefficients as free-fit measurements. The driver truncated steps against a linear-inequality constraint (the Stephens cone) in the answer-producing stage, so the optimum sits on or near a constraint face: admissible, not measured. Vary the start before quoting — and note this is the only signal a declared constraint was active rather than merely present

CELL_DEGENERATE_PROBE

(info) Read this as anything about the reported values — it is not. Cell’s six parameters declare no bounds of their own, so an underdetermined cell stage can drive a trial into a zero- or negative-volume metric (crystallography.lattice.DegenerateCellError); each such trial is refused by name and answered with a penalised residual instead of a bare RuntimeWarning and a silent NaN (issue #283), and never enters the reported parameters. value and the message give the total count and the stage(s) it happened in, summed across every completed stage — nonzero is the ordinary outcome on a poorly-constrained cell, not evidence of a problem with the fit

QPA_UNAVAILABLE

Read result.qpa is None as “this specimen is single-phase” or as any statement about composition. The refined scales gave a non-positive Σ S·ZMV, so there are no fractions to renormalise — where names the scales that died. It is reported rather than raised on purpose: QPA is one field of a result, and raising took a whole 157-pattern sequential run down with one bad pattern

MODEL_FAR_FROM_DATA

(error) Read status, the parameter values or their esds at all. Rwp is past the point where the model is no better than predicting zero everywhere, so this is a mismatch between model and data, not a converged refinement — and the solver may well say converged, because driving the phase scale to zero is a minimum once the cell is far enough off that every reflection sits outside its frozen evaluation window. The message quotes the share of above-background intensity the model actually accounts for (0.2 % on the reproduction); check the cell (~1 % precondition, §1), wavelength, zero shift and 2θ range, then re-index

STAGE_MAX_ITER

Read the result’s status as covering every stage — it is the last stage’s, so a middle stage can stop on its iteration budget while the fit reports converged. The named stages did not converge; they ran out. Raising max_iter buys solver evaluations, not a different minimum — the stages that stall are the degenerate groups in §3 (measured: three identical mixtures, same models and parameter counts, 39 s / 858 s / 2838 s with no difference in the answer)

SPACE_GROUP_SETTING_ASSUMED

(warning) Read the composition, ZMV or any weight fraction as the one you meant. The phase names a bare Hermann-Mauguin symbol the tables hold in more than one setting (40 of them — the :1/:2 origin choices and the rhombohedral :H/:R axes), and gemmi resolved it to the first. The site multiplicities differ, so the choice changes how many atoms each site puts in the cell while leaving Rwp untouched: spinel’s origin-2 coordinates under a bare F d -3 m give Mg2AlO4 where MgAl2O4 was meant. The message quotes the cell contents each setting implies — recognise yours and write it into the symbol ("F d -3 m:2"). Where the sites that swap carry one species the contents agree, so it quotes the site multiplicities instead: Mn3O4 under I 41/a m d is Mn12O16 either way and the wrong choice still moves half the intensity. A :H/:R pair gets alternatives and no composition: different axes. A symbol carrying its setting is silent; what each foreign format can say is in §7g and §7h

SITE_SNAPPED_TO_SPECIAL_POSITION

(warning — on result.diagnostics, and on the reader’s channel from Structure.from_cif(..., diagnostics=[])) Read the site’s multiplicity as the general-position one. The named sites sit within 1e-4 (fractional) of a special position without being on it — what a file quoting five decimals produces — so their orbit was expanded at that position and their multiplicity is the special one, which is what the file’s own _atom_site_symmetry_multiplicity and its stated density say. The stored coordinates are not rewritten and the fit is unaffected; what changes is how many atoms the site puts in the cell, hence ZMV and every weight fraction. The message names each site, its shift and its multiplicity. If they match the source’s own multiplicities, nothing is wrong and the coordinates are merely rounded; if they disagree, the coordinates and the space group are telling you different things

CIF_CELL_ANGLE_CORRECTED

(warning — from the reader, same channel as CIF_SPECIES_NORMALISED) Assume the cell is the file’s. A symmetry-fixed angle disagreed with its space group by a reportable amount (up to 0.1°) and was read at the exact value, because ParameterTable refuses such a cell and has no channel to say why. The deviation is information: if it is real, the symmetry is lower than the symbol claims. A disagreement beyond 0.1° is not corrected — it still raises, because the symbol and the angle contradict each other and choosing between them is yours

PATTERN_SCAN_REVERSED

(info — from the pattern reader: pass read_pattern(..., diagnostics=[]) to collect it) Assume the point order in the file is the point order you are fitting. The scan was stored high 2θ → low and was reversed, which is lossless — the same measurement written backwards — but it means an index into the file is not an index into the pattern

PATTERN_DUPLICATE_POINTS

(info — from the reader) Quote the file’s point count as the fitted channel count. One or more 2θ values appeared twice with the same intensity and the repeats were dropped. A repeat with a different intensity is not reported here: it raises, because averaging invents a datum and dropping picks one

PATTERN_MULTISCAN_DEFAULTED

(warning — from the reader) Read the result as a fit to the file. The file holds several scans, none was named, and scan 0 was read — a third of a measurement is a choice, and this one was made by default rather than by you. Pass scan= (or list_scans(path) first) before quoting anything

PATTERN_X_AXIS_ASSUMED

(warning — from the reader) Trust the 2θ scale. The file states which axis it scanned and named one this reader does not recognise, so the x column was read as 2θ in degrees. Each format states it somewhere different — .chi’s line-2 label, .ras’s *MEAS_SCAN_AXIS_X, .uxd’s _DRIVE, .xrdml’s scan/@scanAxis — and the message names both the field and what it held. The code means unrecognisable, never “recognisably not 2θ”: an axis that is recognisably q, d, ω, χ, φ or a translation is refused, because such a scan parses perfectly and refines to a confidently wrong cell. Worth taking seriously — most vendor files are not powder scans (4 of the 5 real .uxd files obtained are pole figures or rocking curves), and a block marker is not evidence either: a rocking curve is stored under _2THETACOUNTS too

PATTERN_INTENSITY_SCALED

(warning — from the reader) Quote esds, χ² or a σ-normalised residual from this fit as if the weights were measured. The stored intensities are not whole numbers and no counting time in the header makes them whole either, so whether they are counts or a rate could not be established — and no σ was supplied. The Poisson fallback √max(y,1) is therefore being applied to a quantity that may already be divided by a counting time, in which case every weight is wrong by √t. The fit still runs and the parameters are largely unaffected; it is the uncertainties that are not quotable. Re-export in counts, or supply esds

RAS_ATTENUATOR_PRESENT

(warning — from the reader) Compare intensities across the 2θ range this names. A Rigaku .ras or .rasx carried an attenuator factor that is not 1, and it was not applied: no specification states whether the intensity column is already corrected for it, so applying it and not applying it are both defensible and guessing corrupts exactly the strong peaks Rietveld weights most. σ is affected too — √counts·attn is not √y. The message names the affected points; check the export against the instrument software before trusting their relative heights. Contrast XRDML_ATTENUATOR_APPLIED, where a real file settles what no Rigaku file can

XRDML_ATTENUATOR_APPLIED

(info — from the reader) Nothing; it is here because the correction is large and invisible in the file’s own numbers. A PANalytical beam attenuator dropped a foil in front of the detector for the points named, and the reported intensity is counts × factor with σ = √counts·factor — not √y. Read it as the opposite of RAS_ATTENUATOR_PRESENT: this one is settled, because a real file shows the raw series dipping at exactly the attenuated point of a substrate reflection, which is the attenuation and not a profile. A factor of 188 over one point is normal. The case GSAS-II gets wrong, weighting 1/y regardless

BRML_ABSORBER_ENGAGED

(info — from the reader) Nothing to the intensities; they are already right. A Bruker automatic absorber engaged over the points named, and the stored series is already corrected for it, so nothing was multiplied — but those points carry a factor fewer counts than their height suggests, and σ was derived as √(y/a)·a rather than √y. The third answer to one question: RAS_ATTENUATOR_PRESENT reports because no Rigaku file settles it, XRDML_ATTENUATOR_APPLIED multiplies because a real file shows the raw series dipping, and this one leaves the values alone because a real file shows them continuous

READER_OPTION_IGNORED

(info — from the reader) Assume a reader option you passed took effect. This format does not take it, so the file was read as if it had not been given — normal when a form carries a value across a change of file, and a mistake when you named a block or a scan you meant to select. Check identify_format claimed the reader you expected

CIF_SPECIES_NORMALISED

(info — from the reader: pass Structure.from_cif(..., diagnostics=[]) to collect it; it is not on result.diagnostics) Assume the model’s species are the file’s literal type symbols. The reader rewrote a wild form — a site label in the type-symbol column (O1) or a sign-first charge (O-2) — onto the canonical grammar, keeping the ion when one was written; each message names its substitution and where lists the atoms it touched

The TOPAS_* and FULLPROF_* families are not here: they belong to rietx.io.projects, they fire at import rather than on a fit, and none of them ever reaches result.diagnostics. They have their own file — §7g, references/diagnostics-projects.md — because the question they answer comes before a fit exists: is the model I just imported the model that file describes? Load that one if a TOPAS .inp or a FullProf .pcr import handed you a diagnostic.

The RECIPE_* family is not here either: it belongs to the PowderLine recipe reader (read_recipe, Recipe.diagnostics) rather than result.diagnostics, and it answers a question the others do not: how does the fit I am about to run differ from the one the recipe describes? It has its own file too — §7g, references/diagnostics-projects.md — since a PowderLine recipe is another program’s file just as a TOPAS .inp or a FullProf .pcr is. Load that one if a PowderLine recipe handed you a diagnostic.

codes = {d.code for d in result.diagnostics}
if "BACKGROUND_ABSORPTION" in codes:
    # stiffen the background BEFORE reporting anything intensity-derived
    ...

# the same measurement as evidence rather than as a bit — and the two
# background hypotheses, which are advice: they change what the background can
# *absorb*, not which parameters move, so there is no stage to run
bg = report.background                      # None only if no background curve
bg.worst_absorption, bg.worst_absorption_path    # 0.46, "…atoms.0.biso"
bg.absorption                                    # every screened path → R²
bg.off_region_chi2_reduced, bg.off_region_durbin_watson   # 12.6, 0.19
report.action("decrease_background_flexibility")  # fewer terms, larger λ
report.action("increase_background_flexibility")  # capped at 0.6 — see below

increase_background_flexibility is capped however strong its evidence, because an amorphous hump and an un-modelled broad crystalline phase produce the same between-peak signature as a too-stiff background, and bending the background over either of the last two hides it while improving every statistic. add_impurity_phase rides in its alternatives for that reason, and each names the other. If you add the flexibility anyway, read any QPA afterwards as fractions of the crystalline content you did model.

Reference: 7b-7f. Indexing: peak picking, the answer, the closed loop, the extinction screen

Load it when the phase is unknown — peak picking through indexing, space-group screening, and on to a refinement.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

7b. Peak picking and indexing (PeakList.diagnostics, DataQualityReport.diagnostics)

These arrive from rietx.pick_peaks and rietx.indexing.assess_peak_list, before any refinement exists — so they are read on the peak list, not on a RefinementResult.

Code

What it means you must not do

PEAK_LIST_TOO_SHORT

Read the answer as scored. Below 20 usable lines the classical figures (M₂₀, F₂₀, Smith’s envelope) are undefined, so the search still runs — over the systems the line count supports — but ranks on the reduced panel, and nothing in the answer is comparable to a published threshold. (Before WP-1043 this code refused the search outright; that conflated scoring with searching, and it refused fluorite’s 18 clean lines that all three engines index at −5 ppm)

INDEX_DATA_INSUFFICIENT

Spend a search budget. The gate has already decided the data cannot support a search in any system, and it names which of the two reasons applies (lines per metric degree of freedom, or σ(Q)/Q)

INDEX_PANEL_REDUCED

Treat an absent figure as zero, or compute your own M on fewer lines and quote it as M₂₀. Each absent member is named with its reason on quality.fom_undefined; the members that remain rank every candidate alike, so the order means what it always does — the fom_panel_reduced caveat (capping) is what says the scoring does not

PEAK_SIGMA_ASSUMED

Quote a precision, or weight lines by 1/σ² as if that meant something — every σ in the list is the same assumed constant. Re-pick from the pattern if you have it. You may still index it: an assumed σ is not grounds for refusing, so the σ(Q)/Q abstention below does not run on such a list (it would be quoting a precision this package invented)

PEAK_NOT_SEPARABLE

Treat the flagged component as a line, or as noise. It is neither: the fit believes in it as shape and disbelieves it as a line, so it stays in peaks (removing it from the model displaces the real line beside it) and is excluded from usable(). If many fire, suspect a mis-declared instrument profile rather than a crowded pattern — undeclared axial divergence reproduces the whole effect

PEAK_POSITION_PRECISION

(info/warning) Ignore it when choosing a tolerance: it is the resolving power of the list, and it bounds every tolerance downstream

INDEX_SHIFT_DETECTED

Absorb the shift into the cell. The named template is the physical cause; correct the instrument (zero_shift, sample_displacement, sample_transparency) rather than the lattice

INDEX_SHIFT_MODEL_AMBIGUOUS

Pick one cause from this data. The magnitude is measured and the cell is safe to prediction_spread_deg; the cause is not identified, and extending the 2θ range is the only fix

PEAK_KALPHA2_ALIAS

Assume the dropped candidates were noise — each is at a stronger line’s Bragg-predicted Kα2 position, and a genuine coincident line is indistinguishable from an alias in one pattern

PEAK_AXIAL_TAIL

(info) Read the flagged components as lattice lines — or exclude them without looking. Each sits on the axial-divergence tail side of a much stronger group-mate (low-2θ below 90°, high-2θ above — the one sign flip nothing else has), and they stay usable because the side test is evidence, not proof. Measured on SRM 660c (WP-1043): five such components carried 125 ppm of certified-cell bias, and excluding exactly the flagged ones plus measuring the shift took the gate to high at −2 ppm — so on a well-aligned Bragg-Brentano pattern, excluding them before indexing is usually right

PEAK_KALPHA2_RESIDUAL

(info) Treat it as a line of anything. It sits at a strong group-mate’s predicted Kα2 maximum at a few per cent of its area — the residual of a modelled doublet, re-created by a re-seed pass after detection dropped the alias. Kept usable only because a genuine reflection can coincide with an alias position; exclude it before indexing unless you have a reason to believe one does

PEAK_UNRESOLVED_SHOULDER

Quote one of a pair as an independent line. Their σ already carries the correlation

PEAK_CONTAMINATION_LINE

Subtract it. Ghosts are flagged and excluded from usable(), never stripped

PEAK_ASYMMETRY_UNMODELLED

Trust the positions of the flagged lines. An unmodelled one-sided aberration biases a centroid in one direction, which σ cannot see — and the low-angle lines are the ones indexing depends on most

PEAK_WIDTH_LAW_MISMATCH

Leave instrument.profile as declared. A factor near 13 is the ProfileTCHZ synchrotron default (W = 1e-3 deg², FWHM ≈ 0.03°) on lab data

PEAK_SHOULDER_SEEDED

(info) Read a shoulder-seeded line as a detection. Survival was decided by ΔBIC, not by detection

INDEX_SEARCH_INCOMPLETE

Read “no cell found” as “no cell exists”. Only a completed exhaustive search says that; this one ran out of budget, and search_complete[system] says which systems it covered

INDEX_DOMINANT_ZONE

Conclude the pattern cannot be indexed. The exact-solve engine found nothing at its base-line index table but found a cell with a wider one, which means one axis is long enough (or short enough) that the lowest observed lines carry large indices. Use the dichotomy engine, which bounds the metric instead of assuming indices

INDEX_SHIFT_ALLOWANCE

(info) Quote the winning cell without fitting a shift template. The search assumed a systematic allowance (no shift had been measured), and a cell found inside a widened window absorbs the shift — measured, +1400 ppm on a certified pattern. Re-fit with shift_template and quote that cell

INDEX_SHIFT_FROM_PAIRS

(info) Read the reported amplitude as naming a cause. It does not: the pair method measures the shift’s size from harmonic reflection pairs with no reference, and constant and cos_theta are collinear over an ordinary range, so best is not an attribution. Read pairs.refuted_templates for what the data do reject, and widen the 2θ range if the cause matters

Peaks you name (rx.fit_peaks)

pick_peaks decides what the lines are; fit_peaks(data, instrument, positions) fits exactly the positions you pass and nothing else. Reach for it when the question is about the peaks rather than about a cell: a width analysis (Williamson-Hall) over lines you chose, d-spacings for a lookup, one reflection checked. It needs no structure and no space group, and it runs in tens of milliseconds on a lab pattern.

The answer is an ordinary PeakList, so §7b’s flags and the usable()/peaks split read exactly as they do above. Four rules are specific to naming your own positions.

  • A position with no peak comes back flagged, not dropped. It carries no_intensity (and usually position_at_bound), and it is out of usable(). Do not read its two_theta: a component at zero intensity has no gradient on its own position, so the fitter leaves it wherever the solve ended, and the esd says so in the only way it can — 3e+15° on the bundled fluorapatite pattern. Count the flag, quote nothing (WP-1101).

  • unnamed_neighbour means your list is incomplete, and the bias is real. It fires when the window holds a component you did not name, decided by the same ΔBIC test pick_peaks uses to accept one. Measured on the bundled fluorapatite: naming 52.253° alone gives 52.2502(10)° with χ²_red 4.94; naming its weak neighbour at 52.170° too gives 52.2529(8)° with χ²_red 1.50 — the line you wanted moved 2.7 m°, 2.7 of its own esds. On a deliberately doubled synthetic the same omission moved a position 51 m°, 26 esds, with χ²_red 0.93 → 82.5. Re-fit with the neighbour named, or say the position is biased; do not quote it silently.

  • Widths are the pattern’s, not the specimen’s. ObservedPeak.fwhm is what was measured: instrument convolved with sample. A Williamson-Hall intercept from raw widths is a floor set by the diffractometer (1355 Å on that fluorapatite, where the instrument dominates), not a crystallite size. Calibrate the instrument on a standard first (lab_calibrate, save_instrument_profile), or refine the sample-broadening terms and read rietx.model.microstructure, which reports each mechanism with an esd and says whether the two are separable. State K and whether β is FWHM or integral breadth whenever you quote a size: they differ by ~10 % on the same data.

  • Refusals are by name, not by silence. A position off the end of the pattern, or in a gap or an excluded region, raises ValueError naming the position and the channel count. That is a fact about the range you asked for; re-read the pattern’s limits rather than retrying. The same position named twice is refused too: two components at one 2θ are exactly singular in their intensities, so there is no answer to give.

Positions sharing a window are fitted together in one solve, so pass the whole cluster at once rather than calling once per line — separate calls over overlapping lines each bias the other.

7c. The answer’s own diagnostics (IndexingResult.diagnostics, and each candidate’s)

These arrive from rietx.index_pattern. Statements about one candidate live on that candidate (result.candidates[i].diagnostics); statements about the result live on the result. INDEX_ABSTAINED names the top candidate’s caveats, which is the pointer from one level to the other — so read both, and start at the result.

Code

What it means you must not do

INDEX_PREDICTED_BUT_ABSENT

Keep this candidate. The lattice needs reflections the pattern does not have — the oversized-cell signature, and the one M₂₀ cannot see. Prefer the smaller cell that indexes the same lines: a cell whose extra reflections are systematically absent has a translation it is not using, so the lattice is the sublattice. Do not check this with Rwp — measured, an oversized cell scores 0.379 against a correct 0.216, a gap smaller than the spread between specimens

INDEX_IMPURITY_LINES

Read it as one thing. A handful of unexplained lines is an impurity; most of the pattern is a wrong metric (measured, 95 of them when the metric was 1 % off). And note the fence: this package does not index multi-phase patterns, so a second phase means subtracting the solved one first

INDEX_BRAVAIS_AMBIGUOUS

Refine in the higher symmetry because it was reported. The stated system is the conservative one; refine there and test the higher one, never the reverse. A disagreement between gemmi and spglib is information, not a bug — their tolerances are different kinds of number (a Le Page obliquity in degrees against a symprec in Å) and disagreement is what genuine pseudosymmetry looks like

INDEX_VOLUME_UNPHYSICAL

Quote the cell. It is outside what these data can support — below a single atom’s exclusion volume, or clear of Smith’s (1977) envelope for the number of lines observed

INDEX_NOT_VALIDATED

Read a medium as a near-high. No pattern was supplied, so nothing tested any candidate against the whole profile, and the figure-of-merit panel is blind to lines beyond the first twenty, to impurity content and to predicted-but-absent reflections. Pass data= and instrument=

INDEX_VALIDATION_FAILED

(warning — on the candidate’s lebail.diagnostics) Read this candidate’s Le Bail numbers as a judgement, in either direction. The validation fit raised (the message names the exception), so lebail.rwp is inf and its status is failed: nothing was measured, which refutes nothing. It is evidence about the candidate, not about the search — but check the instrument before discarding the candidate, because a mis-declared profile or a wavelength on an absorption edge fails every candidate alike, and a run whose validations all fail this way is telling you about the setup, not the cells

INDEX_BUDGET_EXHAUSTED

Read the answer as covering the requested search. The ceiling (quick’s default, or a declared total_budget_seconds) bound, and the result covers what was reached: systems_searched + search_complete distinguish three states — searched (present, True), truncated (present, False), and not reached (absent; the diagnostic’s where names them) — and candidates whose validation never ran read not_validated (capping), never validation_failed (refuting). Units run system-major (WP-1042), so what a binding ceiling cuts is trailing low-symmetry systems for every engine equally, never a whole engine — a candidate from a completed system keeps all its finders. The message also distinguishes the slice-only case: the run finished under its ceiling but one or more validation fits exhausted their equal slice of the remaining clock. A user cancellation never writes this code: a stopped run is not a budget statement

INDEX_SINGLE_ENGINE

(info) Read low as “refuted”. One engine ran, and agreement between independent searches is what confidence measures — so every candidate of a one-engine run grades low structurally (fewer than two finders), which means “unconfirmed by construction”. It is a diagnostic rather than a caveat because a capping caveat cannot explain a floor grade() produces before caveats are consulted. Re-run with the default engine set for a gradeable answer

INDEX_CELL_SYSTEMATIC_UNQUANTIFIED

Quote a Bragg-Brentano cell to its esd. The esd is a precision from the line positions; the goniometer radius alone carries ≈ ±85 ppm that no esd reports, because the data cannot identify it (Rwp moves 0.029 points across 180–320 mm)

INDEX_CANDIDATES_TRUNCATED

(info) Read the reported list as everything the search produced. It is the top max_candidates of a larger merged set, and the message says how many ranked below it — the cap exists because each reported candidate is priced a Le Bail fit, not because the rest were judged. Its second clause names any (engine × system) unit that returned a full pool (five times the reported cap since WP-1046); that clause is a flag rather than a count, because how many distinct lattices sat behind a discarded harvest is not knowable without deduplicating one the search already dropped. Raise max_candidates to see further down — it raises the pool with it, and the cost is the validation fits

INDEX_PRIOR_USED

(info) Read the answer as unsteered. It was steered — this diagnostic names each declared prior and its fate (confirmed by engines / entered unconfirmed / refuted / refused at the box) — but steering changed only when things were searched and what seeded the stochastic engine, never a range, a system set, or a rank: prior-only candidates are appended after the ranked list and never enter the Borda ranking. A candidate whose found_by is ["prior"] alone is stated-and-unconfirmed — the ordinary agreement caveat grades it down, so treat it as your own hypothesis checked against the lines, not as a finding (WP-1045)

7d. The closed loop: from a pattern of an unknown phase to a refinement

Indexing is the step that used to be missing. Before it, this package could refine a structure against a pattern but could not find the cell, so an unknown phase was out of reach entirely. index_pattern is a peer of refine and the loop between them closes:

These names are provisional, and the answers they return are versioned. Indexing is under active development, so everything under rietx.indexing and every answer type in rietx.schemas.indexing may change in any release — the compatibility promise declares the subsystem rather than listing names, and every change is in the release notes. Two things do not move with them: capabilities().indexing_thresholds_version, which is what the gates below are versioned by, and the serialized shape of an IndexingResult. So a tool loop that reads an answer sees any observable change as a version bump; one that imports these types should pin an exact version.

How long will this take? Since WP-1042 the default answers this itself: index_pattern resolves the quick preset — every engine, every requested system, and a whole-run ceiling (SEARCH_PRESETS["quick"]) covering search, probe and validation, with each validation fit drawing an equal slice of the remaining clock. Nothing is narrowed; a run that hits the ceiling says so (INDEX_BUDGET_EXHAUSTED, §7c) rather than having silently searched less, and what it cuts is the trailing low-symmetry systems — cheapest-first ordering’s documented cost. Progress and a graded shortlist for every completed system stream on the event ladder as the run goes (events=), so the useful answer usually arrives seconds in, long before the run ends. preset="full" is the unbounded pre-1.0 behaviour — reach for it when a quick run reports truncated or not-reached systems and the answer may live there. For the arithmetic, ask rietx.indexing.engines.estimate_ceiling(spec) (CLI: rietx index --ceiling): budget_seconds (default 30) is per (engine × system), the worst case is that arithmetic plus the probe plus per-fit validation (measured 0.6–44 s each), against measured typicals an order of magnitude lower, because searches usually finish their systems early. Budgets are runaway guards, not timers — this package’s record has six point measurements where a longer run never bought a better answer, and one where too little budget reported a wrong centring, so bound generously and read the three states rather than shrinking the search.

State what you know. You often hold something the search does not: an isostructural analogue from a database hit, a homologue’s cell, a family’s space group. Declare it (WP-1045) — SearchSpec.prior_cells / prior_spacegroups, the same fields on the agent request’s search and in the GUI’s Search controls — and the search runs the prior’s crystal system first, seeds the stochastic engine’s starting basin with the stated metric, and checks the cell itself against the lines. Three facts make this safe to do liberally: a prior steers, never gates (no system dropped, no range changed, prior-only candidates appended after the ranked list — a wrong prior costs time, not truth, and that sentence is pinned by test); a real prior is then found by the engines themselves, so found_by and the grade keep their meaning; and INDEX_PRIOR_USED records what you assumed and what it changed, so assumed knowledge can never read as measured knowledge. A worked example — you suspect the specimen is isostructural with calcite (R -3 c, a = 4.99 Å, c = 17.06 Å):

idx = rietx.index_pattern(
    peaks, data=data, instrument=instrument,
    spec=rietx.indexing.SearchSpec(
        prior_cells=((4.99, 4.99, 17.06, 90.0, 90.0, 120.0),),
        prior_spacegroups=("R -3 c",)))   # trigonal jumps the queue; the
                                          # centring steers the prior's check
# the same controls survive a JSON round trip (a project document stores them):
# {"search": {"prior_cells": [[4.99, 4.99, 17.06, 90, 90, 120]],
#             "prior_spacegroups": ["R -3 c"]}}

If the analogue is right, the truth arrives in the first streamed per-system shortlist instead of after the whole sweep; if it is wrong, the final list is the one you would have had anyway, plus the record that the prior was tried.

peaks  = rietx.pick_peaks(data, instrument)           # fitted positions + σ
report = rietx.indexing.assess_peak_list(peaks)       # fit to index at all?
if not report.supports_indexing:
    ...                        # abstention. Do not spend a budget (§6)

idx  = rietx.index_pattern(peaks, data=data, instrument=instrument)
cell = idx.best_or_none()
if cell is None:
    ...                        # read confidence_caveats; do NOT take candidates[0]

phase = rietx.indexing.structure_from_candidate(cell)  # dummy atom, lattice group
result = rietx.refine(data, phase, instrument, mode="lebail",
                        plan="profile_only")

screen = rietx.determine_extinction_symbol(data, cell, instrument)
klass  = screen.best_or_none()          # an extinction *class*, never one group
if klass is not None:
    # any member fits the data equally well — that is what the class means
    phase = rietx.indexing.structure_from_candidate(
        cell, space_group=klass.space_groups[0])

Five things about that sequence are load-bearing:

  1. Pass the pattern, not only the peaks. It is what turns whole-profile validation on, and validation is what catches the oversized cell no figure of merit sees. Without it every candidate caps at medium and the result abstains.

  2. best_or_none() returning None is the normal first outcome. With no measured systematic shift it is currently unreachable to get high on real lab data at all — both engines widen their matching window by an assumed allowance and say so — and the fix is evidence (an internal standard, a calibrated shift_allowance_deg), not a bigger constant.

  3. Go through structure_from_candidate. It supplies the mandatory dummy atom and, more importantly, defaults the space group to the absence-free lattice group. A plausible-looking space group would hide exactly the reflections whose absence is not yet established — which is also why the indexing gate’s predicted_but_absent test must keep running against the lattice group even after the screen has named a class.

  4. The extinction screen answers the next question, and answers it as a class. determine_extinction_symbol ranks the classes the lattice admits, each listing its space groups; the powder observable is the extinction symbol, so a returned class with three groups in it is a complete answer, not a hedge (§7e).

  5. Choosing inside the class is chemistry, not diffraction. Any member can be handed to structure_from_candidate for the Le Bail or Rietveld step that follows — they predict the same reflections at the same positions. What separates them is what you know about the compound, or which one a structure solution works in.

The reverse direction closes too. When a refinement’s Layer 2 emits reindex_or_recheck_cell — peak offsets beyond the linearisation radius in most of the misfitting regions, i.e. the cell is wrong (or the calibration grossly off) rather than slightly off — that action has something to call: pick peaks and run index_pattern on the same data. Since WP-1054 it survives abstention, which is where it matters most: the wrong-cell state abstains, and before the fix it surfaced only a confident add_impurity_phase built from its own displaced peaks. The action carries refine_zero_shift / refine_sample_displacement in alternatives because the validity-radius signature cannot choose between a wrong cell and a gross calibration error — re-indexing is still the safe first move, because index_pattern searches under its own shift allowance.


7e. The extinction screen (ExtinctionScreen.diagnostics, and each class’s)

These arrive from rietx.determine_extinction_symbol, which runs after a cell is in hand and answers the next question — which systematic absences the pattern shows. Same split as §7c: a refutation lives on the class it refutes.

Code

What it means you must not do

EXTINCTION_GROUPS_NOT_SEPARABLE

(info) Pick one of the listed space groups and call it the answer. They produce identical powder patterns by construction — a centre of symmetry, an enantiomorph or a mirror leaves no absence — so this is not weak data and not a tie to be broken by counting longer. Carry the list; structure_from_candidate(cand, space_group=…) accepts any member. The arbiters are chemistry (a polar or optically active compound cannot be centrosymmetric) and, eventually, which one a structure solution works in

EXTINCTION_SYMBOL_AMBIGUOUS

Read the ranked first class as the answer. It fires for three different reasons and says which: a runner-up inside the decisive ΔBIC margin, a leading class none of whose absences is testable here (each is outside the range, coincides with a line the class still allows, or sits where the class’s own fit already puts a neighbour’s tail), or classes a max_classes cap never fitted. Only the first is fixed by better data at the same setting

EXTINCTION_FORBIDDEN_INTENSITY

Keep this class. A position it forbids carries intensity its own Le Bail fit cannot account for, and the hkl and 2θ are named so you can look. Two things it is not: a position under a neighbour’s tail, which is no longer testable at all (WP-1077), and necessarily a violated absence — one flagged position can be an impurity line, so check it against the indexing result’s unmatched_observed before concluding. What it cannot be excused by is a good ΔBIC: a class asserts absences, so a testable position carrying intensity refutes it however well it scores

EXTINCTION_CONDITIONS_PARTIAL

(info) Read conditions as the complete condition list for this class. The screen used the absence set itself, which is unaffected; only the human-readable reduction is short. Read space_groups

EXTINCTION_SCREEN_FAILED

(error) Read the empty ranking, or any class’s absence from it, as evidence about the symmetry. The reference Le Bail fit of the absence-free lattice group raised (the message names the exception), so no class was screened at all — screen.status is failed and there is nothing to rank. This is about the cell, the instrument or the data, never about one class: every class would fail the same way. Validate the cell first (index_pattern with data=, §7d’s sequence) and check the wavelength is not on an absorption edge

Three things about the screen that change how you use its answer:

  1. The score is a nested comparison, not Rwp. A class with fewer absences has more reflections and can only fit at least as well, so Rwp ranks the least-constrained class first every time. delta_bic is BIC(class) − BIC(absence-free lattice); negative favours the class, and the difference between two classes’ values is itself a ΔBIC. Measured on a synthetic P 2₁/c specimen: the true class and its screw-free partner differ by 1e-5 in Rwp and by 24 in ΔBIC.

  2. n_added counts only testable absences, so a class whose extra absences all hide under allowed neighbours earns nothing for them. Read n_testable beside n_absent: if it is zero the class is a hypothesis these data cannot address, whatever its Rwp; if it is None the class was never fitted, so the question was not asked. The third clause of testable — the class’s own fit must leave the window below the detection threshold — is what stops a badly-modelled peak tail refuting a true class, and it is measured: on certified corundum, sham positions 1–3 FWHM below an allowed line, carrying no reflection at all, clear the same 3σ test on 40–50 % of probes.

  3. The absence-free class winning is a result, not a failure. On NAC (I 2₁3) it is the correct answer: I-centring already extinguishes the very reflections the 2₁ screws would, so those screws are invisible in principle. It is a wrong answer when the shared profile fit is bad, and ExtinctionScreen.profile_rwp is the field that tells the two apart: on certified corundum the screen returns the certified R - c - at Rwp 0.149 and the absence-free R - - - at 0.270, from the same cell and the same pattern. Give it a range and a width law its profile fit can match before reading a refutation.

where now names the paths on every guard code, HIGH_CORRELATION included (v1.0, WP-1007). It used to be empty on that one — the paths were recovered from the message by taking its first word, which for a pair is not a path at all — so a consumer had to parse "a ~ b (ρ=+0.994)" to learn which two parameters were degenerate. Read d.where; never split the message.

for d in result.diagnostics:
    if d.code == "HIGH_CORRELATION":
        a, b = d.where          # the degenerate pair, as dot-paths

And ask the package what it can do rather than assuming: rietx.capabilities() returns the live registries — backends (with whether each optional dependency is importable on this machine), solvers, plan presets with their when_to_use text, modes, anodes, the pattern formats read_pattern opens, and the six versioned contracts (schema_version, report_thresholds_version, event_schema_version, project_format_version, textdoc_format_version, indexing_thresholds_version). Its features map is derived from the tree, so features["indexing"] tells you whether this build has an indexer instead of leaving you to try one.

Two of those flags are about speed rather than capability, and they are the ones to read before reporting that a refinement is slow. features["compiled_kernels"] says whether the compiled peak kernels can be built here (numba is a required dependency, but an install may legitimately omit it), and features["compiled_kernels_active"] says whether the next refinement will use them — RIETX_COMPILED=0 in the environment switches them off without a reinstall. Both false on a slow fit is an explanation; both true is not, and the answer is somewhere in the stage plan. Nothing else changes: the numbers agree to one or two units in the last place either way, and the accumulation is bit-for-bit identical.


7f. Two consumers, one answer: the gate and the evidence (WP-1043)

The gate exists for unattended use: a machine that cannot weigh evidence must never be handed one cell confidently, so best_or_none() stays as strict as it is and nothing in this section loosens it. But the gate’s three levels compress the judgement’s inputs, and a consumer that can reason — an LLM in a tool loop, or a human at a screen — wants the inputs, not only the verdict. The design call this section serves: give them all the information, and let the judge, human or machine, be the judge.

What a reasoning consumer reads: result.evidence(), the companion to dump beside the answer. Per candidate: every caveat with its refuting/capping kind (the split confidence_caveats alone withholds); the panel members that ranked, with values, beside fom_undefined — the figures that could not be computed, each absent with its reason, never silently zero; and the whole-profile numbers together — lebail_rwp and both detector counts, surfaced so a reader can see when a detector has failed (as it measurably does on magnetite’s rival, whose fit buys a negative background — §7c’s row), and never a thing to score on. Result-wide: what the search covered (systems_searched + search_complete) against what the list supports (systems_supported). The visual check is part of the answer, not documentation of it: rietx.viz.plot_indexing(result, peaks, data=..., instrument=...) draws the ranked tick rows and the Le Bail panel from the result alone.

Worked example — fluorite, why the two reads differ. Seventeen usable lines on certified CaF₂ (Fm-3m, a = 5.4631 Å). The unattended read: best_or_none() is None — correct, and exactly as strict as ever, because the fom_panel_reduced caveat (capping) holds every below-twenty-line candidate at medium. The reasoning read: the certified cell at rank 1 at −18 ppm, found by every engine that ran, Le Bail-validated converged, four systems searched to completion, and the only caveat on it is the reduced panel — M₂₀/F₂₀ absent for cause, the coverage and reversed members all ranked. A consumer that can weigh that is entitled to adopt the cell with its eyes open; before WP-1043 the same list was refused outright — the old gate conflated scoring (twenty lines, where M₂₀/F₂₀ are defined) with searching (MIN_LINES_PER_DOF per system: seventeen lines are seventeen-fold over-determined for a cubic metric), and neither consumer got anything.

Worked example — bethanechol set F, the truth already at rank 1. On the one externally graded benchmark, the published P2₁/n cell comes back ranked first on set F (measured 2026-08-06: trial_error under the paper’s own manual-mode conditions, −340/+56/+67 ppm with β out by 0.012°) — and the sets are bare positions, so there is no profile to validate against and a single-engine find grades low: the gate can never promote it. The evidence view is where that answer exists for a consumer — rank 1, its figures, and caveats that say precisely what was not checked. An unattended pipeline correctly gets nothing; a reasoner gets the answer with its qualifications attached.

One qualifier on everything above, and on any scoreboard number you quote: nine of ten real-data corpus datasets sit at ≤ 2 free metric parameters (0 orthorhombic, 1 monoclinic, 0 triclinic), so every measured claim here is about high-symmetry lattices until the corpus moves — post-v1 by scope call.

Reference: 7g. The project and recipe readers: what an imported file does not carry

Load it when you read another program’s project file — a TOPAS .inp or a FullProf .pcr — or its recipe file — a PowderLine recipe — and a Diagnostic came with it.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

These are the codes of rietx.io.projects — one module per foreign format, governed by WP-1118 — and of the PowderLine recipe reader. They are a different channel from every other family in §7, but not because none of them ever appears on result.diagnostics — that is equally true of CIF_SPECIES_NORMALISED, CIF_CELL_ANGLE_CORRECTED and every PATTERN_* row, all of which stay in §7. The criterion that does separate them is whose file you are reading: another program’s project or recipe file — a TOPAS .inp, a FullProf .pcr, a PowderLine recipe — against your own data and structure files, which every fit reads whether or not another program was ever involved. A project reader emits its codes at import, into a list you pass in (read_topas_inp(..., diagnostics=[]), rietx.io.projects.fullprof.to_structure(..., diagnostics=[])); the recipe reader emits its own at the read (read_recipe, Recipe.diagnostics). If you do not pass a list the finding for a project import is still on the model — model.coverage.reported, model.skipped_blocks, species_raw — but nothing is raised. So the question a project reader answers is not “is my fit wrong” but “is the model I just imported the model that file describes”, asked before a fit exists; the recipe reader answers a related but distinct question of its own, below. GSAS and GSAS-II have a file of their own, §7h, references/diagnostics-gsas.md, holding four file kinds between them and the writers that put three of them back, under the same criterion.

Each row states, as the others do, what you must not assume once it fires. Two conventions run across the family. A reader repairs and reports where the repair is a stated, recoverable loss and refuses where carrying on would mean choosing between two readings of the file — so an _NORMALISED or _DROPPED code is a repair you can undo, and a _REFUSED one is a build that did not happen. And every construct a format can write is classified in rietx.io.projects.coverage, one row per feature, so support for a construct arrives by moving its row rather than by growing a reader.

code

what you must not assume, and what to do

TOPAS_SPECIES_NORMALISED

(info — from the TOPAS .inp reader: pass read_topas_inp(..., diagnostics=[]) to collect it; it is not on result.diagnostics) Assume the model’s species are the file’s literal tokens. The reader rewrote a sign-first ionic charge (La+3, Cu+1) onto IUCr digit-first order (La3+, Cu1+); each message names its substitution and where lists the atoms it touched. The .inp sibling of CIF_SPECIES_NORMALISED

TOPAS_ORIGIN_TRANSLATED

(info — from the TOPAS .inp reader, same channel) Assume the space group is the file’s literal symbol. TOPAS writes an origin/axis choice as a letter suffix (Pn-3mZ, R-3cH) and the reader translated it to the Hermann-Mauguin extension gemmi resolves (Pn-3m:2, R-3c:H). Not cosmetic: dropping the suffix silently selects the other origin — a bare Pn-3m is choice 1, so a Cu2O written on choice 2 would be built on the wrong origin’s symmetry with nothing raised

TOPAS_BLOCK_SKIPPED

(warning — from the TOPAS .inp reader, same channel) A str block stated a cell or sites but no phase_name/space_group, so the reader could not name it as a phase; it is recorded on model.skipped_blocks (never dropped in silence) and this reports what it lacked and what it carried. Building a Structure from a model with such a block will refuse where other phases build, because dropping a cell-bearing block would leave the weight fractions no longer summing — read model.phases/model.skipped_blocks to see what the file states

TOPAS_CELL_COUPLING_DROPPED

(warning — from the TOPAS .inp reader, same channel) Two cell keys the file ties together arrived as two independent numbers, because resolving either idiom copies the value and not the constraint. The format writes the tie two ways and both are reported: b = Get(a);, one edge naming another, and a = edge; b = edge;, two edges naming one declared parameter. Reported only where the phase’s own space group does not already tie the pair — under P 4/m m m rietx ties b a itself and the model says what the file said, so this stays silent (all four coupled files in the corroborating archive are tetragonal or cubic and raise none of these). Where it fires the two edges may refine apart, which is neither what the file meant nor what rietx would have done unaided: tie them in the refinement plan before fitting, or read model.phases for the file’s own text. The message names which idiom the file used and where names the coupled key

TOPAS_FEATURES_NOT_IMPORTED

(warning — from the TOPAS .inp reader, same channel) The import is partial, and this says how far it goes. A .inp states far more than a structure — peak shapes and convolutions, texture, restraints and penalties, per-reflection controls, scattering-factor overrides — and none of it reaches the built Structure. The numbers that do reach it are unaffected; what is missing is the model around them, so a cell or a coordinate is still the file’s and an Rwp computed against rietx’s own profile is not comparable with the file’s. One diagnostic per import, naming each feature and the keywords the file actually wrote. model.coverage.reported carries the same list whether or not a diagnostics= list was passed, and model.coverage.partial is the yes/no

TOPAS_FEATURE_REFUSED

(warning — from the TOPAS .inp reader, same channel) The file states a construct whose absence would misrepresent the refinement rather than simplify it — a rigid body (the site lines report coordinates the body generated), merged or scaled occupancies (a composition the file does not state once the coupling is gone), a stacking-fault model (the phase does not diffract from the cell it states), or a magnetic structure. read_topas_inp reads such a file and records it, because the model is still an honest account of the text; to_structure is what refuses, naming the construct and what building without it would claim. Read model.phases for what the file states and model.coverage for every construct the import does not carry. Which construct takes which stance is declared in rietx.io.projects.coverage, one row per feature, and support for one arrives by moving its row

FULLPROF_SPECIES_NORMALISED

(info — from the FullProf project reader: pass rietx.io.projects.fullprof.to_structure(..., diagnostics=[]) to collect it; it is not on result.diagnostics) Assume the built structure’s species are the .pcr’s literal scattering tokens. FullProf writes that token in whatever case the author typed (CR) and can write a sign-first charge (CA+2); the reader case-folds and reorders it onto IUCr spelling (Cr, Ca2+) for the Structure, which carries only the one form. read_fullprof_pcr keeps the raw token beside it on species_raw, so the model is checkable against the file even without this — the code fires only at the build, where the raw form is dropped. Each message names its substitution and where lists the atoms it touched; a magnetic form-factor label (MCR3) is left verbatim

FULLPROF_ORIGIN_CHOICE

(info — from the FullProf project reader, same channel) Assume the built phase’s space group is the .pcr’s literal symbol. FullProf writes no origin-choice or axes suffix at all, so the setting was chosen by the reader: origin choice 2 where the bare symbol resolves to 1 (F D -3 MF d -3 m:2, the spinel case), and rhombohedral axes where the cell says so (a bare R -3 c over an a = b = c, α = β = γ cell → R -3 c:R). Not cosmetic: the two R settings differ by a factor of three in general multiplicity, and an origin choice moves every special position. The choice is licensed by the occupancy-ratio check, which a wrong setting fails — except where FULLPROF_OCCUPANCY_UNCHECKED also fired, and then it is unlicensed

FULLPROF_OCCUPANCY_UNCHECKED

(warning — from the FullProf project reader, same channel) Assume the origin/axes choice above was verified. The check that verifies it compares each site’s Occ × M_general / M_site and requires them to agree, but the phase has a single site, so it compared one number with itself and passed for every setting. That is not repairable by a better test — FullProf’s Occ carries one arbitrary per-phase factor, so one site is one equation in two unknowns — so confirm the setting against the published structure yourself, or add a site. where names the site whose occupancy could not discriminate

FULLPROF_TIE_DROPPED

(warning — from the FullProf project reader, same channel) Assume the built Structure has the same free parameters as the refinement the .pcr records. It has more: a .pcr writes a tie by giving two values one parameter number, and this one is not reproduced by rietx. Whether that reports or refuses turns on one question — can you put the tie back in a single unambiguous call? A cell tie the space group does not hold equal (a = b under an orthorhombic or triclinic symbol, or a scaled b = 2a symmetry never expresses) and a cross-site tie in Biso/Occ or in one coordinate column where every site involved has exactly one DOF all report, because the restoring call is exact; this matches TOPAS_CELL_COUPLING_DROPPED one reader over, so the two readers say the same thing about the same defect. A tie whose restoring call would be a choice refuses instead — a group spanning different columns on different atoms, or a coordinate group touching a site whose DOF count is not 1, since dof.k on two different sites is not the same direction. to_structure(..., drop_parameter_ties=True) is how you declare you accept the looser model in that case, and the message then says so. Either way, re-declare the constraint on the refinement — and mind which spelling the call takes: Refinement.tie_equal(['phases.i.atoms.j.biso', ...]) on the column paths for a Biso/Occ group, but on the DOF paths (Refinement.tie_equal(['phases.i.atoms.j.dof.k', ...])) for a coordinate group, because a coordinate column already follows its own dof by site symmetry and tie_equal refuses to outrank that — phases.0.atoms.1.x raises already follows 'phases.0.atoms.1.dof.0' (symmetry) even though the group description names .x. Refinement.tie(path, source, scale=multiplier) for a signed one. Then the parameter count is restored

The RECIPE_* family is the reader of the PowderLine interchange format (read_recipe, Recipe.diagnostics) rather than result.diagnostics, and it answers a question the others do not: how does the fit I am about to run differ from the one the recipe describes? A recipe is written for whichever engine gets it, so some of what it declares has no counterpart here, and every such difference is a row below rather than a silence. Anything the format states that this package cannot represent at all raises RecipeError naming the field — see the manual’s recipe chapter for the list and the reason behind each.

Code

What it means you must not do

RECIPE_FLAG_DROPPED

(warning) Compare this fit’s free-parameter count, esds or χ² against the reference engine’s as if the two refined the same problem. A parameter the recipe flagged for refinement has no counterpart here and its flag was not honoured — GSAS-II’s constant Lorentzian Z, a hump’s Lorentzian γ, or a size/strain half that LG_eta put at exactly its off state, where a softplus parameter’s gradient is its value and nothing can move it. The starting model is unchanged (each of these is at its identity), so the answer is a fit to the same data with one fewer degree of freedom, not a fit to a different model. where names what carries the difference instead

RECIPE_FIELD_DROPPED

(info) Assume every field of the recipe reached the model. A fixed value sitting at its identity was dropped rather than translated: Z = 0 is no constant Lorentzian, a hump’s γ under one 2θ step is a width these data cannot hold, and refinement_cycles is GSAS-II’s cycle count where the stopping rule here is the solver’s ftol. Nothing about the model moved; the record of what the recipe said is now this diagnostic and not the model

RECIPE_FLAG_TRANSLATED

(info) Look for a mixing parameter in the result. GSAS-II carries one magnitude per broadening effect plus a Lorentzian share LG_eta; this package carries a Lorentzian and a Gaussian coefficient and no share, so a freed LG_eta became two free coefficients. Same two degrees of freedom, different names — where gives them

RECIPE_ENGINE_DEFAULT_DECLINED

(info) Read a difference from the reference engine’s broadening as a disagreement about the data. The recipe left a size or strain magnitude null, which GSAS-II fills with its own project default (1 µm, 1000 × 10⁻⁶ Δd/d — measured off its committed output, not read from a manual) and this package reads as silence. On a synchrotron pattern the strain default alone is 0.057°·tanθ, a quarter of the peak width at the top of the range, so the instrument terms will differ by however much that was. Another engine’s project default is not physics; if you want it, state it in the recipe

RECIPE_BACKGROUND_RESEEDED / RECIPE_SCALE_RESEEDED

(info) Compare a background coefficient or a phase scale against the recipe’s own number, or against another engine’s. Neither is transferable: the two codes scale the Chebyshev domain differently, and a scale factor’s normalisation is each code’s own (on one committed specimen GSAS-II converges to 3.77e-2 and TOPAS to 2.61e-6). The term count and the refine flag are carried; the values are re-seeded, the scale by matching the summed calculated intensity to the data. Phase scale ratios are comparable and the absolute values are not — which is what a recipe quantifying phase fractions actually needs

RECIPE_BACKGROUND_PEAK_DEGENERATE

(warning) Quote the background-peak parameters, or expect the fit to converge. The recipe declares a peak whose FWHM reaches the fitted range, so it never falls to half height inside the window and carries no curvature the Chebyshev terms have not got — it will correlate with the low-order background at |ρ| = 1 and the stage will spend its budget walking that valley. Both reference engines confirm it from opposite ends on the one committed instance: one let the peak run to 8.77e10 °2θ at esd 0, the other kept it at 1.63° with an esd 188× its own value. A hump substitutes for polynomial terms; it never adds to them (§HUMP_TOO_NARROW is the same lesson from the narrow end)

RECIPE_CONVENTION_ASSUMED

(info) Treat the split as measured. GSAS-II’s SH/L is a combined (S+H)/L and this package’s axial divergence is two parameters, so it was halved evenly — the symmetric Finger-Cox-Jephcoat reading, and the only one a single number admits. No committed recipe can distinguish it from an uneven split; if your specimen’s slit and detector heights differ, set axial_sl and axial_hl yourself

RECIPE_PLAN_STAGED

(info) Read the stage list as a change to what the recipe asked to refine. PowderLine runs one pass over everything flagged; Recipe.plan frees the same set over several stages in McCusker order, because a single cold step walked a monoclinic cell to a = 4231 Å on a real fixture. Staging here is cumulative, so the last stage is the recipe’s single pass — the free set at the end is the recipe’s and only the route differs

RECIPE_DISPERSION_DECLINED

(info) Read it as an approximation you are choosing. The recipe’s wavelength is outside the bundled Cromer-Liberman table’s 3-70 keV band — a PDF-beamline λ = 0.1665 Å is 74.5 keV — so f = f₀ is used. Every edge of every element in such a recipe is more than an order of magnitude below that energy, so it is the correct limit rather than a concession; what would be wrong is extrapolating the table. The result still carries DISPERSION_NEGLECTED, as any dispersion=None fit does

RECIPE_FIT_RANGE_CHANNELS

(info) Compare an Rwp against another engine’s before checking this count against theirs. A recipe states a fit_range in angles and an engine fits channels, and the two need not agree to the channel: on the committed fixtures this package’s inclusive mask selects 3767 where GSAS-II’s own fit_profile.txt shows it fitted 3768, keeping the first channel past the stated upper limit. value is the count and the message gives the first and last 2θ

RECIPE_ZERO_WEIGHT_EXCLUDED

(info) Assume the pattern’s channel count is the fitted count. A recipe excludes a point by giving it weight 0, which has no finite σ, so those channels became excluded_regions instead. Same points excluded, different mechanism

RECIPE_BOUND_HONOURED

(info) Read a BOUND_HIT on this path as a bug. The recipe’s 4-tuple carried a min/max, which PowderLine documents as not implemented in GSAS-II — honouring it is the conservative reading and a recorded difference from the reference engine, not an accident

RECIPE_SCHEMA_UNTESTED

(warning) Trust a field whose meaning may have moved. The recipe declares a schema_version this reader has not been checked against; it is read anyway, because PowderLine’s models allow extra fields on purpose, but a field that changed meaning is being read with its old one

RECIPE_NOTHING_REFINED

(warning) Read the answer as a refinement. No parameter in the recipe is flagged, so the plan has one stage that frees nothing — the model evaluated at its declared values, which is a simulation

Reference: 7h. The GSAS and GSAS-II readers and writers: what their files do not carry

Load it when you read a GSAS .EXP or a GSAS-II .gpx project file, a GSAS .prm or a GSAS-II .instprm instrument-parameter file, or when you write one of those back or export a phase CIF for GSAS-II, and a Diagnostic came with it.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

These are the codes of rietx.io.projects.gsas and rietx.io.projects.gsas2, together with the instrument-file and phase-CIF pairs rx.read_gsas_prm / rx.write_gsas_prm, rx.read_gsas2_instprm / rx.write_gsas2_instprm and rx.write_gsas2_phase_cif. They are §7g’s channel and not the engine’s: a reader or a writer emits them into a list you pass in (rx.read_gsas_exp(..., diagnostics=[]), rx.read_gsas2_gpx(..., diagnostics=[]), rx.read_gsas_prm(..., diagnostics=[])), and none of them ever reaches result.diagnostics. §7g, references/diagnostics-projects.md, states that rule in full and holds the TOPAS and FullProf rows.

The two programs share a file because their row sets are parallel and a migration reads them against each other. Five of the .EXP suffixes are also .gpx suffixes, and two of the .prm ones are also .instprm ones, so the row you want sits beside the row you are comparing it with. The writer rows are here too. A .EXP, a .prm, an .instprm and a GSAS-II phase CIF are all written back, this build writing no .gpx, and what a writer could not carry is as much a difference from the other program’s answer as what a reader could not read.

Each row states, as the others do, what you must not assume once it fires. Two conventions run across the family. A reader repairs and reports where the repair is a stated, recoverable loss and refuses where carrying on would mean choosing between two readings of the file — so an _NORMALISED or _DROPPED code is a repair you can undo, and a _REFUSED one is a build that did not happen. And every construct a format can write is classified in rietx.io.projects.coverage, one row per feature, so support for a construct arrives by moving its row rather than by growing a reader.

code

what you must not assume, and what to do

GSAS_EXP_HISTOGRAM_NOT_READ

(warning — from the GSAS .EXP reader: pass read_gsas_exp(..., diagnostics=[]) to collect it, or read_project_model(..., diagnostics=[]); it is not on result.diagnostics) Assume every histogram in the file reached the model. One did not: it is single-crystal data, or it is time-of-flight or energy-dispersive rather than constant-wavelength, whose profile functions number their coefficients differently. The histogram is still on model.histograms with its wavelengths and ranges; what is missing is its named profile coefficients, because naming them under a constant-wavelength function’s order would mis-assign every width. model.unsupported carries the same lines whether or not a diagnostics= list was passed

GSAS_EXP_BACKGROUND_NOT_CARRIED

(info — from the GSAS .EXP reader, same channel) Assume the background crossed over. It did not, and it mostly cannot: GSAS’s seven background functions include a reciprocal-Q expansion for air scatter and a linear interpolation between fixed points, neither of which is a rietx background. The function number and its coefficients are on model.histograms[…].background, so you can see what shape the run fitted; choose your own background and refine it rather than converting the numbers. This is why an Rwp you compute is not comparable with the file’s until you have matched the background too

GSAS_EXP_PHASE_MAGNETIC

(warning — from the GSAS .EXP reader, same channel) Assume the phase is nuclear. GSAS phase types 2 and 3 are magnetic, and rietx has no magnetic scattering model, so to_structure refuses it rather than handing back the nuclear half — which would look complete. The cell and sites are readable on model.phases. The same stance the .inp and .pcr readers take, and it lifts for all three together when the magnetic model lands

GSAS_EXP_SPECIES_NORMALISED

(info — from the GSAS .EXP reader, reported at build: pass diagnostics= to to_structure) Assume the built structure’s species are the file’s literal tokens. GSAS writes them in upper case (CA, NA) and the reader title-cases them onto IUCr spelling (Ca, Na) for the Structure, which carries only the one form. read_gsas_exp keeps the file’s own spelling on model.phases[…].atoms[…].species, so the model stays checkable against the file. The .EXP sibling of CIF_SPECIES_NORMALISED and FULLPROF_SPECIES_NORMALISED

GSAS_EXP_COORDINATES_SYMMETRY_FIXED

(info — from the GSAS .EXP reader, reported at build) Assume a site carrying GSAS’s X refine flag has free coordinates. GSAS’s flag means “refine as permitted by symmetry”, and these sites sit on fully fixed special positions, so nothing of them was free in GSAS either. Carrying the flag onto x/y/z directly would make the whole import raise, because rietx refines coordinates as site-symmetry DOFs and a zero-DOF site refuses a vary request. Nothing is lost here and the parameter count is right; the row exists so that “this site is held” reads as the file’s statement rather than as the reader’s omission. where names the sites

GSAS_EXP_SCALE_NOT_COMPARABLE

(info — from the GSAS .EXP reader, reported at build) Assume the phase scale came from the file. It did not: it is seeded at 1e-3 and left to refine. GSAS’s histogram scale folds its own profile normalisation and the phase fraction into one number, so it is not a rietx scale and converting it would be inventing a correspondence. The file’s own numbers are on model.histograms[…].scale and model.hap[…].phase_fraction. Refine the scale rather than trusting a converted value — and for the same reason, phase fractions from the two codes are comparable only after both have refined

GSAS_EXP_VALUE_NARROWED

(info — from the GSAS .EXP writer: pass write_gsas_exp(..., diagnostics=[]) to collect it) Assume a written value is the value you had. A .EXP is read by column, so a number is worth exactly the ten characters its field holds; one whose shortest exact decimal is longer is written to the precision the field carries. The row fires once for the file, naming how many values and the worst of them, and where lists every one. A displacement is always among them, since GSAS stores Uiso and rietx stores Biso and the 8π² between them makes every Biso a long Uiso: about seven significant figures survive, 1e-7 of the value against an esd of ~1e-2, and two figures better than the six decimals GSAS itself writes. Nothing to act on unless you are comparing bit patterns

GSAS_EXP_REFINE_FLAG_MERGED

(warning — from the GSAS .EXP writer, same channel) Assume the file’s refine flags are your Structure’s. GSAS states one flag for a whole cell and one X letter for a site’s three coordinates, both meaning “refine as symmetry permits”, where rietx carries a vary per parameter. Where a group’s members disagree the flag is written free, so the file frees a parameter you held, and the row names the group. Reading the file back gives every member of that group the one flag, so this is the one place a round trip does not return what it was given. Hold the whole group, or free it, if the distinction matters

GSAS2_GPX_ITEM_NOT_READ

(warning — from the GSAS-II .gpx reader: pass read_gsas2_gpx(..., diagnostics=[]) to collect it, or read_project_model(..., diagnostics=[]); it is not on result.diagnostics) Assume everything in the project reached the model. A .gpx is a whole GSAS-II session and this reader takes the powder half of it: an image, a single-crystal histogram, a sequential-fit result and a restraint set are each named here and carried nowhere. A time-of-flight histogram is the case to watch — it is on model.histograms with its numbers, but its coefficients are a different profile function from the one this build fits, so Gsas2Histogram.constant_wavelength is what decides whether the terms mean what their names suggest. model.unsupported carries the same lines whether or not you passed a list

GSAS2_GPX_BACKGROUND_NOT_CARRIED

(info — from the GSAS-II .gpx reader, same channel) Assume the background crossed over. GSAS-II’s own function name and its coefficients are on model.histograms[…].background, including the Debye terms and fitted background peaks it counts separately, and none of it is converted: choose your own background and refine it. This is why an Rwp you compute is not comparable with the file’s until you have matched the background too — the .EXP sibling of this row says the same thing about the older format

GSAS2_GPX_PHASE_MAGNETIC

(warning — from the GSAS-II .gpx reader, same channel) Assume the phase is purely nuclear. It fires in two shapes and they differ in what you can do next. A phase GSAS-II types magnetic is refused outright by to_structure, because the nuclear half would look complete — the same stance the .inp, .pcr and .EXP readers take. A phase typed nuclear that carries Gsas2Phase.magnetic_partner does build, correctly, and the warning is about comparison rather than about the structure: the file’s Rwp includes magnetic scattering this build cannot compute, so your fit’s figures and the file’s are not the same measurement

GSAS2_GPX_CONSTRAINT_NOT_CARRIED

(warning — from the GSAS-II .gpx reader, same channel) Assume the constraints came with the model. They did not: to_structure applies none of them, because a tie between parameters the build has not made yet is not a tie. They are on model.constraints, each printing as the relation it states, and Gsas2Constraint.is_equivalence marks the ones Refinement.tie_equal restores in one call each — the count in the message is how many. A hold (kind == 'h') is a set_vary(path, False); a constraint equation and a new variable have no one-call equivalent, so read them before assuming your free set matches the file’s

GSAS2_GPX_SPECIES_NORMALISED

(info — from the GSAS-II .gpx reader, reported at build: pass diagnostics= to to_structure) Assume the built structure’s species are the file’s literal tokens. GSAS-II writes an ionic charge sign first (Mn+3, O-2) and the reader rewrites it to IUCr spelling (Mn3+, O2-) for the Structure, which carries only the one form. read_gsas2_gpx keeps the file’s own spelling on model.phases[…].atoms[…].species, so the model stays checkable against the file. The .gpx sibling of CIF_SPECIES_NORMALISED and FULLPROF_SPECIES_NORMALISED

GSAS2_GPX_COORDINATES_SYMMETRY_FIXED

(info — from the GSAS-II .gpx reader, reported at build) Assume a site carrying GSAS-II’s X refine flag has free coordinates. That flag means “refine as permitted by symmetry”, and these sites sit on fully fixed special positions, so nothing of them was free in GSAS-II either. Carrying the flag onto x/y/z directly would make the whole import raise, because rietx refines coordinates as site-symmetry DOFs and a zero-DOF site refuses a vary request. Nothing is lost and the parameter count is right; the row exists so that “this site is held” reads as the file’s statement rather than as the reader’s omission

GSAS2_GPX_SCALE_NOT_COMPARABLE

(info — from the GSAS-II .gpx reader, reported at build) Assume the phase scale came from the file. It did not: it is seeded at 1e-3 and left to refine. GSAS-II keeps a histogram scale and a per-phase scale, each folding its own normalisation into one number, so neither is a rietx scale and converting one would be inventing a correspondence. The file’s numbers are on model.histograms[…].sample['Scale'] and model.hap[…].scale. Refine the scale rather than trusting a converted value — and for the same reason, phase fractions from the two codes are comparable only after both have refined

GSAS2_GPX_SETTING_FROM_OPERATORS

(info — from the GSAS-II .gpx reader: pass read_gsas2_gpx(..., diagnostics=[])) Assume the phase was built under the symbol the file wrote. It was built under the setting the file’s operators describe, which need not be the same: GSAS-II stores the operations themselves (SGData['SGOps']), so a bare two-setting symbol like F d d d is settled by the file rather than by gemmi’s reading of its name — and in all four tutorial-corpus phases that write one, the operators are origin choice 2 where the bare symbol resolves to choice 1. Nothing is lost and nothing is owed; the row exists because the built Structure then carries a symbol ('F d d d:2') that is not the string in the file, which Gsas2Phase.space_group still holds beside .resolved_space_group. It is also why the .gpx route does not raise SPACE_GROUP_SETTING_ASSUMED where the .EXP and .inp routes do: those formats state a symbol and no operators, so nothing in them can settle it and the reader says so at read rather than leaving it to a fit

GSAS_PRM_FIELD_DROPPED

(info — from rx.read_gsas_prm, same channel) Assume the returned Instrument carries everything the .prm states. It carries what ProfileTCHZ and Geometry have room for, and one of these fires per record that held something else at its identity value: ICONS (a zero LAM2 or ZERO, the polarization type IPOLA, the refine flags and IDAMP, and KRATIO where the file states no second line to weight), PRCF (GP and every coefficient past position 8, all 0), and IRAD/ITYP, ignored by design because the wavelength is read from ICONS rather than looked up and an angular range belongs to the pattern. A non-zero value in any of those raises instead of reporting: the rule is refuse at drift, report at the model’s identity, so an info here means the file agreed with the model rather than that something was lost. where names the record. A file stating a doublet comes back with two emission lines, the second weighted by KRATIO, and the ICONS row says so — so do not add a Kα2 line yourself after reading one (Measured: WP-1118)

GSAS_PRM_GEOMETRY_ASSUMED

(warning — from rx.read_gsas_prm, same channel) Do not assume the geometry was read: a .prm does not state one. HTYPE PXCR spans Bragg-Brentano and Debye-Scherrer and the format gives nothing to tell them apart, so the reader returns Instrument.debye_scherrer — a capillary, packing_fraction=0.6 — because that is what the corpus behind the reader is, and fires this every time rather than inferring. It matters more than a dropped zero: Geometry.kind selects the position correction and its suggested action, and the two geometries’ absorption corrections have different off states (mu_r = 0 against mu_t = ), so a flat-plate calibration read this way arrives with the wrong half of that machinery armed. If the calibration is from a flat-plate diffractometer, set the geometry yourself before fitting — and carry geometry.axial_sl and geometry.axial_hl over to the replacement, because PRCF positions 7-8 (S/L, H/L) were read from the file and land on the geometry, so a Geometry built from scratch has no axial divergence at all

GSAS_PRM_FIELD_NOT_WRITTEN

(warning — from rx.write_gsas_prm: pass diagnostics=[]) Assume an exported .prm is your whole instrument. It is what GSAS’s own ICONS and PRCF records can state, and the geometry is never among that: a .prm states none at all, so the kind, the sample displacement and transparency and the specimen absorption stay behind and reading the file back gives debye_scherrer — the writer’s side of GSAS_PRM_GEOMETRY_ASSUMED above, and the reason S/L and H/L are worth noticing, being the two geometry numbers that do cross. The row also names a true-Voigt profile (GSAS’s type 3 is a pseudo-Voigt with no Voigt option, so the widths cross and the shape model becomes the target’s), any extra_components, and the refine flags, which an instrument file deliberately does not carry

GSAS_PRM_VALUE_NARROWED

(info — from rx.write_gsas_prm: pass diagnostics=[]) Assume an exported .prm holds your numbers to the last digit. A .prm is read by column and a column is the budget, so a value needing more characters than its field holds was written to what fits, the largest change named. On a converged calibration this is the ordinary case rather than a warning sign: the centidegree conversion adds the product’s own float noise, so a u of 0.0043710000000001 becomes a GU of 43.710000000000996 and then the characters the field allows. The .prm twin of GSAS_EXP_VALUE_NARROWED

GSAS2_INSTPRM_CONVENTION_ASSUMED

(info — from rx.read_gsas2_instprm: pass diagnostics=[]) Assume the two axial-divergence terms are the file’s own numbers. GSAS-II models one combined (S+H)/L where this package holds axial_sl and axial_hl, so the single number was split evenly — the symmetric Finger-Cox-Jephcoat reading, and the only one a single number admits. rx.read_recipe makes the same split for the same field. If you know the slit and detector heights differ, set the pair yourself

GSAS2_INSTPRM_GEOMETRY_ASSUMED

(warning — same reader) Do not assume the geometry was read: this file stated no Diff-type. The fallback is GSAS-II’s own — a stated Kα doublet is Bragg-Brentano and anything else Debye-Scherrer — and the two differ in more than a name: a different position correction, and absorption corrections whose off states are opposite (µR = 0 against µt = ∞). Set geometry.kind yourself if you know the diffractometer, and carry axial_sl/axial_hl over first, since SH/L was read from the file and a fresh Geometry starts both at 0

GSAS2_INSTPRM_VALUE_DEFAULTED

(warning — same reader) Assume every coefficient came from the file. The named items are absent from it and came back at this package’s defaults. GSAS-II writes every item of a bank and its own header says not to delete any, so a missing one means the file was hand-edited — check the values it does state before trusting the instrument

GSAS2_INSTPRM_FIELD_NOT_READ

(info — same reader) Assume the whole file crossed. The named items have no place in an Instrument: Bank names a detector rather than describing one, InstrName is a name, and on a PNC (neutron) bank Polariz. is inert in both packages — GSAS-II applies the polarization factor only to an XC or XB type, and NeutronSource pins K = 1

GSAS2_INSTPRM_VALUE_MERGED

(warning — from rx.write_gsas2_instprm: pass diagnostics=[]) Assume the file round-trips your axial pair. axial_sl and axial_hl are written as their sum, GSAS-II modelling one number, so reading the file back gives half the sum twice. The row fires only where the two differ, which is the one case the merge loses something

GSAS2_INSTPRM_VALUE_FLOORED

(warning — same writer) Assume GSAS-II will model the SH/L this file states. It floors the value at 0.002 when it evaluates a profile, so a smaller sum is written faithfully and modelled as the floor by the program the file is for. No round trip through this package can show it: the floor is the other reader’s, not the file’s

GSAS2_INSTPRM_FIELD_NOT_WRITTEN

(warning — same writer) Assume an exported .instprm is your whole instrument. It is what GSAS-II’s own instrument items can state: never the background, the specimen absorption, the sample displacement or the transparency, which that program keeps in its sample parameters, and never the refine flags, a calibration having refined nothing by the time it ships. A true-Voigt profile is named here too — GSAS-II’s constant-wavelength function is a TCH pseudo-Voigt with no Voigt option, so the widths cross and the shape model becomes the target’s

GSAS2_CIF_SETTING_IN_OPERATORS

(info — from rx.write_gsas2_phase_cif: pass diagnostics=[]) Assume the exported CIF’s H-M symbol states this phase’s setting. It cannot: GSAS-II resolves a bare two-origin symbol such as F d -3 m to origin choice 2 and answers a colon-suffixed one by setting the phase to P 1, while gemmi resolves the same bare string to choice 1. So the file states the setting three times — the bare symbol in _symmetry_space_group_name_H-M, the resolved one in _space_group_name_H-M_alt, and the operations in _space_group_symop_operation_xyz, which is the channel GSAS-II checks its own reading against. Nothing is lost; read the operations, never the symbol, if a third program opens the file

GSAS2_CIF_FIELD_NOT_WRITTEN

(warning — same writer) Assume a GSAS-II project built from this CIF starts where your refinement did. A phase CIF states no refine flags, no phase scale and no sample broadening, so that program starts from its own defaults for all three, and any Stephens strain, preferred orientation, extinction or restraints the phase carries are named here because a CIF has no tags for them

Reference: 8. Twenty-four things that will surprise you, all measured

Load it when something the fit did makes no sense. Every entry is a measured result that contradicts an intuition.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

These are the findings from building the package that change how an agent should behave. Each one cost a debugging pass.

8.1 A correction that provably cannot improve Rwp can still be essential. The capillary absorption factor factors exactly into a constant × exp(c·sin²θ) — a Debye-Waller shape — so applying it to a model with a free scale and free Biso leaves the residual unchanged to machine precision. Its entire content is that a Biso refined without it comes back low by 0.49 Ų at µR = 1 (Cu Kα), which is comparable to Biso itself and 19σ against its own esd. result.absorption reports the bias because no fit statistic can. Corollary for the agent: never judge a correction by Δ Rwp. Ask which physical quantity it unbiases.

Measured end to end on a real capillary pattern (11-BM, NIST SRM 660a LaB₆ in the documented 0.81 mm bore, µR = 0.674): Rwp moved 3 × 10⁻⁸, the lattice parameter 8 × 10⁻¹² Å, and both displacement parameters moved by +0.0166542 Ų against a predicted 0.0166542. That is what “exact” means here. It is not a general property of absorption corrections — see 8.12.

rietx compare is this rule made mechanical: a browser UI comparing refinement settings side by side on the bundled standards, whose cumulative-Δχ² panel shows where a correction acted rather than whether Rwp moved.

8.2 The opposite also happens: an improvement that passes every statistical test and is still rejected. On round-robin brucite, adding anisotropic strain improves Rwp 18.55 → 17.90 % with ΔBIC +488 — and drives σ²(M) negative on 12 of 43 reflections, so the coefficients are unphysical and unquotable. A statistical test cannot see a violated positivity cone.

8.3 Judge a correction at the reflections, not on the fitted grid. The IUCr round-robin patterns start at 5° 2θ but their first reflections are at 25–32°. A grid-based fence cheerfully reported a 27 % low-angle intensity depression that no modelled peak ever experienced. If you are reasoning about where a correction has leverage, the relevant coordinate is where the reflections are.

8.4 A multiplicative correction is trivially ~0.96 “scale-like”. Any correction that rescales intensity projects almost entirely onto the scale column, so a raw R² against a block containing the scale is a constant that says nothing. Use the partial R² with the scale and background projected out first — optimize.statistics.block_projection_r2(..., nuisance=...). With that fix the statistic tracks real identifiability (R² 0.06 → 0.95 as the low-angle reflections leave the fitted range); without it, it saturates and the guard is blind.

8.5 Pairwise correlation is the wrong statistic for a block. With ~100 spline coefficients, each individual |ρ| against a structural parameter stays around 0.2 while the block collectively absorbs ~46 % of it. Ask “can this group, acting together, imitate that one?” — that is what background_absorption measures.

8.6 Some parameters are dead at zero and some explode there. A softplus-transformed coefficient (extinction, roughness strength) has dp/du → 0 at the floor, so it will never move: it needs Stage.seed. A Stephens block has Λ ∝ √Σ with unbounded slope at Σ = 0, so it needs Stage.strain_seed to start on the isotropic ray instead. These are opposite pathologies with opposite fixes; the plans already carry both.

8.7 A default is a decision, in either direction. Anisotropic ADPs, Stephens strain, surface roughness and preferred orientation are opt-in per atom/phase, because each needs a number about this specimen that the file does not carry. Anomalous dispersion is the exception and is on by default since v1.0: it needs only the species and the wavelength, both already in the model, so declining it is the choice that has to be justified. Turning any of them on or off changes every number downstream, including published acceptance values — if you change one, re-measure; do not carry a comparison across the change. Note the two knock-on effects WP-1001 measured when dispersion went to the default: on a specimen sitting inside an absorption-edge interval the lookup raises rather than degrading (that is deliberate — a selective fallback would leave some species corrected and others not, manufacturing exactly the unequal cross-phase bias the correction exists to remove; decline the block or supply overrides), and light-atom ADPs come back less precise even as they come back less biased (rutile U11/U33 separate at 1.9σ with the block on against 2.2σ without, because f″ raises the heavy atom’s share).

8.8 Never transfer a literature constant without a numerical check across all its arguments. A published cylinder-absorption coefficient printed as “−0·0375” is really −0·3750; the error is invisible against a constant-θ slice of the paper’s own table (which constrains only the other two coefficients) and is 0.082 wrong at µR = 1. What caught it was a quadrature of the exact integral the fit approximates — which shares no constant with any published fit. The general rule: the strongest anchor is the integral a fit approximates, not another code’s transcription of the same fit. The same applies to Stephens S_HKL, where codes fold symmetry multiplicities into their templates and print values differing by small integer factors.

8.9 An absurd statistic is more often the linear algebra than the physics. JᵀJ here is routinely conditioned at ~10²⁰ — that is what the degeneracy table in §3 means numerically. Until 2026-07-28 the covariance was taken with np.linalg.pinv’s general SVD path, which does not know the matrix is symmetric and returned correlations as large as |ρ| ≈ 1.6 × 10³ (and +2.75 for scale ~ axial_sl on a real fluorite fit). If a reported correlation, esd or weight-fraction uncertainty is impossible rather than merely surprising, check the conditioning before you invent a physical story for it — and remember that the fix is to compute the covariance properly, not to clip the output, because clipping 2.75 to 1.0 reports a degeneracy the arithmetic invented rather than the one the data has.

8.10 Conventions are documented by physics, never by letter. GSAS and FullProf swap the X/Y size/strain labels. Most tabulations print the absorption correction A* = 1/A where this package multiplies by the transmission A ≤ 1 — and both equal 1 at µR = 0, so an identity test cannot tell them apart; only the direction of the θ-dependence can. The March coefficient r means opposite habits in reflection and transmission geometry. Read the docstring, not the symbol.

8.11 The anode is a physics choice, not a number to look up. All six radiation= presets come from one column of one evaluation (NIST XRTE SRD 128), and the shipped CuKa pair is bit-identical to it — that is what makes the others trustworthy, so never substitute a value from elsewhere; Bearden’s widely-quoted numbers are a different scale, 24–26 ppm away at Mo/Ag. Three things then follow from which anode, all measured:

  • The Kα1/Kα2 gap grows from 20 eV at Cu to 173 eV at Ag, so the one-|F|²-per- source assumption gets weaker. A census over Z = 3–98 × six anodes refuses 7 of 576 combinations, and one is a real specimen: Ru at Ag Kα, K edge 22.14 keV, between the lines. The refusal is correct — split the lines into separate histograms or supply a measured override.

  • What DISPERSION_NEGLECTED is warning about is anode-dependent: hematite is a warning at Co Kα (180 eV under the Fe K edge, f′ = −3.3 e) and an info at Mo Ka (f′ = +0.3 e). Same specimen, same code, different severity.

  • Contamination checks are per anode. An unrecognised wavelength (synchrotron, an untabulated anode) yields contamination == []not checked, not clean. background.identify_anode(λ) returns None there and is how you tell the two apart.

8.12 “Infinitely thick” is a modelling claim you make by saying nothing. Every flat-plate fit in this package — and by default in every Rietveld code — assumes the specimen is thicker than the beam penetrates. That is exactly right for a filled well and badly wrong for a thin layer on a zero-background holder, which is how small, precious or air-sensitive samples are usually mounted. The error is much larger than the capillary case and has the opposite sign: ΔBiso = −1.5 Ų at µt = 0.2 over a Cu Kα range, because a thin specimen runs out of material exactly where the beam penetrates deepest, and the missing high-angle intensity reads as thermal motion.

Four consequences for an agent:

  • Declare Geometry.mu_t (or thickness_mm, and let it be estimated) whenever the specimen is a thin mount. Silence means thick.

  • The “off” value is µt = ∞, not 0 — the reverse of every other correction here. mu_t = 0 is a specimen of no thickness and is refused for reflection geometry rather than being taken as “no correction”. Under flat_plate_transmission it is the other way round: silence means a transparent plate, and the sec θ footprint factor applies regardless, because it belongs to the tilt rather than to the absorption.

  • The reported ΔBiso is a lower bound here, not the answer. For the capillary it is exact (seven decimals on real data); for a flat plate the bias a fit actually absorbs runs 1.06–1.5× larger, tracking absorption.unabsorbed_fraction — which is on the record for exactly this reason. Quote it as “at least this much”, with the residue beside it.

  • Unlike the capillary case this one does move Rwp, because it is not an exact reparameterisation (1–40 % of ln A survives a free scale and Biso). So 8.1’s rule inverts: here a worse Rwp after declaring a thickness is evidence the specimen was not that thin. Measured on round-robin fluorite — a thick back-packed mount — declaring µt = 0.5 takes Rwp 0.1793 → 0.1830 and drives one Biso onto its bound. That is the correction correctly refusing to fit a specimen that is not there.

8.13 A stage that takes minutes is telling you it is degenerate. Measured on three weighed NaCl/Li₂CO₃ mixtures — identical models, identical parameter counts, same-sized patterns — wall clock ran 39 s, 858 s and 2 838 s, a 73× spread with no corresponding difference in the answer, and the pass reported status="converged" either way. Until v1.1 the budget made that spread far worse than it needed to be: max_nfev was max_iter × n_par, pricing a finite-difference Jacobian the package does not build, so at 46 free parameters a single max_iter=100 stage could spend 4 600 evaluations before giving up. It is now max_iter × a small measured constant (the worst evaluations-per-iteration ratio over 28 real stages is 3.2), so a stalling stage says so roughly 30× sooner and a converging one is untouched — every protocol measured stops an order of magnitude inside the cap. That changes when you hear about it, not what you should do about it. The stages that stall are the degenerate groups of §3, so the signal is available before you run them: per-phase size/strain freed against a still-free instrument U,V,W,X,Y (they model one width curve; the package’s own lab_sample_refine only frees them against a frozen calibrated instrument), or preferred orientation whose coefficient has reached a bound. Corollary: treat elapsed time as a diagnostic. If a stage runs long, do not wait for it — look at what you freed.

8.14 A bound that exists is not a bound that holds. PreferredOrientation.r is declared min=0.0 with a softplus transform, the idiom that is supposed to keep a parameter strictly positive. The softplus pre-image runs to −∞, so r reaches exactly 0, and the March-Dollase factor then evaluates (1 c)/r and returns inf/NaN. Nothing raises: the residual becomes garbage and the trust region grinds through its whole budget on it (a 3-second stage that had not returned after ten minutes). Bounding r to 0.15–6 fixed the stall and the fit, Rwp 30.8 % → 13.2 %. Corollary: for any parameter whose model divides by it, set a real floor rather than trusting the transform — and read RuntimeWarning: divide by zero as a fit-stopping error, not noise.

8.15 A coverage score cannot tell a mixture from a low-symmetry single phase, and this project already published that mistake and withdrew it. Measured on third-party data with an engine restricted to two metric parameters: it indexed 47–60 % of the lines of single-phase orthorhombic and monoclinic patterns, 82–100 % of genuinely tetragonal or hexagonal ones, and 69 % of a real two-phase mixture. The bands overlap. A “this pattern contains at least two phases” claim built on the 69 % had to be retracted, because the same number is what a single-phase pattern of symmetry the engine could not reach produces. IndexingResult therefore carries systems_searched beside search_complete and reports failure as “no cell found in the systems searched”; no diagnostic code in the indexing vocabulary asserts a phase count at all, and a test pins that. Corollary: partial coverage is a statement about your search, not about the specimen. Widen the systems before you conclude anything about the sample.

8.16 An indexer’s tolerance is not its precision, and the gap is 11σ on real data. The peak list carries a fitted σ(2θ) per line — median 0.0056° on the bundled corundum pattern, whose cell is certified — and that σ is exactly right for weighting and exactly wrong as a matching window: the same pattern’s lines sit a median 0.060° from the certified positions, a cos θ specimen displacement of −0.065°. At 3σ the true cell indexes zero lines and both engines return nothing, on a pattern whose answer is known. That is why every indexing program in the literature ships a global ~0.03° tolerance, and why the engines here add an assumed 0.05° in quadrature and say so with INDEX_SHIFT_ALLOWANCE. Corollary for the agent: a cell found under a widened window has absorbed the shift (+1400 ppm measured), so re-fit it with shift_template before quoting it — and the way to earn high confidence is to supply a measured shift_allowance_deg — the shift’s amplitude, not the residual scatter a template leaves — from an internal standard, not to widen further.

8.17 “Is there intensity here?” is not one question — it depends on what else your hypothesis predicts nearby. Two detectors in this package ask it with the same window (±½ FWHM) and the same threshold (3σ), and they must use different null models. WP-1024’s predicted_but_absent asks it against the fitted background, which is right for an oversized cell’s phantom reflection because a phantom sits in a gap. WP-1025’s extinction screen asks it at a forbidden position, which sits inside a dense predicted pattern — and measured on the FAP lab pattern, the 003 that P 6₃/m forbids is 0.89 FWHM from an allowed neighbour ten times stronger whose tail fills the window to +27.6 σ. Against the class’s own y_calc — background plus every reflection the class still allows — the same window reads −3.9 σ. Corollary for the agent: never compute your own “nothing is here” test from the raw pattern minus a background. Where nothing else is predicted nearby the two agree; where something is, the raw test refutes the true answer.

8.18 A position correction belongs to a geometry, and the suggestion you get now says which. cos θ is the flat-plate specimen-displacement shape, and instrument.geometry.sample_displacement is force-fixed on anything that is not bragg_brentano. A capillary off the centre of the 2θ circle has its own pair (McCusker eq 4): instrument.geometry.capillary_offset_along_beam carries sin 2θ and …_across_beam carries cos 2θ, they exist only on debye_scherrer, and both need goniometer_radius_mm, which eq (4) divides by — a value or a vary without one is refused by name rather than defaulted. Free them for a laboratory capillary or Guinier camera; at a synchrotron with a crystal analyser the paper says the displacement error is eliminated, and measured on 11-BM NAC the fit agrees, so freeing them there measures nothing.

The report’s position templates and actions are now chosen by geometry, so the two refine_sample_* actions no longer reach a capillary fit at all (before WP-1073 they did, naming parameters that could not be freed). On flat_plate_transmission they do not reach either (WP-1003): that geometry models neither aberration, so a cos_theta or sin_2theta trend there is reported as a shape with no action — read it as “a flat specimen off the axis”, evidence with no legal one-click repair. And this is a correction whose cause the endpoint hides: measured on a synthetic capillary with a 0.30/−0.20 mm offset, refusing the pair puts −290 ppm into a, and the converged report names no position cause, because the zero shift and the cell between them imitate most of eq (4). The zero stage’s own rung names refine_capillary_offset_along_beam at 0.66. Corollary for the agent: this is §9’s rule with a concrete case — read the trajectory, not the last state.

8.19 A restraint’s weight is a per-stage decision, and a fit can converge to an impossible bond without saying so. Stage.restraint_weight_scale is c_w of McCusker eq (7), S = S_y + c_w·S_G: high while the structure is incomplete or approximate, reduced as it improves. It defaults to 1.0 (every restraint exactly as declared), and 0.0 silences the restraints for a stage while keeping their rows, so the row count the statistics exclude never changes mid-plan.

Measured on a synthetic case whose data under-determines two oxygen sites, starting from a Zr–O of 3.73 Å for a 1.87 Å bond: the same three stages run at c_w = 1 throughout converge with that distance at 4.834 Å, the restraint 148σ in tension and the coordinates 0.425 rms from truth; run at c_w = 300 then 1, the stiff stage lands the bond at 1.866 Å (0.03σ) and the relaxed stage converges at 1.872 Å, 0.00107 rms. The plans differ in nothing but c_w.

Corollary for the agent: this is a case where the fit statistics are the weaker channel and you must read result.restraints. The failed fit’s Rwp is 0.0393 against 0.0327 and its GoF 1.23 against 1.02 — a slightly worse fit, not an announcement of a 4.8 Å bond; RESTRAINT_TENSION is what fires. And a stiff c_w makes a restraint more authoritative, not more correct: where the assumed coordination is wrong, §8 of the paper says the refinement “will not progress satisfactorily”, and raising c_w makes that worse rather than better. RestraintReport.weight_scale records which value produced a report, so the penalty actually minimised is weight_scale · restraint_chi2.

8.20 Intermediate stages are not converged, on purpose, and the last one is. A staged plan stops every stage but the last at ftol = 1e-6 rather than the solver’s 1e-9 (RefinementPlan.intermediate_ftol, default since 1.1). The reason is 8.13’s mechanism seen from the other side: those long stages are walking a near-degenerate direction at ≈0.93 per iteration, and 99.99 % of the cost decrease is banked by evaluation 55 of 93 — the rest is digits the next stage refines again anyway, because stages are cumulative and the last one polishes everything at 1e-9. Measured over the three lab-shaped benchmark cases: 1.51×, 1.62× and 1.55× fewer evaluations, every non-degenerate parameter within 0.03 esd of the fully converged plan, QPA within 0.0014 wt %. Corollary for the agent, in three parts. Do not read a small parameter difference between a 1.0.x number and a 1.1 number as a physics change; check StageResult.ftol first. Set intermediate_ftol = None when a number is going into a paper, or when you are reproducing an earlier release, and say that you did. And measure a series rather than assuming it: the same chained ten-pattern comparison came out 1.04× worse on one tree and 1.12× better on the next, one commit apart, because each pattern warm-starts from its predecessor and a different seed changes how many recovery rungs the next one needs (§9b). The per-fit bound above does not survive a chain in either direction.

8.21 A model-selection test can bless a change that makes half your phases stop being measurements. On a four-phase in-situ scan, the majority phase’s lines were visibly mis-shaped and the cause was real: its control file modelled hkl-dependent strain and the transcription had dropped it. Freeing the equivalent Stephens block (six Laue-allowed coefficients under Pmn21) took Rwp 0.1092 → 0.0878 and χ² 8460 → 5465 for +4 net free parameters at 3887 channels — ΔBIC −2962, so the test §4 prescribes endorses it overwhelmingly — and removed 79-82 % of the two worst regions’ χ². It also made PHASE_UNCONSTRAINED fire on two of the four phases, which were supported without it: six hkl-dependent coefficients on the majority phase are enough to imitate the minority phases’ lines, and StageResult.held grew from five paths to twelve. The better fit is the one where half the answers stopped being measurements. Corollary for the agent: ΔBIC counts parameters, not whether each phase still has support. After any Rwp gain from a new width or shape model, re-read PHASE_UNCONSTRAINED and StageResult.held before quoting a single scale, fraction or cell from the improved fit.

The same run refutes a tempting converse. That width error owned 35 % of the fit’s χ², and correcting it moved the background level by ≤ 2 % in every region. A phase width and the background can be degenerate — a phase broadened past the instrument becomes a second background — but they are not automatically coupled: a width wrong enough to dominate the residual can leave the background where it was. So do not read a width finding as a background finding, or the reverse, without measuring. BACKGROUND_ABSORPTION on the majority phase’s scale was likewise unmoved, 0.442 before and 0.437 after, which is the useful half: it measures the geometry of the problem, not the current misfit, so it fires from a good start at the same value it fires at from a bad one.

8.22 A tie carries its dependent’s bounds back onto its source, so a source can stop somewhere it never declared. The least-squares box covers the free column, and a tied parameter is not one — it is reconstructed from its source after the solve — so a dependent’s own limits have to reach the solver through the tie or not at all. They do: dependent = coefficient · source + offset inverts to a range on the source, intersected over every dependent that source drives and with whatever the source declares itself. Atom.biso is [0, 25] Ų, so under ref.tie("phases.0.atoms.2.biso", "phases.0.atoms.0.biso", scale=2.0) the master is given a ceiling of 12.5 whatever its own max says, and BOUND_HIT names the master when a stage stops there. Measured: master 0.66 and dependent 0.33 under a declared dependent ceiling of 0.33 at coefficient 0.5, Rwp 0.0458 — a converged fit that stopped at a derived limit, not a failed one. (Measured: WP-1119, four-site LaB6.)

What this means when you read a run. A BOUND_HIT on a source whose own min/max are nowhere near the value is not a bug and not a bad bound — it is one of its dependents’ ceilings arriving through the tie, and the fix is to widen that parameter, not the one the diagnostic names. Check the coefficients before touching anything.

One case it cannot close. A tie with several sources is a slanted boundary, and the optimiser can only be handed a range, so what it gets is the smallest range containing every allowed point — it never rules out an answer you asked for, and it can leave a corner where two sources conspire to put a dependent out of range. Landing there raises on write-back, after the solve, naming the parameter, its bounds and the tie: “writing phases.0.atoms.1.biso=40 back to the model breaks its own bounds [0, 25]; it follows 2·phases.0.atoms.0.biso”. Read that as a constraint to widen, never as a corrupt model or a bad CIF. (Measured: WP-1119.)

8.23 Declaring an intruding peak is not more accurate than excluding the region — it is cheaper, and on some patterns that is the whole difference. A sample holder line sitting on a sample peak can be handled two ways: declare a PeakComponent so the channels stay in the fit, or put the region in excluded_regions. On the NIST SRM 660c LaB6 protocol with two holder lines injected onto reflections, both recover the clean-pattern cell — declaring to −1.0 ppm, excluding to +0.6 ppm — while ignoring the intruder costs +7.6 ppm and inflates the cell esd 7.5x. Excluding cost 256 of 5332 channels (4.8 %). So do not tell a user that declaring is the more accurate choice; it is not, on a pattern with plenty of reflections. What declaring buys is the retained channels and a measured intruder (area and position with esds) instead of a masked one. The balance tips toward declaring as the reflection count falls — a low-symmetry phase, a narrow range, or a series whose trajectory needs every reflection at every point — and that is the case to recommend it for. (Measured: WP-1103, SRM 660c.)

And read EXTRA_PEAK_ON_REFLECTION before quoting anything that overlaps. It fires whenever the declared component lands within 0.08° of a position the model already predicts, which on the design case is expected — the intruder really is on a sample peak. It is telling you the intensity split between phase and component is a degeneracy, not a measurement: in the run above the recovered component areas came back 1-2σ high because they had taken a little of the reflection underneath. Quote the phase quantities and say the two overlap; never quote the component’s share as if the fit had resolved it. (Measured: WP-1103.)

8.24 An instrument profile does not carry a declared peak, even when the peak really is the instrument’s. save_instrument_profile writes the widths, the zero and the geometry and drops extra_components entirely — measured: an Instrument with one PeakComponent saves and loads back with zero, while profile.w survives unchanged. The stripping is deliberate for a HumpComponent, which belongs to the specimen, but a holder or window line is exactly the thing that does recur on the next sample, and it is dropped on the same code path. So in the calibrate to save to load to sample workflow, re-declare the component on the sample’s own instrument after load_instrument_profile; nothing warns you. Check len(instrument.extra_components) after the load if you are driving that workflow unattended. (Measured: WP-1103.)

Reference: 9. The trajectory, and the history DAG as a search structure

Load it when one fit is not the answer: reading the trajectory rather than the last state, and using the history DAG as a search structure.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

Read the run, not just its last state

The first thing to do with a fit is read the report at every stage it passed through, and that costs you one flag. task="refine" returns trajectory[] when the request sets "report_trajectory": true — off by default since WP-1003, because WP-1064 measured that rungs handed over unasked bought no better decisions at more calls — and in python:

ref.fit(data, plan="mccusker_default", stage_reports=True)
for rung in ref.stage_reports_:
    print(rung.stage, rung.rwp, [(a.kind, a.confidence) for a in rung.actions])

Each rung is the same three-layer report, projected (FitReport.for_stage) to the numbers §4 judges a fit on, the summary sentence, and the active suggestions — those the plan you ran will not fix, since the strategy veto is applied against the whole plan. Two properties make it safe to rely on:

  • It changes no number. The rungs are read off states the plan already passes through; a fit run with the trajectory lands bit-for-bit where the same fit runs without it (measured: identical Rwp to full float precision on the synthetic fixtures, 0.140249 on 11-BM NAC). Nothing is refined to produce a rung.

  • It costs ≈2.5–2.8× the fit’s wall clock (1.06 s → 2.70 s on 59.5k channels of real 11-BM data; 0.30 s → 0.82 s on the 4200-channel synthetic LaB₆ fixture, re-measured 2026-08-19) and single-digit kB of payload: on that LaB₆ fixture, 0.6–0.8 kB a rung, 3.5 kB for the whole five-rung trajectory, ~3 % of the 111 kB report it ships beside. Quote that share with its fixture — the report’s size is dominated by its geometry table (89 kB of the 111), which no rung carries, so beside a geometry-light report the same trajectory reads as ~26 % (the StageReport docstring’s WP-1058-era episode-fixture measurement). The cost is flat per stage, not per iteration, so on a hard or diverging fit it disappears into the noise. Turn it off for a fit you are not going to read.

What to look for, in order:

  1. A rung that names a cause the final report does not. That is the compensation signature of §5: the plan absorbed a real error. The named parameter is the hypothesis; predict_then_verify below is how you test it rather than believing it.

  2. A confidence that climbs across rungs. On real 11-BM data with an unmodelled CaF₂ impurity, add_impurity_phase reads 0.3 → 0.6 → 0.9 as the host phase fits: a hypothesis getting stronger as the model improves is about the specimen, not about the starting values.

  3. abstained_kind changing. immature early is ordinary. Ending at resolution_limited is a legitimate stopping point for a phase-ID deliverable (§4b) — not a licence to escalate corrections.

  4. n_actions_vetoed. These are the suggestions your own plan already answers; a rung whose actions are all vetoed is telling you the plan is already the right one.

The rungs deliberately carry no regions, curves or per-region attribution: a rung is a pointer to a state worth asking about, and ref.report() (or the report arm) is where you ask. There is no task="diagnose" and no declared bootstrap ladder to invoke, because the states are already there — every preset opens on a background+scale stage, which is that ladder’s first rung (WP-1058). A hand-rolled one-stage plan is the one case with nothing to report but its end: the turn-on order is what makes a trajectory informative.

The DAG: branch, verify, roll back

This is the part of the API that exists because the operator might be a search process rather than a person. Every stage auto-commits an immutable, restorable node (~10 kB — state, not curves), so branching is cheap and a rejected experiment leaves no trace in the working state.

The canonical agent loop:

ref = rx.Refinement(structure, instrument, history="session.jsonl")
ref.fit(data, plan="lab_bragg_brentano")
ref.history.tag(ref.history.head, "baseline")

# try a hypothesis on a branch — rollback is structural, not manual
rival = ref.branch("baseline")
rival.run_stage(data, rx.Stage("aniso_strain", ["phases.*.microstrain.dof.*"],
                               strain_seed=1000.0))

ref.history.compare([n.id for n in ref.history.leaves()])
ref.checkout(ref.history.best("rwp").id)

and the machine-checked version of “should I take this suggestion?”:

outcome = rx.report.predict_then_verify(ref, data, report.suggested_actions[0])
# runs the action on a branch, keeps it only if χ² actually improves by ≥1 %
print(outcome.accepted, outcome.reason, outcome.predicted_delta_chi2,
      outcome.observed_delta_chi2)

Note expected_delta_chi2 on a suggested action is the linear model’s prediction — an optimistic upper bound, not a promise. The gap between predicted and observed is itself information: a large predicted improvement that does not materialise means the linearisation was invalid there, which is usually a peak far enough off that it should have been re-detected rather than shifted.

This loop is executable, not aspirational: tests/test_report_loop.py runs it closed — report → top surviving suggestion → verify → checkout/rollback → re-report — from eight planted-cause starts and measures planted-parameter recovery, stopping behaviour and rollback hygiene against the mccusker_default preset (WP-1052).

The same shape answers the other question the report asks and does not settle — which of an exchangeable pair is physical (§4 step 6):

finding = next(e for e in report.identifiability.exchanges if e.exchangeable)
swap = rx.report.compare_rivals(ref, data, finding)   # two branch fits
for r in swap.rivals:                # [0] frees the held one, [1] the partner
    print(r.freed_path, r.chi2, r.rwp, r.freed_value, r.freed_esd, r.n_free)
print(swap.chi2_ratio)               # < 1 ⇒ the parameter the fit HELD wins

Three things about it, and each is deliberate. It runs each rival alone with the other held at its null — never both together, which is the ridge (§3), and never with the rival at its last fitted value, which is neither rival. The free set is otherwise unchanged, so n_free matches across the two and raw χ² is comparable without an information criterion. And there is no decisive field: the package states the reading rule and never applies it, the same fence predict_then_verify respects by reporting observed_delta_chi2 beside its own threshold. The reading rule is §4 step 6’s band, orientation-neutral because chi2_ratio is directional: take the winning rival’s side whichever index it is — the losing χ² over the winning χ², i.e. max(ratio, 1/ratio) — and compare that against RIVAL_DECISIVE_MIN_CHI2_RATIO. At or above it, the winner’s fit is the answer, quoted without caveat (the 0.86 above is 1/0.86 = 1.17, decisive); below it the pair has tied and the resolution is protocol. A pair with no null (a cell edge, a scale) is refused by name — that one is resolved by protocol, not by measurement.

Two properties worth relying on:

  • Node metrics are as-optimised, measured on a model frozen at the values each stage started from. rx.replay(tree, node_id, data) recompiles at the values the stage ended on, so the two can differ marginally. That gap is a staleness signal, not a bug.

  • Each node carries the API call that produced it, so a session doubles as a reproducible script, and cherry_pick replays another node’s stage action (not its values) on top of the current state.

Reference: 9b. Series: refine a ramp as a chain, and check it both ways

Load it for an in-situ ramp, a parametric sweep or a tray of related specimens.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites.

An in-situ ramp, a parametric sweep or a tray of related specimens is rx.SequentialRefinement / rx.refine_sequential: N separate refinements, each warm-started from its predecessor. (One joint residual over patterns that share structural parameters is the different verb rx.refine_multi.) What comes back is a SeriesResult — per-pattern summaries plus trajectory(path), qpa_trajectory(phase), to_table(), write_csv().

series = rx.refine_sequential(patterns, structure, instrument,
                              x=temperatures, x_label="T (K)",
                              plan="lab_sample_refine")
a_of_T = series.trajectory("phases.0.cell.a")     # x, value, stderr

to_table/write_csv take the derived paths too, resolved the same way the plots are: paths=["qpa.Rutile"] exports the weight-fraction curve and r_bragg.<phase>/r_f.<phase> the agreement indices, beside any ordinary dot-path. Two things to expect. A path no pattern carries raises, naming it and listing what does exist: the phases carrying that kind of curve for a derived path, the series’ own parameter paths for an ordinary one. A typo is therefore an error rather than a column of blanks. And an agreement index gets no _esd column at all, because it is a residual and has no esd in any series — where a column is present and a cell is empty, that pattern did not estimate one, which is a different fact. (Measured: WP-1310.)

x is the series coordinate, and where it comes from is the file: a reader puts a scan’s own temperature in data.metadata["temperature_k"], and rx.io.readers.list_scans(path) reports the same number per scan before any of them is read. Today the Bruker .raw v3 range header is the one format here with such a field; the others record no specimen temperature and none is guessed from an axis named for something else. A missing key is a file that recorded nothing — refuse rather than substitute an ambient value, because an invented coordinate makes the trajectory a fiction while every fit in it stays perfectly good.

What an operator must know, all measured:

  • Chaining is worth ~3x in iterations, not in accuracy. On the eight round-robin sample-1 mixtures: 2863 iterations unchained, 904 chained, at identical Rwp and identical weight fractions. Use it to make a long series affordable, never to make an individual fit better.

  • What licenses a chain is physical continuity, and a tray has none — so of the three cases above, the tray is the one to think twice about. Chaining eight ex-situ YBaCo₄O₇ specimens that shared only a method created the disagreement it was then used to measure: refitting them as independent cold points shrank the referenced pattern’s Rwp gap by 13× and its cell-a gap by 12×, and what had been recorded as that campaign’s largest cell-a disagreement with TOPAS was the chain’s own artefact. Warm chaining is ~10x cheaper per pattern (0.16 s against 1.82 s measured on one series), and on specimens with no physical ordering that is the wrong saving. Inside a genuine ramp the same test is worth running step by step rather than once: on that series a different, larger jump survived having no chain, so that one was real — chaining artefacts are established or refuted transition by transition.

  • The default refit="single" collapses the plan into one stage for every pattern after the first. The staged turn-on order exists to keep early stages conditioned from a poor starting model; a converged neighbour is not one. A pattern where that turns out to be wrong is caught by the reseed fence, which escalates one rung at a time — the full staged plan from the warm state, then the full staged plan cold — and keeps the best attempt. entry.rung says which one produced the values and entry.rungs_tried says what else was tried; entry.reseeded still means only the cold rung won, so a "warm_staged" point is one whose chain is unbroken.

  • A pattern that no rung recovers is quarantined, not merely flagged (SEQUENTIAL_UNRECOVERED): it seeds no successor and its Rwp is left out of the median that decides every later trigger. So a single failure cannot propagate down the chain or quietly raise the bar for the patterns after it — but it is still reported, and reading its parameters is on you.

  • A sequential trajectory is path-dependent by construction, so a smooth curve is exactly what a poisoned chain produces. direction="both" runs the series each way and reports SEQUENTIAL_PATH_DEPENDENT per parameter. For any trajectory you intend to publish, run it — it is the only check that separates a measurement from an ordering artefact.

  • But forward/backward agreement is not evidence of correctness, and the two failures it cannot see were both measured. A flat degenerate band reproduces the same wrong answer in both directions — |Δweight| median 0.000, max 0.030, 0 of 35 patterns disagreeing by more than 5 pp on a construction that was physically wrong — because the check compares two paths, not two basins. And where findings are present the set is not the signal: SEQUENTIAL_PATH_DEPENDENT was non-empty on all four series of one tranche, and the single chain actually sitting in a wrong basin was localised by σ magnitude (98.9σ and 84.4σ on the scales the QPA is built from, against ≤8.8σ everywhere else). Read what was flagged before reading physics into it: two of the largest flags measured anywhere — 81.5σ for a and b exchanging in Fddd and 15.8σ for a and c in Pnma — are degenerate axis relabellings, and the largest on another run (16.6σ) was a held cell reaching the pattern with two different held histories, which is bookkeeping rather than two measurements.

  • SEQUENTIAL_RESEED is not the net for a wrong basin. Its trigger is Rwp > reseed_factor × the running median of accepted patterns (default 1.25), so it needs roughly a 25 % relative Rwp jump — and a polymorph swap does not cost that. Tested directly on 20 such patterns it fired 0 times; where it can fire it works, 6 firings elsewhere in the same run all keeping the chain out of the disagreement set. A wrong basin at negligible Rwp cost is the case for direction="both" and for cold refits, not for the reseed ladder.

  • The SEQUENTIAL_* codes live on SeriesResult.diagnostics, one level up from the entries. Reading entry.diagnostics for them returns zero on every series, which presents as a clean run rather than as a lookup in the wrong place — it cost one operator a full re-run of the chain stage for nine series, caught only by distrusting a suspiciously flat all-zero result. Per-entry diagnostics are real and carry every per-pattern occurrence; the rollups are not among them.

  • An unrun check is indistinguishable from a passed one. A series whose forward pass completed all 125 patterns and whose backward pass crashed reports zero SEQUENTIAL_PATH_DEPENDENT findings — byte-for-byte what a clean series reports. Confirm both passes completed before reading an empty set as a pass, and report the empty set as a measured result rather than omitting it. Both safety checks are also worth pricing up front: on one series the backward pass was 43.9 % of wall clock and the verify_discontinuities refits 15.1 %, so 59 % of the run bought assurance rather than answers — the right trade for a trajectory you will publish, the wrong one for a screen.

  • A flagged step can check itself. verify_discontinuities=True refits each SEQUENTIAL_DISCONTINUITY’s two patterns cold and independently and writes the cold step over the chain’s step to the diagnostic’s value, signed: near 1.0 the step is in the data, near 0 the chain made it, negative a cold pair that moved the other way. Off by default because a cold fit is the full staged plan from the initial models; measured on a 68-pattern ramp flagging four steps over four patterns it costs 5 % of the chain, and the cost scales with the patterns flagged rather than with the series length. Nothing else moves: the refits are separate Refinement runs writing to their own <label>.verify histories. An abstaining verification is a finding, not a pass: on a polymorph-swap exhibit the refit fired and then reported “an independent cold refit of both patterns does not determine this parameter, so the step could not be re-measured” — the check needs the parameter determined and the pathology is that it is not. Read that message as PHASE_UNCONSTRAINED about the step, and go to a cold refit sample rather than concluding the step was real.

  • The check that covers both blind spots is a cold-refit sample, and it is affordable. Neither SEQUENTIAL_RESEED (needs a ~25 % Rwp jump) nor direction="both" (agrees in a degenerate band) sees a wrong basin at negligible Rwp cost, and the audited job that did see it refitted 380 patterns cold against the chain and found 316 material disagreements, 29 of them at |ΔRwp| ≤ 0.5 pp — invisible to both. Whole job, warm chain plus 380 cold refits plus 244 minority-geometry fits plus 2 reference fits, 57 minutes single-threaded. Sample the chain cold; it is the only check here that catches a cheap wrong basin.

  • One batch row applies to a chain and lives in §9c, which a series operator never loads: the shared EventStream opens its file in append mode, so a re-run of a ramp writes into the previous run’s log unless the path is rotated (references/batch-operating.md § 9c.13) — measured on chains. The companion rule — a phase that switches fully in and fully out along the ramp is a degenerate split rather than a coexistence, and whether its fraction is an observable at all depends on the transition’s order — is this file’s own Is the transition first order? section, not a batch row.

  • A trajectory of phase fractions is a QPA question at every point of it, and the background is what decides it. Fractions ride on scales, and an over-flexible background biases scales silently while improving every agreement index — which is why §4b’s QPA row reads report.background.worst_absorption before any statistic beside it. Nothing in SeriesResult repeats that check for you, and no SEQUENTIAL_* code can: they compare each pattern against its neighbours, and a background too flexible for the specimen is wrong in the same direction at every point, so the trajectory it produces is smooth, self-consistent and false. Measured on a real 68-pattern reel: a 12-term cold fit put LT-ZrMo₂O₈ at 77.9 wt % when that phase is not present at all, Rwp 0.0821 against 0.0822 for the correct answer, with a difference curve that looks fine; over the round the absent phase took 40-96 wt % at an Rwp within 0.01 of the right one. Neither Rwp nor the plot separates them. So read background.worst_absorption per pattern — at least on the first, the last and every flagged step — before quoting a fraction trajectory at all. The route is not SeriesResult, which carries summaries only, and not rx.refine_sequential, which discards the per-pattern results: use the class form, whose results_ holds them. sr = rx.SequentialRefinement(structure, instrument), series = sr.fit(patterns, ...), then rx.build_report(sr.results_[i]).background.worst_absorption.

Driving one: fit() is all-or-nothing, so wire on_result= before starting anything long. The per-pattern loop catches RefinementCancelled and nothing else and returns its SeriesResult only at the very end, so one exception discards every pattern already fitted — measured as a LinAlgError: SVD did not converge at pattern 217 of 531 taking all 216 with it, and a deterministic LinAlgError: Eigenvalues did not converge at index 78 destroying 103 already-fitted patterns whose values were fine, because the fit had converged and only the error bars failed. Cell lengths carry no physical upper bound, so a pattern reaching a = −347.644 Å raises out of the reflection enumerator and takes the chain with it (3 of 9 chains on one tranche, one losing 20 of 24 patterns). The failure mode is reassuringly narrow — across 1846 fit_start/fit_end pairs the only exceptions raised anywhere were those reflection-enumeration guards, no hangs, no NaNs, no silent wrong-shape results — so a driver only has to survive that one. on_result=(index, result) fires as each pattern lands; persisting there is what saved the 103 above, and it is the single highest-value line in a series driver.

on_result fires on the forward pass only, and so does results_. With direction="both" the backward chain is passed None for the callback, so backward-pass patterns have event-log timings and no RefinementResult — 53 of them on one run. The backward SeriesResult is on .backward_ and on series.backward; any per-pattern instrumentation you plan is half-populated by design, which matters most for exactly the check above, since background.worst_absorption is unreachable for the backward pass.

Turn history on explicitly — the two defaults disagree. Refinement’s history defaults to True and SequentialRefinement’s to False, and one harness silently lost the restorable per-pattern tree for 550 fits by never passing it. Here the path becomes a directory of one JSONL per pattern holding more files than patterns, because reseed retries write their own (291 files for 275 patterns) — so file count is not a completion check.

When the deliverable is the trajectory, print its deciding rows: series.summary(deliverable="series") — §4b’s fourth row, and the two statements no diagnostic can make for you.

print(series.summary(deliverable="series"))

carry (dot-path globs) restricts what crosses a pattern boundary. Reach for it when a parameter must provably not be chained; do not reach for it because a parameter jumps. That hypothesis was tested on a series whose composition swings 1 → 94 wt % and it is false: carrying everything is cheaper there than excluding the scales.

prepare=(index, data, structure, instrument) is the other half of that, and the case above is what forces it: excluding a parameter from carry only falls back to the first pattern’s guess, which is not the same as re-estimating it. prepare runs on the warmed models just before each fit, so a scale that must be estimated from this pattern rather than carried or left at an initial value has somewhere to be set. Two smaller hooks worth passing on any long run: progress= (a stream or path) emits one line per stage boundary per pattern and is the cheap way to know a run is alive, and labels= names the entries — without it every downstream table is keyed by integer. One letter away, label= names the whole chain in rietx watch’s list, and a sequence passed to it raises TypeError rather than being recorded. A cancelled series returns what completed, with SEQUENTIAL_CANCELLED. Note that stage_reports=True does not exist here; it is a Refinement.fit() argument and raises TypeError, and per-stage Rwp lives on the stage_end events of the shared EventStream instead (present on 637 of 637 payloads in one run, where StageResult carries no rwp field at all).

Where stage_reports=True does apply — the single Refinement.fit() calls of a batch — it is expensive, and the name suggests only that it adds output. On the bundled NAC fixture it cost 6.5×: 1.923 s against 0.296 s, median of three, with Rwp identical to six decimal places, so it changes nothing about the refinement. cProfile puts 75 % of the fit inside _stage_report, whose March-Dollase texture and Stephens strain analyses re-run at every stage and each cost more than the optimiser step they describe; the solve itself is 23 %. Sample it — the first fit, the last, anything you will plot — rather than setting it on every fit of a long run.

That cost also sits outside the stage brackets, which makes one natural way of totalling a run wrong. Σ(stage_startstage_end) is 11 % of wall clock with stage reports on and 50 % with them off, and the bracketed total is unchanged between the two (0.225 s against 0.223 s) because it only ever covered the solve. The log is not missing the time — fit_startfit_end accounts for ~100 % either way — so take a fit’s cost from that span, and never from a sum of per-stage durations.

Microstrain evolves along a chain — leave it free, or it lands in the fractions

Hold the instrument profile after calibrating it once, and let the per-phase width terms refine per pattern. That is not a stylistic preference: in an in-situ reaction, an electrochemical cycle or a temperature ramp, microstrain and domain size genuinely change along the series, and a chain that holds sample broadening fixed has nowhere to put that change. It goes into whatever is free — usually the scales, and therefore the weight fractions, which is the one number such a run is normally for. Measured on an 11-BM cryostat ramp, a phase’s lor_strain rose 0.019° → ~0.11° toward low temperature, reproducibly and in both directions; a chain with that term pinned would have had to absorb the same intensity redistribution somewhere else.

The mirror-image mistake is a numeric floor carried across instruments. A microstrain or size floor is a hedge against the term collapsing to zero and capturing no broadening at all, and it has to be re-derived from the peak widths of the data in front of you. A 0.02° floor that behaved well on lab Cu Kα data was reused on 11-BM, where the instrumental width is one to two orders of magnitude smaller; it pinned lor_size and lor_strain to the bound on every pattern of a series and inflated Rwp 3–4× (0.546 with the floor, 0.148 without, same pattern and seed), while BOUND_HIT fired on the width paths on essentially every fit. The package’s own suggested remedy for the resulting misfit — free instrument.profile.u — did not help (0.546 → 0.544). So: measure the FWHM of two or three strong isolated peaks off the observed data, keep any floor well below the narrowest of them, and say which number you used and why. A floor at or above the instrumental width is not a floor, it is the profile.

A symmetry test that has not been run against a null is not yet a test

A trajectory that ends in “the symmetry changes at T*” needs the claim checked against a compound, or a sub-series, where the symmetry is reported not to change — and the check has to be able to fail. The reason is specific and it defeated two candidate methods on one tranche: a lower-symmetry model carries freedom that a naive parameter count does not see — more resolved reflections, and more atoms once sites split — so a bare ΔBIC comparison tilts toward the lower symmetry regardless of the physics. Applied to a cubic control it “found” a distortion below 235 K in a compound reported cubic throughout.

What survived that control was a two-part criterion: a jump in Rwp(T) against its own baseline scatter, confirmed by the shape of the ΔBIC curve — a step followed by a new plateau, rather than a smooth decay. Write the threshold down before running it, report the null arm’s result next to the positive one, and if the null fires too, say that the detector measured flexibility rather than physics. A shuffled-coordinate run on the same patterns is the cheapest null available when no second compound is to hand: on one series it gave ~12× the β scatter and ~3× the Rwp scatter of the true ordering.

Is the transition first order? The order decides whether a phase fraction is an observable at all

Establish the order before reading any phase fraction across the transition. A first-order transition genuinely coexists, so a parent-plus-distorted two-phase model is correct and its fraction is the measurement: ZnCr₂O₄ and MgCr₂O₄ hold both low-temperature phases in near-equal amounts down to base temperature, and Mn₃O₄ does the same below its T_N — all explicit in the papers cited below, whose figures are the ones to quote. A continuous transition never coexists, and a parent whose distortion is undetectable never has a second phase at all — in either case the child’s metric approaches the parent’s, the two become one phase, and the weight split is unidentifiable by construction, which is exactly what HIGH_CORRELATION on the two scales reports (ρ ≈ −0.98 on 87 of 182 patterns, all of them above T_N). Read the symptom correctly: a candidate that is fully in (ΔBIC ≈ −10⁴) at the coldest scan, out (+9.5, weight exactly 0) at the next, back at a third and gone at a fourth is a profile-shaping device switched on wherever the peak shape is imperfect, so ΔBIC across a batch is not by itself a discriminator even at −8 700 to −41 300 — only a distortion magnitude against a published value separated them, and it agreed with the paper to ~1 %. The observable across a continuous transition is the order parameter, never the fraction. (Measured: archive screening campaign, 11-BM variable-temperature series — a compound published as showing no detected structural transition, modelled as two phases; the first-order coexistence figures are Kemei et al., J. Phys.: Condens. Matter 25, 326001 (2013) and Phys. Rev. B 90, 064418 (2014).)

Reference: 9c. Many fits as one job: deciding an answer across fits

Load it when a batch’s fits are in hand and the task is to decide what they show together — ranking candidate models, differencing two populations, auditing an aggregate, or judging whether an answer is trustworthy at all. One refinement is a unit of a larger job here: candidate models screened against one pattern, or many patterns fitted independently rather than chained (§9b is the chain, whose fits share an order and a path). references/batch-operating.md is the companion file for running the batch itself — budgeting, cost, logging, inventory — rather than reading its answer.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites. Every row carries its evidence: (Measured: …) names the run and its number, (Hypothesis: …) names what would decide it. Where that run belongs to the archive screening campaign, the tag names that corpus instead of a run a reader can open — a screening campaign over a private diffraction archive of laboratory, synchrotron and reactor-neutron patterns, refined against the existing TOPAS, GSAS and XND answers for the same data wherever those survive. The archive is unpublished and ships in no wheel, so those rows cannot be reproduced from this repository; every number in them still comes from the log, and no scientific magnitude of any specimen in it appears in any row.

A batch is N separate rx.Refinement runs, or N branches of one history tree (references/history.md), each a whole §1-§10 job with its own deliverable, stop rule and report. It is not rx.refine_sequential (§9b: each fit is warm-started from its neighbour, so the set has an order and its trajectory a path) and not rx.refine_multi (one joint residual over patterns that share structural parameters). What the body settles for one fit is settled here too and is not restated: the deliverable and its deciding rows (§4b), the three stop conditions (§10), abstention (§6). This file holds what a comparison, an aggregate or a trust judgement needs once the fits already exist; running the batch that produced them is references/batch-operating.md.

Writing a row

A row is a rule an operator of a batch needs and a single fit never does, with the measurement that produced it. Ask first whether it is about deciding what a batch’s fits show or about running the batch itself — the latter belongs in batch-operating.md instead. From a run’s logs the form is: the rule as one imperative sentence in bold, numbered 9c.N; what was measured — the dataset or episode, N, the number that decides — in two to four sentences; then the tag. (Measured: …) names the run. Where the run is in this repository — a WP, an eval round, a dataset in tests/data/README.md — name it so a reader can go and look. Where it is not, name the corpus the file declares in its provenance line above, and name it the same way every time: the package has to be tested on data it cannot ship, so a row measured on data a reader cannot open is admitted, but it must be recognisable as one before the reader acts on it. Two things the private case does not relax. Every number still comes from the log, and anything the log does not decide is a Hypothesis row: unverifiable is not a licence to be vaguer. And a scientific magnitude derived from unpublished data stays out. The line is what the row is about: what the run did may be quoted — counts, rates, wall clock, a ratio of fit qualities, which is what the rows below are made of anyway — and what the specimen is may not: a cell, a phase fraction, a domain size, a transition temperature, and a ratio of two of those is still one of those. Quote the shape, or a published figure, instead, which is where the lesson was anyway. (Hypothesis: …) is for a rule the logs suggest but do not decide, and names what would decide it. A hypothesis row is not a weaker rule; it is an open question stated as one, and §6 applies to it — do not act on it as if it were measured. Before adding a row, ask whether it holds for one fit alone: if it does, it belongs in the body or in §8, not here. All rows sit under The rows, one after another, each closed by its tag; tests/test_skill.py refuses a row without one, and CONTRIBUTING.md § The agent skill has the sync step.

The rows

9c.1 Declare each job’s deliverable and stop rule before the batch starts, and read the stop on the report, never on Rwp. §4b’s Stop when column is the stop rule per job and it differs by deliverable; a batch launched without one stops on whatever its author reached for — in the campaign’s six refining runs an external comparison, a script exit, an instruction, and for three of them nothing: they ended waiting. (Measured: WP-1307 round 1.1, R11 — three of four ramp cells stopped on a §4b deliverable row, against 0 of 6 in the 86-run campaign and 0 in round 1.0, when §4b had no such row to reach for.)

9c.2 A candidate the data cannot see is unseen, not refuted. PHASE_UNCONSTRAINED says the phase was held for the stage because nothing of it moved the residual (§6, rule 22): its scale at the floor is not a measurement of 0 wt %, and its cell is the one you handed in. Screening candidates, sort such a fit into “not testable on this pattern”, never into the rejects — and never let the batch walk it: before the hold existed, one absent phase cost 27 % of a 35-minute session, and its chain, reproduced on 13 sub-onset patterns, took 6.7 s with cell bounds and was killed unfinished at 13 minutes without them. (Measured: WP-1301, and WP-1307’s baseline ramp run — the flat direction’s share of wall.)

9c.3 Rank candidates only on fits of the same channels, the same excluded regions and the same background flexibility. §4 makes Rwp a relative number between fits of the same data over the same channels, and §4 step 17 makes R_B flatter whichever model partitioned the intensities, so neither ranks across protocols. A batch that varies the background order or the excluded regions per candidate has ranked protocols, not models: on one fit, the over-flexible background won on every agreement index while being wrong, and worst_absorption (0.46 against 0.08) was the only row that separated the two. Compare rwp_background_subtracted pairs, the Le Bail gap ratio and ΔBIC for nested models, each on an identical protocol. (Hypothesis: follows from §4’s same-channels rule and WP-1055’s single-fit measurement; no batch has measured the ranking itself.)

9c.4 A ranking is only as strong as the best null you actually reached, so fit the null as hard as the candidate. A protocol held constant across candidates still ranks them wrongly if the baseline was left underfitted: one symmetry adjudication’s cubic baseline was 1.1 pp of Rwp worse than achievable on the same data, and the richer candidate’s extra freedom absorbed that deficit and won on every global statistic. The guard is to localise the improvement where the physics predicts it rather than to total it — windowing the six largest-multiplicity reflection families that the claimed symmetry-breaking actually splits, 6.2 % of points captured −28 % of the total Δχ², i.e. net negative exactly where a real distortion must concentrate its advantage while the global numbers pointed the other way. The same asymmetry appears in plan hygiene: restoring two Gaussian terms silently dropped from a copied plan moved one weight fraction by 0.09 in absolute terms and Rwp by 0.04, so a candidate compared against a copied predecessor is being compared against nothing in particular. (Measured: archive screening campaign — the cubic-versus-rhombohedral NiO adjudication, whose ΔBIC −5553 for the richer model was real and reproducible while the interpretation was wrong, and its windowed Δχ²; the dropped Gaussian terms are from a low-temperature scan of a spinel series.)

9c.10 Compare populations conditioned on a diagnostic; do not regress a number against whether it fired. Whether an unsupported phase costs or saves time is a threshold, not a slope: PHASE_UNCONSTRAINED holds that phase’s 7–9 structural parameters (9c.2), which made fits slightly faster on one batch and 13.8× slower on another, reconciled by whether holding it leaves a flat direction behind for the solver to grind on. The methodological point is the transferable one — on the batch where the effect is 13.8×, the linear correlation against the flagged fraction is r = +0.073, which a regression would have reported as “no relationship”. (Measured: archive screening campaign rounds 2–3 — the (La,Sr)FeO₃ and 11-BM GeNi₂O₄ batches, whose signs are opposite; the same trap applies to any guard finding whose effect is conditional rather than graded.)

9c.18 Read a code that fires on every fit as a statement about the model, and reduce the volume before reading it at all. Per-pattern diagnostics do not scale and are not meant to: on 275 patterns BOUND_HIT fired 1289 times, 4.7 per pattern, and BACKGROUND_ABSORPTION 841. A code with no per-pattern variation carries no per-pattern information — PHASE_UNCONSTRAINED on a fifth phase fired 275/275 and a species-fallback code 36/36 — and the finding is one level up: a single BOUND_HIT is a pattern that hit a bound, while a BOUND_HIT in most of them is a bound that is wrong, including one you imposed yourself (a lor_strain floor the operator had set, flagged in 178 of 215 patterns). Change the model or the plan; refitting reproduces it. Read which parameter where names rather than merely that the code fired — two draft conclusions in this campaign were wrong because a BOUND_HIT was attributed to the wrong parameter. (Measured: archive screening campaign — the 275-pattern CuO/Cu₂O reduction series for the volume and the rollup, and the 11-BM GeNi₂O₄ series for the operator-set lor_strain floor at 178 of 215 patterns, 83 % of that series.)

9c.19 Gate every summary statistic on determinacy, state the gate beside the number, and report the screened n. Reference and fitted rows alike carry undetermined parameters, and differencing them silently is how a batch publishes noise: real rows read a cell edge whose esd was an order of magnitude larger than the edge itself, a microstrain esd of 78011, and a phase present at 6e-5 wt %. Screen by a minimum weight fraction and by the untrustworthy-value codes (PHASE_UNCONSTRAINED, BOUND_HIT) before differencing anything, then say so with the result — a median screened to patterns above 1 wt % that did not declare its screen could not be reproduced from the saved rows by a later audit, which is a defect in the record even where the value is right. The screen is not cosmetic: one unscreened draft median read 0.329 pp where the screened one was 0.931 pp over 142 rows. (Measured: archive screening campaign, the CuO/Cu₂O tranche’s cross-code agreement medians and their re-audit.)

9c.20 Sum each side’s weight percents over the phases the comparison shares before differencing them. weight_percent normalises over the phases in the model, so a model missing a real phase pushes every remaining fraction toward 100 % — one five-against-six-phase comparison inflated four phases’ apparent disagreement 12–14× through exactly that, and it was first written up as a separate “normalisation basis” defect before review reduced it to the omitted phase itself: one mechanism, not two. The tell sits on the reference side, since a rietx model’s own fractions always sum to 100: exported columns summing well short of 100 % say the generating model carried a phase the export does not. Difference like-for-like sums and print the assertion rather than claiming it in prose. (Measured: archive screening campaign — the CuO/Cu₂O five-against-six-phase comparison, and the audit that reclassified my first account of it.)

9c.21 Count how many units actually carry a reference before quoting an agreement statistic. A median over data with no reference is a fabrication, and partial coverage is invisible in the aggregate: one folder had 1 paired point out of 8 fitted patterns, and that lone point sat at 0.572 pp — 3.4× above the ceiling the series had been credited with — while another had zero paired points and only an aggregate range. Where coverage is partial, a qualitative event both codes agree on, such as the unit at which an onset occurs, is more defensible than a Δ-statistic that silently assumes uniform coverage. Match a reference’s blocks by their reported statistics, too, rather than taking the tail of the file: its last converged block is not necessarily the published one. (Measured: archive screening campaign, the cross-code pairing audit.)

9c.22 Run a negative control through the identical machinery, and expect the threshold you pre-registered to be wrong. A control is what fixes the false-positive floor, and without one a threshold is a guess about the noise: the control’s apparent distortion fixed a floor that the real claim sat 7.8× above, and so survived — but the threshold written in advance sat below that floor, so it was guaranteed to fire whatever the data said. Committing to a threshold early is right; committing before the quantity’s noise floor is known is a coin flip, and here a threshold a few times the measured floor would have separated true from false. Relatedly, gate secondary criteria on the candidate being competitive at all: one criterion rejected a model everywhere and the next then read that rejected model’s c/a and found it ≠ 1, which is a parameter being non-zero inside a model nothing supports. Pair the control with a positive arm: a detector that has never fired on a true case licenses nothing, and this floor was only interpretable because the same machinery reproduced a published distortion magnitude, to ~1 %, on the one case in the batch known to be real. (Measured: archive screening campaign, 11-BM variable-temperature series — the distortion claim, its negative control, and the published positive it reproduced.)

9c.23 Hold the configuration constant before reading a trend along the batch axis — then do not read the surviving trend as physics either. Where configuration is confounded with the axis, a pooled trend mixes two measurements, so restrict first: one series restricted to its 194 identically-configured scans still showed the effect cleanly — a monotone fall of more than two orders of magnitude from the coldest scans to the warmest. Note what that bought — it exonerated the configuration and left a trend that was still an artefact, the negative control’s temperature-driven peak broadening bought as apparent c/a by a tetragonal model fitted to a broadened-but-unsplit reflection (9c.22’s floor). Surviving a configuration hold is necessary and nowhere near sufficient. The same reasoning retires a null result: freezing a covariate removes its dynamic range, so “broadening does not predict cost” measured under a frozen instrument is a claim about that protocol only. (Measured: archive screening campaign — the 11-BM negative control’s configuration restriction, and the frozen-width CuO/Cu₂O timing null.)

9c.25 Once a scale is at its bound, every local identifiability statistic goes quiet — scan it instead. On one such phase background.absorption gave 0.183 and 0.208, below a well-behaved phase’s 0.318, the top five correlations were unrelated pairs, and the phase did not appear in soft_modes at all: three misses. A pin-and-scan profile likelihood saw it at once — 17 pp of weight for +0.20/0.39/0.51 pp of Rwp, inside the noise floor. Batch-wide counts corroborate rather than detect (BOUND_HIT on that scale in 180 of 275 patterns, PHASE_UNCONSTRAINED in 274 of 275), so a scan is what turns a suspicion into a bound on the answer. (Measured: archive screening campaign, CuO/Cu₂O reduction series — the pin-and-scan control on the fifth phase.)

9c.26 Trustworthiness is a property of the unit, not of the protocol, so do not credit a batch with its best fit’s identifiability. Identifiability varies along a batch: a profile-likelihood scan showed three basins spanning nearly the whole 0–100 wt % range inside a total Rwp span of ~0.01 pp at one temperature and was clean and single-minimum at another with 58× more signal, and at the bad point the reported weight_fraction_stderr understated the admissible range by ~2 orders of magnitude — a covariance esd describes local curvature and cannot see a second basin. Run any such scan warm-started from one common state: the first attempt, run cold, gave scattered minima at Rwp 3.5–6.1 and measured the optimiser rather than the landscape. (Measured: archive screening campaign, the YBaCo₄O₇ gas-cell series — the 300 °C pin-and-scan against its 200 °C control.)

9c.27 Multi-start agreement is evidence about the landscape only if the starts were where you think. Three seeds converging on one basin proved nothing on a fit whose seed conversion was wrong: both “biased” starts were 19–42× below the intended values, so all three began from effectively the same tiny strain and the agreement measured the seeding bug. Verify each seed’s physical value, not merely that the three differ. Where a term wants to sit on a floor, seeding and flooring it away from zero is what makes the basin reachable at all — a lor_strain lower bound of 0.02 kept the optimiser off the bound in 14 of 15 patterns and moved that phase from a fraction the reference contradicts to one agreeing with it within 0.5 pp, at an Rwp 0.10 pp better than baseline. (Measured: archive screening campaign — the ZnCr₂O₄ size-strain arm, and the YBaCo₄O₇ strain-floor arm.)

9c.28 Freeze the model between rounds when the purpose is regression. Changing the model destroys the comparison that proves the machinery moved nothing: one round reproduced a predecessor’s 316-of-380 disagreement count to the pattern across all nine series, which is a strong pass on the sequential machinery and is only available because nothing else moved. Where the protocol has to deviate from the reference, report the deviation rather than fixing it mid-round. (Measured: archive screening campaign R3, the nine-series regression round.)

9c.29 Audit the aggregation before publishing it, and recompute every cell from the machine-readable per-unit rows. A batch’s write-up is a second dataset with its own error rate: one pass over a campaign’s own numbers returned 61 confirmed, 11 disagreeing, 8 unverifiable and 2 unverifiable without a refit, with the disagreements concentrated in how results were written up rather than in the results themselves — recomputed p90 cells off by 0.03–0.04 pp, a pilot’s prose total 1.9 % from its own results.json, and a wt % quoted as 16.09 where the row says 16.10. Two of the eleven were descriptions of upstream defects that had already been fixed at the commit checked, so a reader would have concluded two open issues were still open — which is the argument for auditing before publishing rather than after. The audit’s own totals were not exempt from its own rule: its header says eleven disagreements, its closing prose says twelve, and its per-row labels support nine, so quote the cell you actually recounted rather than the summary above it. Keep narrative estimates out of the same register as measurements, too: a prose “~40 min” beside a log-derived “43.8 s” makes an unverifiable number look measured, and one campaign’s own 68.5 min session figure was unverifiable from its event logs, which spanned 25–62 min depending how generously they were stretched. (Measured: archive screening campaign, the full numeric re-audit of its own findings record.)

9c.32 Build the FitReport on the pilot fit and read report.backgroundresult.diagnostics does not carry the between-peak verdict, and report.suggested_actions names the remedy even when Layer 1 abstains. Almost every capillary or cryostat pattern holds a container amorphous halo, and a low-order polynomial cuts straight through one while Rwp stays respectable because the Bragg channels dominate the weighted residual. A 6-term Chebyshev with no declared peak left the fitted background 35 % high below 3.5° and up to 2× low across 4–7° at Rwp 0.157, and the report said so on three channels at once — off_region_chi2_reduced 2.885 against its 1.5 threshold, off_region_durbin_watson 0.393 against 1.0 over 7 408 off-region channels, the summary naming the between-peak residual “systematic, not noise”, and the halo’s own maximum listed first in report.unmatched at 7.6σ — while result.diagnostics carried only BACKGROUND_ABSORPTION. The remedy was on the report too, under a kind a driver can branch on: report.suggested_actions held increase_background_flexibility active and unvetoed even though Layer 1 had abstained as unreadable, because background_actions runs on the abstain path by design — an over-stiff background is a cause of an immature fit, so branch on suggested_actions[].kind and do not read an abstention as “no advice”. Model the feature with a HumpComponent or a BackgroundPSpline rather than more polynomial terms, which hide it while improving every statistic. (Measured: archive screening campaign — the 11-BM VT Mn₃O₄ 8.281 K fit, whose report was rebuilt and read only after the tranche was written up; the package’s own manual measures the same Kapton halo at d = 4.74 Å and needs fourteen Chebyshev terms to match one Gaussian’s three.)

9c.33 Never multiply a quoted esd by esd_inflation — the esds already carry it. Statistics.esd_inflation is the Bérar-Lelann serial-correlation factor, and the fit-side and report-side docstrings both say the reported esds have already been multiplied by it; you divide it out to recover raw χ²·(JᵀJ)⁻¹ esds. It is conservative by construction — perfectly white residuals land at ≈1.51 — so it is an upper bound on serial-correlation damage rather than a measurement, and a batch acceptance bar set near 2 fires on sound fits. Read a large value as evidence about the model, i.e. unmodelled profile detail whose residual is serially correlated, which report.background and report.regions then localise — never as an uncertainty correction to apply. (Measured: archive screening campaign — 8.45 on the 11-BM VT Mn₃O₄ 8.281 K fit, against the 2-4 band the docstring gives for lab data and the ≈1.51 white-residual expectation the package verifies in its own tests; three independent readings of that number in one session inverted its direction, one while quoting the docstring that states it.)

Reference: 9c. Many fits as one job: operating the run

Load it when a batch is running or about to run and the question is how to run it well — budgeting a job’s cost, timing stages, keeping the event log honest, scoping the inventory, recovering from a per-unit failure. One refinement is a unit of a larger job here: candidate models screened against one pattern, or many patterns fitted independently rather than chained (§9b is the chain, whose fits share an order and a path). references/batch.md is the companion file for reading what the fits, once run, show — ranking, differencing, trusting an answer.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites. Every row carries its evidence: (Measured: …) names the run and its number, (Hypothesis: …) names what would decide it. Where that run belongs to the archive screening campaign, the tag names that corpus instead of a run a reader can open — a screening campaign over a private diffraction archive of laboratory, synchrotron and reactor-neutron patterns, refined against the existing TOPAS, GSAS and XND answers for the same data wherever those survive. The archive is unpublished and ships in no wheel, so those rows cannot be reproduced from this repository; every number in them still comes from the log, and no scientific magnitude of any specimen in it appears in any row.

A batch is N separate rx.Refinement runs, or N branches of one history tree (references/history.md), each a whole §1-§10 job with its own deliverable, stop rule and report. It is not rx.refine_sequential (§9b: each fit is warm-started from its neighbour, so the set has an order and its trajectory a path) and not rx.refine_multi (one joint residual over patterns that share structural parameters). What the body settles for one fit is settled here too and is not restated: the deliverable and its deciding rows (§4b), the three stop conditions (§10), abstention (§6). This file holds only what running a batch as a set costs and requires — budget, timing, logging, inventory, fault tolerance; reading what the fits show once they exist is references/batch.md.

Writing a row

A row is a rule an operator of a batch needs and a single fit never does, with the measurement that produced it. Ask first whether it is about running the batch itself or about deciding what a batch’s fits show — the latter belongs in batch.md instead. From a run’s logs the form is: the rule as one imperative sentence in bold, numbered 9c.N; what was measured — the dataset or episode, N, the number that decides — in two to four sentences; then the tag. (Measured: …) names the run. Where the run is in this repository — a WP, an eval round, a dataset in tests/data/README.md — name it so a reader can go and look. Where it is not, name the corpus the file declares in its provenance line above, and name it the same way every time: the package has to be tested on data it cannot ship, so a row measured on data a reader cannot open is admitted, but it must be recognisable as one before the reader acts on it. Two things the private case does not relax. Every number still comes from the log, and anything the log does not decide is a Hypothesis row: unverifiable is not a licence to be vaguer. And a scientific magnitude derived from unpublished data stays out. The line is what the row is about: what the run did may be quoted — counts, rates, wall clock, a ratio of fit qualities, which is what the rows below are made of anyway — and what the specimen is may not: a cell, a phase fraction, a domain size, a transition temperature, and a ratio of two of those is still one of those. Quote the shape, or a published figure, instead, which is where the lesson was anyway. (Hypothesis: …) is for a rule the logs suggest but do not decide, and names what would decide it. A hypothesis row is not a weaker rule; it is an open question stated as one, and §6 applies to it — do not act on it as if it were measured. Before adding a row, ask whether it holds for one fit alone: if it does, it belongs in the body or in §8, not here. All rows sit under The rows, one after another, each closed by its tag; tests/test_skill.py refuses a row without one, and CONTRIBUTING.md § The agent skill has the sync step.

The rows

9c.5 Budget a job from what a converged job costs on this batch, and read a job past its budget as a diagnosis. §8.13: a stage that takes minutes is telling you it is degenerate. On a chain the rung budget is a factor times the dearest converged first rung, never a fixed wall (WP-1127); a job several times the median converged job is a flat direction or a degeneracy to read in its trajectory, not a fit to wait for. (Hypothesis: the mechanism is measured on a chain, WP-1127, and on the ramp run’s 27 %; the factor for independent jobs is not.)

9c.6 Cost tracks solver iteration count and almost nothing else, so predict a batch from iterations rather than from the model. Stratified by pattern size, cost against iteration count gave r = +0.859 and +0.988 within stratum on the two stratified batches with no sign flip, and +0.983 pooled on a third whose single frozen protocol needed no strata; peak broadening, minimum phase fraction and reflection count all flip sign between pooled and stratified, so each of them correlates with “which protocol is this” rather than with work done. Stratify by (protocol × geometry) before correlating anything against cost — pooling warm chain fits with cold ones, and 180-point windows with 4787-point ones, manufactured a reflection-count correlation of +0.398 pooled (n = 976) against a stratified median of +0.052 (12 strata, n = 961). (Measured: archive screening campaign R3 — three batches, the (La,Sr)FeO₃ chemical-looping series, YBaCo₄O₇ and the six-phase CuO/Cu₂O tranche; the pooled-vs-stratified reversal replicates, though its magnitude does not on a batch with only two pattern sizes.)

9c.7 Do not carry a per-iteration cost model from one batch to another. The same fit machinery costs a different amount per iteration on different data, so a regression fitted on one batch is not a package constant. One batch’s s/iter = 4.4 µs × n_points + 2.9 ms (r = +0.972 there) predicted 8.61 ms/iter for a six-phase batch that measured 28.82 — 3.35× low — and sat at 0.34–0.49× on a third. The missing term is model complexity: six phases on the smallest patterns was the dearest per iteration of anything measured, which is the opposite of what point count alone predicts. (Measured: archive screening campaign R3 — the regression is the (La,Sr)FeO₃ series’, the 3.35× miss is the six-phase CuO/Cu₂O tranche and the 0.34–0.49× is YBaCo₄O₇, i.e. two batches on opposite sides of the same line.)

9c.8 A stage that exhausts its iteration budget is not by itself waste — pair max_nfev with the cost reduction it bought. Across three batches, max_nfev-terminated stages took 46.6 % / 42.9 % / 23.5 % of stage time at 72× / 53.6× / 18.0× the median converging stage; the shape replicates and weakens each time, so the multiplier is not quotable but the sink is real. Splitting them by what they achieved is what separates the two populations: on one batch twelve exhausted warm_refit stages returned a 17 % median cost reduction — real work needing more budget — while five exhausted refit_all stages burned 30.8 s for 0.018 %, and the pooled median of 12.7 % hides the split entirely. Report it per stage name, and do not key a guard on the stage name: three batches put the waste in three different stages (warm_refit/biso, then refit_all, then strain), so a name-keyed guard misses two of three. (Measured: archive screening campaign R3 — three batches; the waste signal is max_nfev and near-zero fractional cost reduction.)

9c.9 Read terminations before blaming the iteration cap for a slow batch. The quieter sink is the larger one: of 63 stages exceeding 5× the median on one batch — 9.9 % of stages and 52.5 % of stage time43 terminated on ftol and only 19 on max_nfev. Long converging stages cost more in aggregate than exhausted ones, so raising the cap addresses the smaller half of the problem. (Measured: archive screening campaign R3, the six-phase CuO/Cu₂O tranche’s full stage census — the run whose 40 minutes had been attributed to the iteration cap.)

9c.11 Sum fit_startfit_end for a fit’s cost; never sum its stage durations. Inter-stage bookkeeping belongs to no stage, so stage-level totals are systematically low: fit-level agreed with an independent wall clock to 0.62 % while stage-level undercounted by 19.9 % on one batch and ~27 % on another. Anyone budgeting a batch from summed stage durations is 20–27 % low before any other error. (Measured: archive screening campaign R3 — YBaCo₄O₇ at −19.9 % and the CuO/Cu₂O tranche at ~27 %, both audited against an external clock, fit-level agreeing to 0.62 %.)

9c.12 Pair timing events in order; never bucket them by pattern index. A pattern index recurs — reseed retries, verification refits, restarted segments — so max(fit_end) min(fit_start) within a bucket spans everything that happened in between, and nothing about the result looks wrong: one segmented recovery chain reported 700.686 s of fit time against a 147.988 s wall clock, 4.73×. Ordered fit_startfit_end pairing is the fix, but it can also overstate where fits are silently re-executed with no retry marker (2.7× on one audit), so cross-check against a per-part table before quoting. Assert event-log-summed fit time against an independently measured wall clock on every unit of the batch — it is the only check that caught any of this. (Measured: archive screening campaign R3, the (La,Sr)FeO₃ chemical-looping series and its segmented-recovery audit.)

9c.13 Rotate or re-path the event log before every run. EventStream opens its file in append mode unconditionally and writes no run marker, so re-running a batch silently interleaves two runs into one file and every derived duration is a mixture. It corrupted timings on two independent batches, once reporting a 10.276 s calibration as 458.995 s — 44.67×. Truncate or re-path before every write, and note concurrent load on the machine as a confound to state rather than to absorb. (Measured: archive screening campaign R3, the (La,Sr)FeO₃ series among two affected batches; history/events.py opens with mode "a" on current main.)

9c.14 Pilot the model on a dozen patterns before multiplying it by N. The dominant failure at scale is not a bad fit but a good fit to a wrong model, N times, and the errors that do it are invisible in the residual: a space-group setting resolved from a bare Hermann-Mauguin symbol (40 symbols in gemmi’s own table carry more than one setting, and origin-2 coordinates under an origin-1 setting converge at an ordinary Rwp with a whole reflection class silently zeroed and no diagnostic firing), a unit conversion made once in a shared calibration and therefore wrong in every pattern that uses it, and a mis-parsed axis (a GSAS FXYE with its BANK line commented out returned [50.0, 4399.6]° instead of [0.5, 43.996]° — same point count, no exception, provenance None). Total cost is near-linear in length, so a dozen patterns is a few per cent of a long run and tests all of it; per-pattern cost is sub-linear in model size (1.489 s → 4.170 s, 2.80× for a 5.35× increase in reflections), so the pilot’s per-pattern time is an upper bound — time it as well as reading it (9c.31). Assert a sanity bound on every parsed 2θ axis, check a transcribed phase’s cell mass against the source file’s own (286.182 against 286.184, 7 ppm, settled an ambiguous Cu₂O setting whose origin-1 alternative was a 1:2 Cu:O compound — though mass balance cannot catch the origin error above, so the two checks complement rather than substitute), and validate any reference-file reader against check values you did not derive. (Measured: archive screening campaign — the Cu₂O transcription against its own TOPAS .inp, the FXYE reader fallback on a ZnCr₂O₄ pattern, and the CuO/Cu₂O tranche’s five-to-six-phase cost scaling and 12-pattern timing pilot.)

9c.15 Re-derive the batch’s inventory from the archive and de-duplicate by file identity. A batch scoped from a list someone wrote is scoped to that list: one brief’s own per-series counts summed to 638 rather than the 658 it stated, and of those 638 only 624 were diffraction patterns — the other 14 gas-monitor markers with no diffraction angles and furnace temperature logs — inside a directory set holding 1055 raw files in all; a whole-archive manifest elsewhere carried 1372 rows but 1366 distinct members, six byte-identical duplicates that would have double-weighted four temperatures. Assert the expected file count immediately after the selection glob, so a wrong pattern set fails loudly instead of fitting. (Measured: archive screening campaign — the (La,Sr)FeO₃ tranche’s 1055/638/624 count, and the whole-archive 11-BM manifest whose 1372 entries were 1366 distinct scans, of which the GeNi₂O₄ series contributed 215 fitted after the sentinel row.)

9c.16 Scope from one level above the data, and write the unrun remainder down. A batch scoped from the files it had already chosen looks complete against itself, and the gap is invisible from inside the scope: one campaign covered 3 of 10 series and left 771 patterns unrun while reading as finished. Two smaller instances from the same inventory — a series-looking name that was 2 patterns with no temperature recorded, and a folder whose reference row count disagreed with its pattern count because it held two scan geometries, to be filtered by filename rather than reconciled by arithmetic. Where an arm is time-boxed out, record it as undetermined rather than passed and name the exact excluded set, because an omitted arm in a summary table reads as a clean one. (Measured: archive screening campaign — the inventory rebuilt one directory above the fitted set.)

9c.17 Give every per-unit iteration its own try, not just the innermost loop. A batch driver is judged by what one failure costs, and the guard belongs at each level that can lose work independently: the same reflection-enumeration guard that kills a fit fired inside a calibration fit, and because that loop had no per-geometry try, all of that series’ minority geometries were lost — 8 of 9 series produced them and that one produced none. Persist each unit’s result as it lands rather than at the end. (Measured: archive screening campaign R3, the minority-geometry pass.)

9c.30 Expect model debugging, not the solve, to be the batch’s cost. In the one batch where both halves were timed, “the batch is expensive” was a claim about getting the model right: that pilot’s summed solve time was 13.2 min (409.7 s + 382.0 s, confirmed against results.json) after 20–25 min of setup before the first production fit, while a fully audited job of 380 chained patterns plus 380 cold refits plus 244 minority-geometry cold fits plus 2 reference fits ran in 57 minutes single-threaded. Speed was not the constraint in either job measured, so budget the pilot and the inventory rather than the cores — and pass an explicit long timeout, since a production run will outlive a shell’s default and being auto-backgrounded mid-run is indistinguishable from finishing. (Measured: archive screening campaign — the CuO/Cu₂O pilot’s audited solve sums, whose 68.5 min session wall figure is itself unverifiable from the event logs and is 9c.29’s own example, and R3’s audited nine-series (La,Sr)FeO₃ job at 57 minutes.)

9c.31 Pin the compute environment for the whole batch and assert it per unit, not once at the start. Thread count and package version are covariates of every timing and every regression claim you will make: a 12-pattern timing pilot ran 5.92 s per pattern against the same tranche’s 4.17 s in the previous round, a 42 % miss traced to single-threaded BLAS and caught only because the pilot was timed before a chunk size was committed to. Version drift is worse because it is silent — one machine’s three separately-frozen baseline environments had all become the same release, which unreproduces every version-difference claim measured against them without any of them erroring. Set the *_NUM_THREADS variables inside the driver rather than in the shell that launched it, and record result.provenance per unit so a later reader can tell which build produced which number. (Measured: archive screening campaign — the CuO/Cu₂O timing pilot’s BLAS miss, and the campaign’s collapsed version baselines.)

9c.32 Name every run with label=, after the thing that fit is about. Unnamed, a run takes the name of the working directory it ran in, so a batch driven from one directory writes one name down every row of rietx watch’s list, and the reader tells the runs apart by start time alone. label= is on every verb that records a run (fit, run_stage, refine, refine_sequential, Project.fit) and costs one keyword at the call. Pass the candidate, the phase, the specimen: the fact that makes this fit the one you will want to reopen. It is telemetry, so it changes no number the fit produces and appears in no result. On a chain label= names the job while labels= names the patterns, and the list shows the pattern. (Hypothesis: the limit it removes is measured, WP-1424 having made the rows tell apart by start second and reported that the record still names no run after its science. No campaign has yet run with labels on, so whether a named list is read faster than a timestamped one is undecided. What would decide it: a batch operator asked to reopen a named candidate from the watcher list, against the same task on a timestamped one.)

Reference: 9d. A human is watching: run directories, and the window onto a long fit

Load it when a person may be looking over your shoulder at a fit that takes a while, when you want to hand one a live view rather than a promise to report back, or when you need to read a finished run off disk. §9b is a chain of fits and §9c a batch of them; this file is about the record every single fit leaves behind. A batch inherits one directory per fit. A series does not: one refine_sequential call is one job and records one directory for the whole chain, however many patterns it walks.

A reference file of the rietx skill. The body it belongs to is SKILL.md; section numbers are the ones the body cites. Every row carries its evidence: (Measured: …) names the run and its number, (Hypothesis: …) names what would decide it.

9d.1 You did not have to arrange any of this, and you cannot forget to. Every fit, run_stage, refine and refine_sequential writes a run directory to disk whether or not you passed events=. There is no flag to set first and nothing to remember, so the correct action when a human asks to watch is to point them at a command, never to restart the fit with telemetry on.

(Measured: WP-1403’s acceptance — a plain fit() in an empty directory leaves one run the watcher lists, 204 kB over six files on the synthetic five-stage LaB6 case: snapshot.json 171 kB, events.jsonl 30.8 kB over 87 events, summary.txt 1425 B, meta.json 203 B, status.json 235 B, run.lock empty. [dev] venv, macOS arm64.)

9d.2 Hand a human rietx watch, run from the directory you are working in. It serves a run list on 127.0.0.1:8899, with --port to move it and --open to open a browser. They pick a run and get the obs/calc/difference plot plus the event console, both updating as you fit. A region they have zoomed into stays zoomed while the stages go past, which is what makes it worth their attention rather than a picture they have to re-find their place in after every stage.

Give them the command and the port, and say which run is yours if the directory holds several. Name the run as you start it, with label= on the fit verb (9c.32). Unnamed, its label is the working directory’s name, so in a shared tree say the run id instead. The window has a stop button in it, and 9d.9 is what that does to you.

(Measured: WP-1402 checked the zoom in chromium — the axis range is identical before and after a forced redraw. The watcher’s own effect on your fit is nothing: it reads, and it constructs no project and no refinement.)

9d.3 What the run holds is the parameter trajectory, and it is not yours to leak. events.jsonl carries every free parameter’s value at every recorded evaluation, with the stage boundaries and their statistics. snapshot.json carries the stage’s curves decimated for drawing. No pattern bytes are ever copied into a run.

On a shared filesystem that trajectory is a disclosure nobody opted into. If the work is confidential, set RIETX_TELEMETRY=0 before the first fit rather than deleting directories afterwards.

(Measured: WP-1406, [dev] venv, macOS arm64 — an eval event carries stage, n_eval, cost, accepted and values, the last being the free vector itself; a stage_end carries rwp, status, termination, n_iterations, held and released. The snapshot’s decimation budget is 4000 points, so a 4200-point pattern kept 4143 and a 22 003-point one is a picture of itself.)

9d.4 Read a finished run back with the reader, and never by opening the project. runs.discover(root) lists what is under a directory, runs.read_run(path) reads one, runs.tail_events(path / "events.jsonl", offset) returns events from a byte offset with the next offset, and runs.liveness_of(run) says whether it is still being written. summary.txt in the run is the termination view as print(result) gives it, which is the cheapest way to see how a fit ended without recomputing anything.

The reader constructs nothing on purpose. Project.open appends an annotation to a project’s history before you have done anything, so opening a project to look at a run would change the thing you came to look at.

(Measured: WP-1406, against rietx 1.4.0 — discover returned the run, read_run its id, tail_events 87 events at offset 30782 with 0 bad lines, liveness_of done with the evidence “the run recorded itself done”.)

9d.5 abandoned is a real answer and the one you act on. A run’s liveness is running when a process holds its lock, done / failed / cancelled when the writer recorded its own last word, abandoned when the status still says running and the lock is free, and unknown when the question cannot be answered here. abandoned is what a killed process leaves behind. Read it as “that fit is over and nobody wrote an ending”, and do not wait for it to finish.

unknown is not evidence that nothing is happening. It covers a run written on another host, a run with no state recorded, and every run written before the recorder existed.

(Measured: WP-1401 built the ladder and tests/test_runs.py drives every rung of it — a released lock under a running status reads abandoned, a foreign host reads unknown, a status with no state reads unknown rather than running, and a legacy run reads unknown and says so.)

9d.6 Switching recording off is one line, and the environment outranks you. RIETX_TELEMETRY=0 covers a process and everything it starts, telemetry=False covers one call, telemetry=<path> moves the root, and runs.set_enabled(False) switches it inside a process. No value of telemetry= argues back against the environment variable, so on a machine where an operator has set it you record nothing and should not try to.

Do not switch it off to save time. The cost is small and measured, and a run you did not record is a run nobody can look at afterwards.

(Measured: WP-1403 — RIETX_TELEMETRY=0 and telemetry=False each left nothing at all, asserted as an empty rglob. WP-1322 measured three of three subagents switching telemetry off when it was opt-in, which is why it is on by default now.)

9d.7 Recording costs a few per cent and changes no answer. The event log alone runs 1.01 to 1.03 times a bare fit’s wall clock. Adding the per-stage picture takes it to 1.03 to 1.23 times over three patterns of 22 003, 7251 and 4165 points. The charge is per stage and nearly constant, so a short fit pays the largest multiple and a long one barely notices. Disk is bounded by retention, which deletes by age and size rather than by count: nothing younger than a week goes, and above a 1 GiB ceiling the oldest finished runs go first. A root over the ceiling with nothing old enough warns and keeps everything, so a long batch never eats its own early runs.

(Measured: WP-1401, WP-1402, WP-1404 and WP-1413 on nac (22 003 points), cpd-2 (7251) and trigger (4165), seven interleaved repeats, [dev] venv, macOS arm64. Every configuration returned a bit-identical Rwp. The retention scan runs once per process and cost 0.2 ms at 10 runs, 2.2 ms at 100 and 27.6 ms at 1000.)

9d.8 A failure to record is not a failure to fit. If the recorder cannot write, it stops, warns once for the process, and puts the reason in the run’s status.json where it can still write one. Your fit carries on and its result is unaffected. A RuntimeWarning about telemetry is therefore never a reason to re-run anything.

A callback you passed through events= is the opposite case. It is yours, it runs outside the recorder’s protection, and if it raises it takes the fit down. That is deliberate: a monitoring hook that crashes the refinement is a bug you want to see.

(Measured: WP-1403 — a read-only directory latches the recorder off with a warning and the fit completes; a caller’s callback exception still propagates, asserted in both directions in tests/test_telemetry.py.)

9d.9 The human watching can stop you, and it arrives as an exception. The window you handed them has a stop button. Pressing it raises RefinementCancelled in your process at the next residual evaluation, whether or not you passed a cancel= of your own. Catch it. A RefinementCancelled you did not ask for is not a bug in your call. Re-running the fit is the wrong response to one.

What survives is stated on the exception. .completed_stages are the stages that finished, .node_id is the history node the working state stands at, and that id is a checkout target. The stage in flight is abandoned: no node, no committed parameters, and the structure and instrument go back to where that stage found them. A cancelled run has no summary.txt, because there is no result to write one from.

You cannot tell a human’s stop from your own token.cancel() by looking at the exception, and you are not meant to. The record can: status.json carries cancelled_by when a request caused it, and nothing when your own code did.

In a series a stop ends the whole chain. The call returns what completed, with SEQUENTIAL_CANCELLED among the diagnostics.

Report where you got to and hand back the node id. If a fit genuinely must be uninterruptible, the honest answer is telemetry=False, which removes the run directory and the window with it. rietx watch --read-only is a flag the human sets on their own watcher.

(Measured: WP-1405 — a second process writes the request and the fit exits 0.117-0.126 s later, at stage 1 of 150, with empty stderr, while the control that is never asked runs all 150. A 4-pattern series stopped at 0 completed entries with SEQUENTIAL_CANCELLED, against 4 of 4 unasked. [dev] venv, macOS arm64.)

Reference: The API index

Load it when you are about to call rietx and want the name, the signature or the constructor rather than a guess.

A reference file of the rietx skill. The body it belongs to is SKILL.md. Generated by docs/skill/make_api_index.py from the installed package — every signature, field and default below is rendered, not typed, and a test fails when this file is older than the code. Do not edit it; edit the generator’s selection.

There is one integration surface and it is the Python API. A caller runs a verb, reads the typed answer, and dumps it with model_dump(mode="json") when a file is wanted. A failure raises: there is no envelope and no error code.

Everything in rx. is import rietx as rx; rx.report and rx.viz are submodules the package imports for you. inspect.signature(obj) or help(obj) gives any call’s full docstring; A -> B is a return annotation; a rx.Parameter(…) default names the parameter’s starting value and whether it is free.

In

Readers and constructors. rx.read_pattern opens every format rx.capabilities() lists and takes the file’s esd column when it has one; a wavelength comes from the instrument preset or the file, never from memory (§1). A file extension does not name a format here: dispatch is on content, and a declined .raw names the six vendors who write one and picks none, so read the refusal rather than the suffix (Measured: WP-1407). Handed another program’s input file — a PowderLine GSASII_Rietveld recipe — read it with rx.read_recipe rather than parsing it yourself: it returns the model, the instrument and a plan together, and a unit it could not carry says so as a RECIPE_* diagnostic. rx.read_gsas_prm reads a GSAS-I .prm instrument-parameter file (the dominant one-bank, constant-wavelength case; a neutron time-of-flight file and every other GSAS profile function are refused by name). A Kα1/Kα2 doublet there comes back as two emission lines, the second weighted by the file’s own KRATIO, so do not add a Kα2 line yourself after reading one (Measured: WP-1118). rx.write_gsas_prm is its inverse, and the one writer here whose payload is not the refine flags: an instrument-parameter file is a calibration, so it goes out frozen. It refuses a non-zero zero_shiftICONSZERO has no established unit here and a guess is wrong by 100× — so zero it and let the receiving program refine it. rx.read_gsas2_instprm/rx.write_gsas2_instprm are that pair one program over, for GSAS-II’s text calibration: PXC and PNC banks, and a negative width coefficient refused rather than clamped, which 2 of the 4 constant-wavelength files in GSAS-II’s own tutorial corpus trip (Measured: WP-1118). Handed another program’s whole refinement — a TOPAS .inp, a FullProf .pcr, a GSAS .EXP — call rx.read_project_model, which dispatches on content and returns what the file stated plus a .to_structure() carrying the file’s own refine flags, the half nobody can rebuild from a CIF plus a pattern (Measured: WP-1118 — six agents handed a hand-transcribed series all named the transcription as the hardest part of the work). Read .format.reports_at to know which call takes your diagnostics= list: a .inp reports at read, a .pcr at to_structure, a .EXP at both. A GSAS .EXP carries the whole experiment rather than one phase, so read the wavelengths, excluded regions and refined profile terms off model.stated — a Structure cannot hold them. A blank field there is not a zero: ka2_ratio is None where the file states no Ka2/Ka1 ratio, and the 0.5 one field earlier is the polarization — both are conventionally 0.5 (Measured: WP-1118). A GSAS-II .gpx reads through the same door and carries two things the older formats cannot: the constraints, and the vary_list naming every variable the run refined. It is a pickle, so the reader resolves an allow-list and refuses any other global by name without reading the file (Measured: WP-1118). to_structure refuses a negative Uiso and a phase with no sites, both of which real projects contain. Two codes, two goodness-of-fit conventions: a .gpx’s gof is the square root of reduced χ² and a .EXP’s reduced_chi2 is not a root at all, so comparing your fit with either file’s figure means knowing which one it quoted (Measured: WP-1118). All four formats write back: rx.write_topas_inp, rx.write_fullprof_pcr and rx.write_gsas_exp are each format’s to_structure inverse, a file whose refine flags reproduce the Structure’s own vary exactly and whose space group is get_spacegroup(...).xhm(), never the phase’s stored spelling. FullProf’s grammar has no origin/axis suffix at all, so write_fullprof_pcr refuses a phase whose resolved setting a bare symbol cannot reach — most often origin choice 1 (Measured: WP-1118). A .EXP is the one written by column, so it reports what narrowed to fit a field and which merged refine flags it freed; pass diagnostics=[] for both (a Biso always narrows, the file storing Uiso). GSAS-II has no project file to write, so it takes the pair it imports: rx.write_gsas2_phase_cif for the phases and rx.write_gsas2_instprm for the machine. That CIF states the setting three ways, because GSAS-II reads a bare F d -3 m as origin choice 2 where gemmi reads choice 1 (Measured: WP-1118).

  • rx.read_pattern(path: str | Path, *, diagnostics: list[Diagnostic] | None = None, **options: Any) -> PatternData — Read any supported pattern file, dispatching on content first.

  • rx.read_pdcif(path: str | Path, *, block: str | None = None, diagnostics: list[Diagnostic] | None = None) -> PatternData — Read a powder pattern from a pdCIF file.

  • rx.read_recipe(source: str | Path | dict, *, diagnostics: list[Diagnostic] | None = None) -> Recipe — Read a PowderLine GSASII_Rietveld recipe.

  • rx.read_gsas_prm(path: str | Path, *, diagnostics: list[Diagnostic] | None = None) -> Instrument — Read a GSAS-I .prm instrument-parameter file as a frozen Instrument.

  • rx.write_gsas_prm(instrument: Instrument, path: str | Path, *, header: str = '', diagnostics: list[Diagnostic] | None = None) — Write instrument to path as a GSAS-I .prm.

  • rx.read_project_model(path: str | Path, *, diagnostics: list[Diagnostic] | None = None) -> ProjectModel — Read a refinement another program wrote, dispatching on content.

  • rx.identify_project_format(path: str | Path) -> ProjectFormat — Which registered project format claims path — the dispatch, once.

  • rx.read_topas_inp(path: str | Path, *, diagnostics: list[Diagnostic] | None = None) -> TopasModel — Parse a .inp.

  • rx.write_topas_inp(structure: Structure, path: str | Path) — Write structure to path as a TOPAS .inp.

  • rx.read_fullprof_pcr(path: str | Path) -> FullProfModel — Parse a .pcr.

  • rx.write_fullprof_pcr(structure: Structure, path: str | Path) — Write structure to path as a FullProf .pcr.

  • rx.read_gsas_exp(path: str | Path, *, diagnostics: list[Diagnostic] | None = None) -> GsasModel — Read a GSAS-I .EXP experiment file.

  • rx.write_gsas_exp(structure: Structure, path: str | Path, *, title: str = '', diagnostics: list[Diagnostic] | None = None) — Write structure to path as a GSAS-I .EXP.

  • rx.read_gsas2_gpx(path: str | Path, *, diagnostics: list[Diagnostic] | None = None) -> Gsas2Model — Read a GSAS-II .gpx project file.

  • rx.write_gsas2_phase_cif(structure, path: str | Path, *, diagnostics: list[Diagnostic] | None = None) — Write structure to path as the phase CIF GSAS-II imports.

  • rx.read_gsas2_instprm(path: str | Path, *, bank: int | None = None, diagnostics: list[Diagnostic] | None = None) -> Instrument — Read a GSAS-II .instprm file as a frozen Instrument.

  • rx.write_gsas2_instprm(instrument: Instrument, path: str | Path, *, diagnostics: list[Diagnostic] | None = None) — Write instrument to path as a GSAS-II .instprm.

  • rx.Structure.from_cif(path: str, *, phase_name: str | None = None, aniso: bool = False, diagnostics: list | None = None) -> Structure

  • rx.Instrument.bragg_brentano(*, radiation: str = 'CuKa', goniometer_radius_mm: float = 217.5, monochromator_two_theta: float | None = None, ka2_ratio: float = 0.5, mu_t: float | None = None, thickness_mm: float | None = None) -> Instrument — Lab flat-plate diffractometer preset with a Kα1/Kα2 doublet.

  • rx.Instrument.debye_scherrer(wavelength: float, *, polarization: float = 0.99, goniometer_radius_mm: float | None = None, capillary_radius_mm: float | None = None, packing_fraction: float = 0.6, mu_r: float | None = None) -> Instrument — Synchrotron/capillary preset with a single wavelength.

  • rx.estimate_mu_r(structure: Structure, instrument: Instrument) -> float | None — Starting µR for a packed capillary, from composition and geometry.

  • rx.auto_background(data: PatternData, *, kind: str = 'pspline', diagnostics: PatternDiagnostics | None = None, wavelength: float | None = None) -> Background — Build a background model sized to the pattern.

  • rx.diagnose(data: PatternData, *, wavelength: float | None = None, baseline_lambda: float | None = None) -> PatternDiagnostics — Compute PatternDiagnostics for a raw pattern.

  • rx.load_instrument_profile(path: str | Path) -> Instrument — Read a profile file back as a frozen instrument.

  • rx.save_instrument_profile(instrument: Instrument, path: str | Path) — Write the instrument’s calibrated state to a JSON profile file.

  • rx.capabilities() -> Capabilities — Everything this build can do — see the module docstring.

  • rx.help_for(path: str) -> HelpEntry | None — The entry for a parameter dot-path, or None if no family claims it.

The model objects

Every refinable quantity is a rx.Parameter (value, vary, bounds), addressed by a dot-path such as phases.0.cell.a or instrument.profile.w; rx.help_for(path) says what any of them is. Each class below is its own constructor — the fields are the keyword arguments, a field with no default is required.

  • rx.Parameter — A single refinable scalar. Fields: value: float, vary: bool = False, min: float = -inf, max: float = inf, expr: str | None = None, transform: Literal['identity', 'softplus', 'exp', 'logit'] = 'identity', unit: str | None = None, stderr: float | None = None

  • rx.Structure — The phases refined against the same pattern(s) — possibly none. Fields: phases: list[Phase]

  • rx.Phase — A crystalline phase: symmetry, cell, atoms, scale, sample broadening. Fields: name: str, space_group: str, cell: Cell, atoms: list[Atom], scale: Parameter = Parameter(1.0, min=0.0), extinction: Parameter = Parameter(0.0, min=0.0), preferred_orientation: PreferredOrientation | None = None, lor_size: Parameter = Parameter(0.0, min=0.0), lor_strain: Parameter = Parameter(0.0, min=0.0), gauss_size: Parameter = Parameter(0.0, min=0.0), gauss_strain: Parameter = Parameter(0.0, min=0.0), microstrain: StephensStrain | None = None, particle_radius_um: float | None = None, restraints: list[BondRestraint | AngleRestraint | ValueRestraint] = []

  • rx.Atom — One site in the asymmetric unit. Fields: label: str, species: str, x: Parameter, y: Parameter, z: Parameter, occ: Parameter = Parameter(1.0, min=0.0, max=1.5), biso: Parameter = Parameter(0.5, min=0.0, max=25.0), aniso: AnisoU | None = None

  • rx.Cell — Unit-cell lengths (Å) and angles (degrees). Fields: a: Parameter, b: Parameter, c: Parameter, alpha: Parameter, beta: Parameter, gamma: Parameter

  • rx.AnisoU — Anisotropic displacement tensor in the CIF U^ij convention (Ų). Fields: u11: Parameter, u22: Parameter, u33: Parameter, u12: Parameter = Parameter(0.0), u13: Parameter = Parameter(0.0), u23: Parameter = Parameter(0.0)

  • rx.PreferredOrientation — Single-axis March-Dollase preferred-orientation correction (Dollase 1986). Fields: axis: tuple[int, int, int], r: Parameter = Parameter(1.0, min=0.15, max=6.0)

  • rx.StephensStrain — Anisotropic strain broadening coefficients S_HKL (Stephens, 1999). Fields: s400: Parameter = Parameter(0.0), s310: Parameter = Parameter(0.0), s301: Parameter = Parameter(0.0), s220: Parameter = Parameter(0.0), s211: Parameter = Parameter(0.0), s202: Parameter = Parameter(0.0), s130: Parameter = Parameter(0.0), s121: Parameter = Parameter(0.0), s112: Parameter = Parameter(0.0), s103: Parameter = Parameter(0.0), s040: Parameter = Parameter(0.0), s031: Parameter = Parameter(0.0), s022: Parameter = Parameter(0.0), s013: Parameter = Parameter(0.0), s004: Parameter = Parameter(0.0)

  • rx.Instrument — Everything about the measurement except the sample. Fields: source: Source | NeutronSource, geometry: Geometry = Geometry(…), zero_shift: Parameter = Parameter(0.0, min=-0.5, max=0.5), profile: ProfileTCHZ = ProfileTCHZ(…), background: BackgroundChebyshev | BackgroundFixedPlusChebyshev | BackgroundPSpline = BackgroundChebyshev(…), extra_components: list[HumpComponent | PeakComponent] = []

  • rx.Source — Constant-wavelength X-ray source. Fields: kind: Literal['xray_cw'] = 'xray_cw', lines: list[EmissionLine], polarization: Parameter = Parameter(0.5, min=0.0, max=1.0), dispersion: Dispersion | None = Dispersion(…), harmonics: list[Harmonic] = []

  • rx.NeutronSource — Constant-wavelength neutron source: one wavelength, nuclear scattering. Fields: kind: Literal['neutron_cw'] = 'neutron_cw', wavelength: Parameter, harmonics: list[Harmonic] = []

  • rx.Geometry — Diffraction geometry. Fields: kind: Literal['debye_scherrer', 'bragg_brentano', 'flat_plate_transmission'] = 'debye_scherrer', goniometer_radius_mm: float | None = None, surface_roughness: RoughnessSuortti | RoughnessPitschke | None = None, sample_displacement: Parameter = Parameter(0.0, min=-1.0, max=1.0), sample_transparency: Parameter = Parameter(0.0, min=0.0, max=0.05), capillary_offset_along_beam: Parameter = Parameter(0.0, min=-1.0, max=1.0), capillary_offset_across_beam: Parameter = Parameter(0.0, min=-1.0, max=1.0), axial_sl: Parameter = Parameter(0.0, min=0.0, max=0.2), axial_hl: Parameter = Parameter(0.0, min=0.0, max=0.2), mu_r: float | None = None, capillary_radius_mm: float | None = None, mu_t: float | None = None, thickness_mm: float | None = None, packing_fraction: float = 0.6

  • rx.ProfileTCHZ — Thompson-Cox-Hastings pseudo-Voigt width parameters. Fields: shape: Literal['tchz_pv', 'voigt'] = 'tchz_pv', u: Parameter = Parameter(0.0, min=-0.05, max=1.0), v: Parameter = Parameter(0.0, min=-0.5, max=0.5), w: Parameter = Parameter(0.001, min=0.0, max=1.0), x: Parameter = Parameter(0.001, min=0.0, max=1.0), y: Parameter = Parameter(0.0, min=0.0, max=1.0)

  • rx.BackgroundChebyshev — Shifted-Chebyshev background, linear in its coefficients. Fields: kind: Literal['chebyshev'] = 'chebyshev', coefficients: list[Parameter] = [Parameter(0.0) × 4]

  • rx.BackgroundPSpline — Penalized cubic P-spline background, co-refined with the structure. Fields: kind: Literal['pspline'] = 'pspline', breakpoints: list[float], coefficients: list[Parameter], lambda_smooth: float = 1.0, air_scatter: Parameter = Parameter(0.0, min=0.0)

  • rx.BackgroundFixedPlusChebyshev — A fixed estimated curve (never subtracted; held additively) plus a small refinable Chebyshev correction on top. Fields: kind: Literal['fixed_plus_chebyshev'] = 'fixed_plus_chebyshev', fixed_two_theta: list[float], fixed_intensity: list[float], chebyshev: BackgroundChebyshev = BackgroundChebyshev(…)

  • rx.Dispersion — Anomalous scattering corrections f′, f″ at the source wavelengths. Fields: table: Literal['cromer_liberman'] = 'cromer_liberman', overrides: dict[str, tuple[float, float]] = {}

  • rx.PatternData — A 1-D constant-wavelength powder pattern. Fields: two_theta: list[float], intensity: list[float], sigma: list[float] | None = None, excluded_regions: list[tuple[float, float]] = [], metadata: dict[str, str] = {}

Refining

rx.Refinement is the stateful entry point and rx.refine the one-shot function form. Plans are named in rx.PLAN_INFO or built as a rx.RefinementPlan of rx.Stages; ref.parameters() lists every entry, fixed, locked and tied included, and the editing verbs auto-commit a history node each.

  • rx.Refinement(structure: Structure, instrument: Instrument, *, backend: str = 'numpy', solver: str = 'trf', history: bool | str | Path | RefinementTree = True) — Refine structure + instrument against a powder pattern.

  • rx.Refinement.fit(data: PatternData, *, mode: Mode = 'rietveld', plan: RefinementPlan | str = 'mccusker_default', two_theta_limits: tuple[float, float] | None = None, events=None, cancel=None, telemetry=None, label: str | None = None, stage_reports: bool = False, progress=None) -> RefinementResult — Run a staged refinement.

  • rx.Refinement.report(*, plan: RefinementPlan | str | None = None, **kw) — The full rietx.report.FitReport for the last fit.

  • rx.Refinement.summary(*, deliverable: str | None = None, plot: str | None = None, plan: RefinementPlan | str | None = None, report: FitReport | None = None) -> str — The termination view (WP-1302): “done, or not, and why” from one call.

  • rx.Refinement.suggest(data: PatternData, *, top_n: int = 5, include: str | list[str] = '*', exclude: list[str] | tuple[str, ...] = (), mode: Mode | None = None, two_theta_limits: tuple[float, float] | None = None, report=None) -> SuggestionResult — Which held parameter should be freed next?

  • rx.Refinement.predict(two_theta=None) -> np.ndarray — y_calc at the current parameters — the evaluate-only path.

  • rx.Refinement.run_stage(data: PatternData, stage: Stage, *, mode: Mode | None = None, two_theta_limits: tuple[float, float] | None = None, correlation_guard: float = 0.98, events=None, cancel=None, telemetry=None, label: str | None = None) -> RefinementResult — Run a single stage from the current state, recording a child node.

  • rx.Refinement.parameters(*, mode: Mode | None = None) -> list[ParameterRow] — Every parameter as data — fixed, locked and tied rows included.

  • rx.Refinement.set_vary(path_globs: list[str] | str, vary: bool = True) -> list[str] — Free (or hold) every parameter matching path_globs.

  • rx.Refinement.set_values(values: dict[str, float]) — Set parameter values by dot-path, recording a set_value node.

  • rx.Refinement.tie(path: str, source: str | dict[str, float] | Sequence[tuple[str, float]], *, scale: float = 1.0, offset: float = 0.0) -> str — Constrain path to scale·source + offset, recording a node.

  • rx.Refinement.tie_equal(paths: list[str] | str, *, source: str | None = None) -> list[str] — Make every parameter matching paths one parameter.

  • rx.Refinement.untie(paths: list[str] | str) -> list[str] — Release user ties, recording a set_tie node.

  • rx.Refinement.add_variable(name: str, value: float, *, vary: bool = False, min: float = -inf, max: float = inf, transform: str = 'identity', unit: str | None = None) -> str — Declare a named variable other parameters can follow.

  • rx.Refinement.remove_variable(name: str) -> str — Delete a named variable, refusing while anything follows it.

  • rx.Refinement.edit(*, structure: Structure | None = None, instrument: Instrument | None = None, label: str = '') -> str | None — Record a change to the model itself — adding an impurity phase, raising the background order, swapping the geometry.

  • rx.refine(data: PatternData, structure: Structure, instrument: Instrument, *, mode: Mode = 'rietveld', plan: RefinementPlan | str = 'mccusker_default', two_theta_limits: tuple[float, float] | None = None, backend: str = 'numpy', solver: str = 'trf', history: bool | str | Path | RefinementTree = False, events=None, cancel=None, telemetry=None, label: str | None = None) -> RefinementResult — One-shot functional API: refine(data, structure, instrument).

  • rx.RefinementPlan Fields: stages: list[Stage], correlation_guard: float = 0.98, intermediate_ftol: float | None = 1e-06

  • rx.Stage — One turn-on group of a staged plan — a declaration, not a result. Fields: name: str, turn_on: list[str], max_iter: int = 100, ftol: float | None = None, lebail_cycles: int = 3, seed: float = 0.0, strain_seed: float = 0.0, restraint_weight_scale: float = 1.0, window_slack_deg: float | None = None

  • rx.PLAN_INFO — keys: mccusker_default, mccusker_structural, lab_bragg_brentano, lab_calibrate, lab_sample_refine, profile_only, pawley_default

  • rx.ParameterRow — One row of the parameter table — the mirror of params.vector.Entry. Fields: path: str, value: float, vary: bool = False, lo: float = -inf, hi: float = inf, transform: Literal['identity', 'softplus', 'exp', 'logit'] = 'identity', tie: TieSpec | None = None, locked: bool = False, esd: float | None = None, mode_fixed: bool = False, needs_held_cell: bool = False, help_key: str | None = None

The four answers are four different types

A refinement, a series, an indexing run and a suggestion each return their own type, and none nests inside another. An rx.IndexingResult carries no cell key by design: best_or_none() is the only way to one cell, and it returns None more often than not (§6). RefinedParameter.at_bound is three-valued — test is True.

  • rx.RefinementResult Fields: status: Literal['converged', 'max_iter', 'diverged'], mode: Literal['rietveld', 'lebail', 'pawley'], parameters: list[RefinedParameter], statistics: Statistics, stages: list[StageResult] = [], diagnostics: list[Diagnostic] = [], provenance: Provenance, node_id: str | None = None, tree_id: str | None = None, two_theta: list[float] = [], y_obs: list[float] = [], y_calc: list[float] = [], y_background: list[float] = [], sigma: list[float] = [], ticks: dict[str, list[float]] = {}, qpa: QuantitativePhaseAnalysis | None = None, phase_agreement: list[PhaseAgreement] = [], data_support: DataSupport | None = None, absorption: AbsorptionCorrection | None = None, restraints: RestraintReport | None = None, geometry: GeometryTable | None = None, microstructure: list[PhaseMicrostructure] = [], identifiability: Identifiability | None = None, n_extra_components: int | None = None, n_background_components: int | None = None, histograms: list[HistogramResult] = []

  • rx.StageResult — One stage’s outcome. Fields: name: str, status: Literal['converged', 'max_iter', 'diverged'], n_iterations: int, cost_initial: float, cost_final: float, freed: list[str] = [], ftol: float | None = None, n_constraint_truncations: int = 0, n_degenerate_cell_probes: int = 0, held: list[str] = [], released: list[str] = []

  • rx.Statistics — Agreement indices, defined per Toby (2006), Powder Diffraction 21, 67. Fields: rwp: float, rp: float, rexp: float, chi2: float, gof: float, rwp_background_subtracted: float | None = None, durbin_watson: float | None = None, esd_inflation: float | None = None, n_points: int, n_free_parameters: int, max_shift_over_esd: float | None = None, identifiability_clause: str | None = None

  • rx.RefinedParameter — One row of RefinementResult.parameters. Fields: path: str, value: float, stderr: float | None = None, vary: bool = True, at_bound: bool | None = None

  • rx.Diagnostic — A structured, actionable message produced by the engine. Fields: level: Literal['info', 'warning', 'error'], code: str, message: str, where: list[str] = [], suggestion: str | None = None, value: float | None = None

  • rx.SeriesResult — The result of a sequential refinement over an ordered set of patterns. Fields: mode: Literal['rietveld', 'lebail', 'pawley'] = 'rietveld', entries: list[SeriesEntry] = [], x_label: str = 'index', direction: Literal['forward', 'backward', 'both'] = 'forward', backward: SeriesResult | None = None, diagnostics: list[Diagnostic] = [], provenance: Provenance | None = None

  • rx.SeriesResult.trajectory(path: str) -> Trajectory — One parameter’s trajectory across the series.

  • rx.SeriesResult.to_table(*, paths: list[str] | None = None) -> tuple[list[str], list[list]](header, rows): one row per pattern, a column per path (+ esd where that kind of path has one).

  • rx.SeriesResult.write_csv(path, *, delimiter: str | None = None, paths: list[str] | None = None) — Write to_table to CSV/TSV (delimiter inferred from suffix).

  • rx.SeriesResult.summary(*, max_entries: int = 5, deliverable: str | None = None) -> str — The series termination view (WP-1302): the trajectory table, the SEQUENTIAL_* rows, first and last max_entries with the count.

  • rx.SeriesEntry — One pattern’s place in the series: what was fitted and how it went. Fields: index: int, label: str = '', x: float | None = None, status: Literal['converged', 'max_iter', 'diverged'] = 'converged', statistics: Statistics | None = None, parameters: list[RefinedParameter] = [], qpa: QuantitativePhaseAnalysis | None = None, phase_agreement: list[PhaseAgreement] = [], diagnostics: list[Diagnostic] = [], n_iterations: int = 0, reseeded: bool = False, rwp_warm: float | None = None, rung: Literal['warm', 'warm_staged', 'cold'] = 'warm', rungs_tried: list[str] = [], node_id: str | None = None, tree_id: str | None = None

  • rx.IndexingResult — What rietx.index_pattern returns — and what it cannot return. Fields: candidates: list[CellCandidate] = [], engines_run: list[str] = [], systems_searched: list[str] = [], search_complete: dict[str, bool] = {}, engine_stats: dict[str, float] = {}, fom_panel_disagrees: bool = False, quality: DataQualityReport | None = None, validated: bool = False, wavelength: float = 0.0, n_usable_lines: int = 0, provenance: Provenance, preset: str | None = None, thresholds_version: str = '1.3', diagnostics: list[Diagnostic] = []

  • rx.IndexingResult.best_or_none() -> CellCandidate | None — The single candidate, or None.

  • rx.IndexingResult.evidence() -> IndexingEvidence — The machine-readable evidence view (WP-1043).

  • rx.SuggestionResult — Ranked, gated answer to “which parameter should be freed next?”. Fields: groups: list[CandidateGroup] = [], non_separable: list[ParameterCandidate] = [], skipped: list[str] = [], n_evaluated: int = 0, chi2_red: float, noise_floor: float, summary: str

  • rx.CandidateGroup — One rankable unit: a single candidate, or a tie the data cannot split. Fields: members: list[ParameterCandidate], gain: float, resolved: bool, delta_bic: float

The report

ref.report() or rx.build_report gives the three-layer rx.FitReport §5 reads. compare_rivals and predict_then_verify are the two experiments §4 step 14 and §4b call for.

  • rx.build_report(result: RefinementResult, *, model=None, values=None, plan=None, free_paths: list[str] | None = None, top_n: int = 15, match_tol_deg: float = 0.08, min_peak_sigma: float = 5.0) -> FitReport — Build the report, going as deep as the inputs allow.

  • rx.FitReport — All three layers. Fields: thresholds_version: str = '1.5', rwp: float, gof: float, cumulative_chi2_breakpoints: list[float] = [], regions: list[Region] = [], n_regions_total: int = 0, unmatched: list[UnmatchedPeak] = [], lebail_gap: LeBailGap | None = None, background: BackgroundEvidence | None = None, identifiability: IdentifiabilityEvidence | None = None, summary: str = '', attribution: list[RegionAttribution] = [], trends: list[TrendAnalysis] = [], texture: list[TextureAnalysis] = [], strain: list[StrainAnalysis] = [], restraints: RestraintReport | None = None, geometry: GeometryTable | None = None, microstructure: list[PhaseMicrostructure] = [], layer1_available: bool = False, abstained_reason: str | None = None, abstained_kind: Literal['immature', 'resolution_limited', 'unreadable'] | None = None, suggested_actions: list[SuggestedAction] = []

  • rx.report.compare_rivals(refinement, data, finding: ExchangeFinding | tuple[str, str]) -> RivalComparison — Fit each member of an exchangeable pair alone, the other at its null.

  • rx.report.predict_then_verify(refinement, data, action: SuggestedAction, *, min_improvement: float = 0.01) -> VerificationOutcome — Try an action on a branch, keep it only if χ² actually improves.

Series, history, projects

refine_sequential chains N patterns by warm start (§9b); refine_multi stacks them into one joint residual, which is a different thing. The history verbs work the DAG every fit commits to (§9); a Project owns a .rex directory; a CancelToken cancels cooperatively, between residual evaluations.

  • rx.refine_sequential(patterns: Sequence[PatternData], structure: Structure, instrument: Instrument, *, carry: Sequence[str] = ('*'), backend: str = 'numpy', solver: str = 'trf', history: bool | str | Path = False, **kw) -> SeriesResult — One-shot functional API for a warm-started series.

  • rx.SequentialRefinement(structure: Structure, instrument: Instrument, *, backend: str = 'numpy', solver: str = 'trf', carry: Sequence[str] = ('*'), history: bool | str | Path = False) — Refine an ordered series of patterns, each warm-started from the last.

  • rx.refine_multi(data: list[PatternData], structure: Structure, instruments: list[Instrument], *, plan: RefinementPlan | str = 'mccusker_default', sharing: SharingMap | None = None, two_theta_limits=None, weights: list[float] | None = None, backend: str = 'numpy', solver: str = 'trf') -> RefinementResult — One-shot joint refinement of structure against several data/ instruments pairs.

  • rx.MultiHistogramRefinement(structure: Structure, instruments: list[Instrument], *, sharing: SharingMap | None = None, backend: str = 'numpy', solver: str = 'trf') — Joint Rietveld refinement of a shared structure against several patterns.

  • rx.Refinement.checkout(node_id: str) -> Refinement — Restore the state recorded at node_id (a node id or a tag).

  • rx.Refinement.branch(node_id: str | None = None) -> Refinement — A second working tree over the same history, for a rival strategy.

  • rx.Refinement.merge(other: str, *, prefer: str = 'theirs', label: str = '') -> str — Three-way merge of another branch into the current state.

  • rx.Refinement.cherry_pick(node_id: str, data: PatternData) -> RefinementResult — Re-run another node’s stage action on top of the current state.

  • rx.replay(tree: RefinementTree, node_id: str, data: PatternData) -> RefinementResult — Recompute the curves and statistics of a recorded node.

  • rx.Project(path: str | Path, doc: ProjectDoc, data: PatternData, refinement: Refinement, data_diagnostics: list[Diagnostic] | None = None) — An openable, savable refinement session on disk.

  • rx.Project.create(path: str | Path, *, pattern: str | Path, structure: Structure | None = None, instrument: Instrument, mode: Mode = 'rietveld', plan: Any = None, two_theta_limits: tuple[float, float] | None = None, excluded_regions: list[tuple[float, float]] | None = None, reader_options: dict[str, Any] | None = None, ui: dict[str, Any] | None = None, backend: str = 'numpy', solver: str = 'trf') -> Project — Create a project directory around a pattern file and a model.

  • rx.Project.open(path: str | Path, *, backend: str = 'numpy', solver: str = 'trf') -> Project — Open an existing project, resuming at the history head.

  • rx.Project.save() — Rewrite project.json.

  • rx.CancelToken() — A thread-safe “stop asking for evaluations” flag.

An unknown phase

Peaks, then a cell, then the extinction symbol — the closed loop of §7b-7f, each step returning a ranked list and never a singleton. rx.fit_peaks leaves that loop: it fits the positions you name, for a width analysis or a d-spacing, with no cell in the question.

  • rx.pick_peaks(data: PatternData, instrument: Instrument, *, two_theta_range: tuple[float, float] | None = None, shoulders: bool = True, flag_contamination: bool = True) -> PeakList — Every resolvable line in data, with a fitted position and its esd.

  • rx.fit_peaks(data: PatternData, instrument: Instrument, positions: np.ndarray | list[float], *, two_theta_range: tuple[float, float] | None = None) -> PeakList — Profile-fit exactly the peaks at positions — no structure, no space group, no refinement.

  • rx.index_pattern(peaks: PeakList | None = None, *, data: PatternData | None = None, instrument: Instrument | None = None, spec=None, preset: str | None = None, engines: Sequence[str] | None = None, quality=None, shift_from_pairs: bool = True, validate: bool = True, check_top: int | None = None, two_theta_limits: tuple[float, float] | None = None, events=None, cancel=None) — Find the unit cell — or say, in the shape of the answer, that it cannot.

  • rx.determine_extinction_symbol(data: PatternData, candidate: CellCandidate, instrument: Instrument, *, peaks: PeakList | None = None, two_theta_limits: tuple[float, float] | None = None, k_sigma: float = 3.0, max_classes: int | None = None, cancel=None) -> ExtinctionScreen — Rank the extinction classes compatible with an indexed lattice.

Out

Files and figures. rx.format_su renders a value with its esd as 1.2345(12); plot_for_vlm is the montage §5 allows as a check on a conclusion already reached from numbers. rx.write_recipe_tables is the return leg of rx.read_recipe — a finished refinement as PowderLine’s four tables, for a pipeline that dispatched the job here.

  • rx.write_refinement_cif(result: RefinementResult, structure: Structure, instrument: Instrument, path: str | Path) — Write a refinement CIF: structure (values + esds), R-factors, wavelength, profile/background description, and the observed/calculated pattern loop.

  • rx.write_qpa_table(qpa: QuantitativePhaseAnalysis, path: str | Path, *, delimiter: str | None = None) — Write the QPA table to CSV/TSV (delimiter inferred from suffix).

  • rx.write_reflection_table(rows: list[ReflectionRow], path: str | Path, *, delimiter: str | None = None) — Write reflection rows to CSV/TSV (delimiter inferred from suffix).

  • rx.reflection_table(model: CompiledModel, values: dict[str, float], structure: Structure) -> list[ReflectionRow] — Reflection rows for every (emission line, reflection) of every phase.

  • rx.write_recipe_tables(refinement, out_dir: str | Path, *, phase_names: dict[str, str] | None = None) -> dict[str, Path] — Write a finished refinement as PowderLine’s four output tables.

  • rx.format_su(value: float, esd: float | None, *, decimals: int = 6) -> str — A number with its standard uncertainty in value(su) notation.

  • rx.viz.plot_result(result: RefinementResult, *, path: str | None = None, two_theta_range: tuple[float, float] | None = None, show_background: bool = True, weighted: bool = False, style: str = 'light', wavelength: float | None = None, x_axis: str = 'two_theta', y_scale: str = 'linear', label_align: str = 'bottom', figsize: tuple[float, float] | None = None, font_size: float = 11.0, dpi: int = 300) — Standard Rietveld panel: observed, calculated, difference, tick rows.

  • rx.viz.plot_for_vlm(result: RefinementResult, report=None, *, path: str, n_regions: int = 4, dpi: int = 140) — Annotated multi-panel montage rendered for what VLMs can read.

  • rietx.viz.html.write_html(result: RefinementResult, path: str, *, weighted: bool = False, include_plotlyjs: bool | str = True, max_points: int = 200000) — Render a RefinementResult to a self-contained HTML file.