Statistics-package file formats

Part of the numerical-validation suite. How the SPSS, Stata and SAS readers are proven against the packages that wrote the files.

Added 2026-08-06 (release 0.14.0 item 2); the .sas7bdat leg 2026-08-21 (0.21.0 item 7). Not a numerical gate — no statistic is computed here — but the same discipline applied to a different kind of correctness: does a .sav, .dta, .xpt or .sas7bdat arrive as the file actually says?

The readers (rust/src/foreign.rs) are ambers (SPSS), dta (Stata), sas_xport (SAS transport) and sas7bdat (SAS data files and .sas7bcat catalogs) — young, single-author, pure-Rust crates. That is a deliberate trade against vendoring the mature ReadStat C library, and the evidence below is what makes it defensible.

The corpus rule (standing policy, restated 0.21.0 item 8a). A real-world foreign file that misbehaves in one of these readers lands in test/fixtures/foreign/corpus/ with its fix, and expected.json is regenerated (generate_foreign_corpus.py), so every field quirk becomes a permanent per-cell differential test. Fix the reader, grow the corpus, one commit. The rule is also at the top of foreign.rs (where the fixer is) and in the corpus README (where the files are). Growth so far: the first sweep (0.14.0, 11 files), the strL file (0.18.0), and the seven .sas7bdat files with two catalogs (0.21.0 item 7).

Two artifacts, the same shape as the numerical suite:

Artifact Produced by Holds
test/fixtures/foreign/demo.{sav,dta,xpt} generate_foreign_fixtures.py small files written by pyreadstat, one of each metadata kind the Variable model carries
test/fixtures/foreign/corpus/expected.json generate_foreign_corpus.py every cell and metadata item of 19 real-world files, as pyreadstat reads them (.sas7bdat value labels from the sibling .sas7bcat the reader would attach)

Both generators corroborate before committing: the fixture writer reads its own output back and asserts the writer stored what was intended, and the corpus generator refuses to write ground truth it cannot verify (see the .xpt note below). Fixtures are create-if-absent, so a regeneration cannot silently churn committed bytes.

Why pyreadstat is a valid oracle here — and was not going to be

This is the one piece of reasoning worth preserving, because it inverted when the implementation route changed:

  • Under the original plan (vendor ReadStat), pyreadstat, R's haven and pandas.read_spss would all have been the same implementation as the thing under test — they are ReadStat wrappers. Comparing against them would have proven only that the library was called correctly, and could not have caught a ReadStat bug. The plan therefore specified GNU PSPP and IBM's SPSS Statistics Data File Driver as the oracles carrying independent evidence.
  • Under the shipped route (pure-Rust readers), pyreadstat shares no code with the readers under test. It is now a genuinely independent second implementation, and every corpus row is the cross-implementation check the plan wanted PSPP for.

So PSPP and the IBM driver are optional corroboration, not a gate. Standing them up would add a third opinion rather than the first independent one. What would change this: adopting a ReadStat-based reader after all, at which point the original argument returns in full and an independent oracle becomes mandatory again.

.xpt and .sas7bdat go one step further. Their corpus entries are cross-checked against pandas.read_sas — a native Python implementation independent of both ReadStat and our reader — and the generator fails rather than writing ground truth the two oracles disagree on. pandas.read_sas does not interpret SAS display formats for .xpt, and for .sas7bdat interprets a different set of them than ReadStat (it turns the partial-date formats into dates too), so any column either oracle rendered temporally is excluded from that check and pinned against our own reader instead.

.sas7bdat has no open-source writer (pyreadstat, pandas and haven all read it only), so there is no pyreadstat-written demo.sas7bdat: the Rust unit tests (ffi.rs) pin the committed corpus files directly, and the stress slot reads one committed real file unscaled (recorded in its report as such). The .sas7bdat conventions, decided before the pins were written:

  • One SAS table for both SAS readers. .sas7bdat renders dates, datetimes and times through the same format-stem table as .xpt (sas_render in foreign.rs), not the crate's own classification — the crate would type the partial-date families (MMYY, YEAR, MONYY, …) as dates, where pyreadstat (and the .xpt leg) leave them as the stored number with a warning. The reader overrides every numeric column to "stored number" and renders itself, so one value renders identically from either file.
  • Fractional seconds truncate toward the earlier second (sas_whole): 02:16:02.88 is in second 02:16:02, which is how pyreadstat renders it; a value within 1e-6 of a whole number snaps to it first (XPORT's IBM hexadecimal floats can land a hair off). Shared by both SAS readers.
  • Every SAS missing is blank. and the tagged .A.Z, ._ — matching pyreadstat's default read; a catalog label attached to a tagged missing is not carried and is named in a warning.
  • Value labels come only from a catalog beside the file: name.sas7bcat first, else formats.sas7bcat (SAS's default library catalog name, what a folder exported whole from SAS contains). A column gains labels when its format names one of the catalog's label sets, keys rendered as the cells render. A user-defined format with no label set is named in a warning — with the file name to place when there is no catalog, or the catalog that does not define it when there is. A catalog that fails to parse is a warning, not a failed import.

Recorded divergences

Where our reader and pyreadstat legitimately disagree, the app's behaviour is pinned in test/foreign_corpus_test.dart with its reason, exactly as a convention choice is recorded above — never loosened away:

  • Truncated multibyte strings (tegulu.sav): the file's Telugu text is cut mid-codepoint at the SPSS string width. pyreadstat drops the partial character; we surface U+FFFD. Neither invents data; ours shows something was cut.
  • SPSS QYR/MOYR/WKYR display formats (simple_alltypes.sav): pyreadstat leaves raw epoch seconds, we render real dates. Ours is the more useful behaviour, so it is kept and asserted date-shaped rather than exact-pinned (the exact rendering is upstream-formatting territory).

Known limits, surfaced not silent

Each of these is reported in the import's warnings — to stderr headless, to the import summary in the GUI — so a user never has to infer them (production_readness.md gate 1):

Limit Format Behaviour
User-missing ranges (lo..hi) SPSS No host equivalent; dropped with a warning naming the variable
strL long strings Stata Carried since 0.18.0 (the GSO table after the data records is read and every reference resolved — not a limit any more); the two residual cases are surfaced, not silent: a binary payload decodes as UTF-8 with replacement (warned, naming the variable) and a dangling reference imports blank (warned per cell)
Week/month/quarter/half-year display formats Stata Import as the stored number, with a warning
Partial-date formats (MONYY, MMYY, YYQ, YEAR, …) SAS (.xpt, .sas7bdat) Import as the stored number, with a warning
Value labels SAS .xpt Not in the file at all — SAS keeps them in a separate .sas7bcat catalog; a variable with a user-defined format is named in a warning
Value labels SAS .sas7bdat Carried from a .sas7bcat beside the file (name.sas7bcat or formats.sas7bcat); without one — or for a format the catalog lacks — the variable is named in a warning that says what to place where
Labels for tagged missings (.A.Z) SAS .sas7bdat Not carried (the values import blank); named in a warning
Second and later datasets in a container SAS .xpt Only the first is imported; the rest are named in a warning