Documentation
¶
Overview ¶
Package spss reads SPSS `.sav` system files for the Pulse I/O pipeline.
The format is a tagged dictionary section followed by a data section. This package parses the dictionary spine — the file header, the record type 2 variable records (with their string continuation records), the record 3/4 value-label pairs, and the record type 999 terminator — into a faithful in-memory transcription of what the file declares.
Nothing is converted here. The dictionary carries SPSS's own types, format codes, widths, missing-value specifications and value-label codes verbatim; mapping them onto a Pulse schema is a separate concern, and doing it here would throw away the very information the mapping needs to be reversible.
Scope today ¶
Record type 6 documents are captured verbatim, and the record type 7 extension subtypes that carry the metadata worth having are interpreted: 7/3 machine integer info, 7/4 machine float sentinels, 7/5 / 7/7 / 7/19 multiple-response and variable sets, 7/11 measure level and display parameters, 7/13 long variable names, 7/16 the 64-bit case count and 7/20 the character encoding NAME. Every extension record is ALSO retained verbatim, interpreted or not, so the typed slots are a projection of the bytes rather than a replacement for them.
A dictionary containing no extension records at all is legal and common, so nothing in this package may require one; the system-missing sentinel in particular defaults to the spec value and treats record 7/4 as an override rather than a precondition, and only adopts a declared sentinel from a coherent sysmis < lowest < highest triple.
Every string the dictionary holds is decoded from the file's declared character encoding into UTF-8, strictly: an undecodable byte is a coded error naming the variable and the offending value, never a U+FFFD substitution (see charset.go). The declaration itself — the record 7/20 name and the record 7/3 character code — is retained verbatim for the write path, and a caller can override it with WithCharset.
Deliberately not interpreted: 7/10 extra product info and 7/17 / 7/18 attributes, which are free-form text with no Pulse home and are captured verbatim without warning; 7/14, 7/21 and 7/22, the very-long-string records, which belong to a later story and warn as unrecognised until it lands.
Missing values ¶
SPSS has more missing states than a null bitmap bit can hold: present, system-missing, and up to three discrete USER-missing codes — or a range, or a range plus one code — that separate `refused` from `don't know` from `not applicable`. Both obvious mappings lose: keeping the codes as data makes AGG_SUM add 99999 for every refusal, and collapsing them all to null destroys the item-non-response distinction survey analysis is built on.
So a numeric variable declaring user-missing values gets TWO cohort columns. The analytic column holds the real values and is null at every missing position, so the arithmetic is right; a generated `<var>_missing` sibling immediately after it carries the reason as a categorical — "sysmis", the value label the file declared for the code, or the code itself. Nothing is lost. WithMissingMode(MissingNull) suppresses the siblings for callers who want the slimmer schema and have accepted losing the reason. A CATEGORICAL column gets no sibling either way: its missing code is already a dictionary entry. See missing.go.
The metadata sidecar ¶
An import writes what a `.pulse` file cannot hold — measure levels, print formats, arbitrary value codes, missing-value specifications, declared byte widths, multiple-response sets, documents, attributes and the source charset — to a JSON sidecar beside the cohort, via the optional pio.SidecarEmitter contract. Its most important payload is the code / label / Pulse dictionary ID triple, because the cohort's own dictionary holds CODES and the sidecar is therefore the only place the LABELS live. See sidecar.go.
All three data-section encodings the format defines are read: uncompressed, SPSS's default bytecode compression (see data.go and bytecode.go), and ZSAV zlib block compression (see zsav.go). dictionary.dataOffset is the byte offset of the section's first byte, and the header's compression flag and bias say how to read what follows.
ZSAV is TWO layers rather than a third encoding: the zlib blocks, indexed by the ZHEADER / ZTRAILER pair, inflate to a bytecode command stream, and that stream is then decoded exactly as a flag-1 file's would be. Reading the inflated bytes as anything else would produce plausible numbers rather than an error, which is why the nesting is stated at every level it touches. Emission is out of scope in the other direction: there is no `.sav` or `.zsav` writer (PULSE_SPSS_EXPORT_UNSUPPORTED).
Byte order ¶
Both orders are read. The header layout code decides — it always holds 2 or 3, and neither byte-swaps into anything in range, so reading those four bytes both ways identifies the file's order with no residual doubt. Record 7/3 states the order a second time and is a corroboration only, never a source: reading it already requires knowing the order. A clean contradiction between the two is PULSE_SPSS_ENDIANNESS_MISMATCH, a hard error, because byte order governs every count, offset and double in the file and the wrong choice yields a whole file of plausible wrong numbers rather than one bad field. See checkEndianness in dict_parse.go for the full reasoning and for what is deliberately NOT a contradiction.
The header magic and the compression flag are cross-checked too, but that one WARNS (PULSE_SPSS_MAGIC_FLAG_MISMATCH) and the flag wins: the flag describes the bytes, while the magic is a coarse generation label a re-saving tool can leave stale.
Errors and warnings ¶
Every parse failure is a *errors.CodedError. The byte-addressed family carries the record and the byte offset both in the message and in Details under errors.DetailSPSSRecord and errors.DetailSPSSOffset: PULSE_SPSS_FILE_EMPTY (no bytes at all — held apart from truncation because a file that was never written and a transfer that was cut short have nothing in common but the symptom), PULSE_SPSS_DICT_INVALID (structurally malformed) and PULSE_SPSS_DICT_TRUNCATED (ran out of bytes). Malformed input never panics: every read is bounds-checked before it happens, and that is verified by a corruption sweep over every byte of the dictionary in every compression mode plus two fuzz targets, not asserted here.
Two places tolerate rather than reject.
Extension records: an unrecognised subtype yields one PULSE_SPSS_EXTENSION_UNKNOWN warning on dictionary.warnings; a recognised subtype whose payload does not match its shape yields PULSE_SPSS_EXTENSION_INVALID. Neither stops a parse, because real SPSS versions emit subtypes no published description lists and refusing such a file would reject data that is otherwise perfectly readable.
Record 3/4 value-label BINDING: a set naming variables it cannot attach to — mixed type or width, a string wider than the 8-byte value slot, an index landing on a string continuation — is dropped with PULSE_SPSS_VALUE_LABELS_DROPPED naming the variable, and the file imports. A value label is display metadata, so refusing the file would cost the data to save the labels.
The line under both is record FRAMING: a size or count that would desynchronise the walk is still a hard error, since resuming from a desynchronised offset would produce a plausible dictionary describing nothing in the file. A value-label element index below 1 or past the end of the dictionary is on the hard side of that line too — it is damage rather than dialect, and it puts the record's own framing in doubt.
Index ¶
- Constants
- func DerivedKinds() []string
- func SidecarPath(cohortPath string) string
- type Case
- type CaseValue
- type Category
- type CategoryCode
- type Charset
- type ColumnPlan
- type DataEncoder
- type Derived
- type DerivedFold
- type DerivedReason
- type DictionaryPlan
- type DictionaryRequest
- type Document
- type Fingerprint
- type Float
- type Format
- type MRSet
- type MachineFloat
- type Missing
- type MissingCode
- type MissingMode
- type MissingRange
- type NameRename
- type Option
- type Payload
- type RawText
- type Reader
- func (r *Reader) Close() error
- func (r *Reader) PulseSchema() (*encoding.Schema, error)
- func (r *Reader) ReadHeader() ([]string, error)
- func (r *Reader) ReadRows(ctx context.Context, fn func(row []string) error) error
- func (r *Reader) Reset() error
- func (r *Reader) Warnings() []*errors.CodedError
- func (r *Reader) WriteSidecar(fs afero.Fs, cohortPath string) error
- type SegmentPlan
- type SidecarResolution
- type SidecarStatus
- type Source
- type VLSDeclaration
- type VLSLayout
- type VLSSegment
- type ValueEncoding
- type VarSet
- type Variable
- type Weight
- type Writer
- func (w *Writer) Bytes() []byte
- func (w *Writer) Close() error
- func (w *Writer) OverlayWarnings() []*errors.CodedError
- func (w *Writer) Renames() []NameRename
- func (w *Writer) SetOverlays(layers []*types.OverlayLayer)
- func (w *Writer) SetPulseSchema(s *encoding.Schema)
- func (w *Writer) ValidateCohort(ctx context.Context, src pio.CohortSource) ([]*errors.CodedError, error)
- func (w *Writer) Warnings() []*errors.CodedError
- func (w *Writer) WriteCohort(ctx context.Context, src pio.CohortSource) (int, error)
- func (w *Writer) WriteHeader(columns []string) error
- func (w *Writer) WriteRow(values []any) error
- type WriterOptions
Constants ¶
const ( // SourceByteOrderLittle is a little-endian source file. SourceByteOrderLittle = "little" // SourceByteOrderBig is a big-endian source file. SourceByteOrderBig = "big" )
The two spellings Source.ByteOrder takes. They are named because both the import that writes them and the export that reads them have to agree on the spelling, and a bare string literal in two files is one typo away from a silently unswapped missing-value slot.
const ( // MRSetKindDichotomy is N binary indicator variables plus one // counted value. This is the flavour that derives a `set_*` column; // see mrset.go. MRSetKindDichotomy = "dichotomy" // MRSetKindCategory is N variables each holding a code from a // shared value-label set. It derives NOTHING: it is positional, // permits duplicates and has no counted value, so it is genuinely N // categorical columns and not a set. Only its definition rides this // document. MRSetKindCategory = "category" )
Multiple-response set kinds. They are the JSON discriminant standing in for the two Go types the parser keeps apart, and they are named constants for the same reason the derived-column kinds are: a consumer branching on the flavour of a set is deciding whether the members are indicator variables or positional category slots, and a mistyped string literal at that fork is a silent misread.
const DerivedKindMultipleDichotomy = "multiple_dichotomy"
DerivedKindMultipleDichotomy is the sidecar Derived Kind of a multiple-dichotomy `set_*` convenience column.
This package owns the derived-kind vocabulary and E4-S1 reserved the registry slot as additively extensible, so adding this value alongside DerivedKindNumericMissing leaves SidecarFormatVersion at 1. A consumer folding a cohort back to `.sav` matches on this string to know the column is synthesised, must NOT be emitted as an SPSS variable, and needs no reconstruction work at all — the constituents named in Derived.Sources are real columns that already carry every value the set was built from.
const DerivedKindNumericMissing = "numeric_missing"
DerivedKindNumericMissing is the sidecar Derived Kind of a numeric user-missing reason sibling.
This package owns the derived-kind vocabulary; E4-S1 reserved the registry slot and stated that entries may be extended additively with no SidecarFormatVersion bump. A consumer folding a cohort back to `.sav` matches on this string to know that the column is synthesised, must not be emitted as an SPSS variable, and instead supplies the missing CODE for every row of its single source variable.
const MissingSiblingSuffix = "_missing"
MissingSiblingSuffix is appended to a variable's Pulse field name to name its generated user-missing reason column. It is a fixed convention rather than an option: an export has to recognise a derived column without being told, and a configurable suffix would make that a guess.
const SidecarFormatVersion = 1
SidecarFormatVersion is the version of the Document shape. It is the document's own version and has nothing to do with encoding.FormatVersion (the `.pulse` byte format) or the `--json` envelope's format_version.
Additive slots do not bump it; a rename or a removal does.
const SidecarKind = "spss"
SidecarKind identifies the producing adapter, so a reader can reject a document written by something else before trusting its shape.
const SidecarSuffix = ".spss.json"
SidecarSuffix is appended to a cohort's path to form its SPSS metadata sidecar filename, following the imports.Sidecar convention: "data.pulse" + SidecarSuffix == "data.pulse.spss.json".
It is deliberately NOT imports.SidecarSuffix (".meta.json"). A managed import writes that file for the same cohort, so sharing the suffix would have one artefact overwrite the other; the two carry unrelated documents and both must survive.
const SysmisReason = "sysmis"
SysmisReason is the reason text a sibling carries for a system-missing datum. It always occupies dictionary ID 0 of a sibling column.
Variables ¶
This section is empty.
Functions ¶
func DerivedKinds ¶
func DerivedKinds() []string
DerivedKinds returns the complete, closed vocabulary of Derived.Kind values in a stable order. The returned slice is freshly allocated.
func SidecarPath ¶
SidecarPath derives the deterministic sidecar path for a cohort. Pure, no filesystem access — the mirror of encoding.SidecarIndexPath.
Types ¶
type Case ¶
type Case []CaseValue
Case is one cohort record: one CaseValue per schema field, indexed by field index. It is index-keyed rather than name-keyed because ColumnPlan.Field is an index, so the encoder resolves a value with a slice load and never a map lookup on a per-case path.
type CaseValue ¶
type CaseValue struct {
// Num is the numeric reading of the field:
//
// - u4 / u8 / u16 / u32 / u64 — the integer.
// - f32 / f64 — the float, widened exactly.
// - decimal128 — the value scaled by the field's Scale.
// - packed_bool — 0 or 1.
// - date — whole epoch DAYS, read unsigned, matching io/export.go.
// - datetime — whole epoch SECONDS, read SIGNED, so an instant before
// 1970 stays before 1970 (the on-wire u64 is two's complement, which
// is exactly what encoding.FormatDateTime reinterprets).
// - categorical_* — the dictionary ID.
// - set_* — the mask, echoed as a float; see Mask.
Num float64
// Mask is a set_* column's bitmask at full width. Num echoes it, but a
// set_u64 mask above 2^53 does not survive the float, and bit 63 of a
// selection is data.
Mask uint64
// Null reports that the cohort's null bitmap marks this field missing for
// this record. It is authoritative: Num and Mask are zero when it is set,
// and carry no information.
Null bool
}
CaseValue is one cohort field's value in the raw form the encoder consumes: storage as the `.pulse` record holds it, with no rendering applied.
It is raw rather than text because every mapping the encoder performs is defined on the stored value. A categorical's Num is its DICTIONARY ID, which is what indexes ColumnPlan.Categories to reach the SPSS code the file must carry; a rendered "1" would have to be looked up again, and a rendered label could not be looked up at all.
type Category ¶
type Category struct {
// ID is the Pulse dictionary ID: the entry's position, and the
// value stored on the wire. Two entries MAY share an ID, when two
// distinct source values collapsed to one dictionary text; that is
// represented rather than flattened so the ambiguity stays visible.
ID uint32 `json:"id"`
// Value is the dictionary entry text at position ID — the SPSS
// value's canonical rendering, never the label.
Value string `json:"value"`
// Numeric reports whether the source variable is numeric, and hence
// which of Code and Text carries the SPSS value.
Numeric bool `json:"numeric"`
// Code is the SPSS numeric value. Non-nil only when Numeric.
Code *Float `json:"code,omitempty"`
// Text is the SPSS string value with its declared-width padding
// removed. Non-nil only when not Numeric. It can differ from Value,
// which is additionally trimmed of leading space.
Text *string `json:"text,omitempty"`
// Label is the record type 3 (or 7/21) value label.
Label string `json:"label,omitempty"`
// Labelled reports whether the file DECLARED a label for this
// value. False for an entry appended from the data section — an
// unlabelled code is perfectly legal SPSS.
Labelled bool `json:"labelled"`
// Observed reports whether at least one case carried this value.
// False for a declared label nothing used, which still occupies its
// ID so the file's own code ordering survives.
Observed bool `json:"observed"`
// Missing reports that this entry's SPSS value is one of the
// variable's USER-MISSING codes.
//
// It is the categorical arm of the missing-value mapping, and the
// asymmetry with the numeric arm is deliberate. A numeric variable
// gets a `<var>_missing` sibling because f64 has no dictionary and
// the null bitmap is one bit — there is nowhere else for the reason
// to live. A categorical one already stores the code as a dictionary
// entry, so the information is present and losslessly addressable;
// what it lacks is a way to say WHICH entry is a missing code, and
// that is exactly this flag. Duplicating the code into a sibling
// would be pure redundancy and would double the schema width of an
// all-categorical survey file.
//
// It is `omitempty`, so a document over a file that declares no
// categorical user-missing codes is byte-identical to the shape
// before the flag existed. SidecarFormatVersion is unchanged.
//
// Two entries sharing an ID (a value collision) are flagged
// independently: the flag describes the SOURCE value, and one of a
// collided pair may be missing-coded while the other is not.
Missing bool `json:"missing,omitempty"`
}
Category is one dictionary entry's provenance: the code <-> label <-> Pulse dictionary ID triple.
Code and Text are pointers because exactly one of them is meaningful and which one depends on Numeric. A consumer reading the wrong one gets nil rather than a plausible zero.
type CategoryCode ¶
type CategoryCode struct {
// Code is the SPSS numeric value, meaningful when the variable is
// numeric.
Code float64
// Text is the SPSS string value, meaningful when the variable is a
// string. It is UTF-8, as every string in a Pulse cohort is.
Text string
// Encoded is Text in the EMITTED FILE's charset — the bytes the data
// encoder lays into a case, and the bytes the variable's declared width
// is measured in.
//
// It is precomputed rather than encoded per case for two reasons, and
// only one of them is speed. A dictionary holds every value the column
// can carry, so encoding it once at plan time means an unencodable
// character stops the export before a single byte of the file is
// written, instead of part way through the data section. Nil for an
// empty value and for a numeric variable.
Encoded []byte
// Known reports that the value came from a sidecar's recorded triple
// rather than from the cohort dictionary text. A synthesised plan sets
// it false for every entry, which is the writer saying in the plan
// itself that it did not invent codes.
Known bool
// Ambiguous marks an ID two distinct SOURCE values collapsed onto — the
// PULSE_SPSS_VALUE_COLLISION case. The first is what this entry
// carries; the collision is flagged rather than hidden, because
// re-emitting either one is a guess about which row meant which.
Ambiguous bool
}
CategoryCode is one cohort dictionary ID's SPSS value: the other half of the code / label / ID triple, projected into the form a data encoder indexes by ID.
type Charset ¶
type Charset struct {
// DeclaredName is the record 7/20 name exactly as spelled, "" when
// the file carries no 7/20.
DeclaredName string `json:"declared_name,omitempty"`
// DeclaredCode is the record 7/3 character_code, 0 when absent.
DeclaredCode int32 `json:"declared_code,omitempty"`
// Declared reports whether the file said anything at all. A file
// that did not is being read by assumption, and the write path must
// know the difference — which "" alone cannot tell it, since a
// declaration could in principle be blank.
Declared bool `json:"declared"`
// Overridden reports that a caller's WithCharset replaced the
// declaration for DECODING. The declaration above is untouched.
Overridden bool `json:"overridden"`
// ResolvedName is the canonical name actually decoded with.
ResolvedName string `json:"resolved_name"`
}
Charset is the file's character-encoding declaration.
DeclaredName is the file's OWN spelling and is what a write path re-encodes against. ResolvedName is what this reader decoded with, recorded so a mismatch is visible, never so it can be substituted.
type ColumnPlan ¶
type ColumnPlan struct {
// Name is the variable's FINAL name: the record 7/13 long name where
// one exists, else the record type 2 short name.
//
// It is UTF-8, unlike the wire form of the same name that goes into
// the file. Every other string on this struct describes bytes; this
// one is what a diagnostic quotes and what a later pass matches a
// cohort column against, and neither of those wants codepage bytes.
//
// This is the name records 7/21 and 7/22 carry. ReadStat — the C reader
// behind haven, pyreadstat and most of the ecosystem — REFUSES a file
// whose 7/21 or 7/22 entry names a variable by its short name when a
// long name exists, so emitting anything else here produces files most
// tools reject.
Name string
// ShortName is the 8-byte record type 2 name, as it was WRITTEN —
// encoded in the emitted file's charset. Records 7/5, 7/7, 7/14 and
// 7/19 key by it, and they key by the bytes.
//
// In practice it is 7-bit either way: a synthesised short name is
// folded to ASCII by the name minter and a source's own is ASCII by
// the format's rules, so the encode is an identity. It is documented
// as bytes because that is what it is, not because it usually differs.
ShortName string
// Index is the 1-based dictionary ELEMENT index of the variable's first
// element — what the header weight_index, the record type 4 bindings
// and every other index-bearing field count.
Index int32
// Elements is how many 8-byte elements the variable occupies across all
// of its physical segments.
Elements int
// Width is 0 for a numeric variable, else the LOGICAL declared byte
// width — the total across the physical segments of a very long string,
// not the 255 a single segment declares.
Width int
// Segments are the physical record type 2 variables, head first. Always
// at least one; more than one only for a string wider than 255 bytes.
Segments []SegmentPlan
// PrintFormat and WriteFormat are the emitted output formats.
//
// For a MOYR (28) / QYR (29) / WKYR (30) column these are the SOURCE's
// codes, re-emitted unchanged over an unchanged raw-seconds value. That
// is the routed export-side answer to E2-S6's deferral: those formats
// are date-valued in SPSS but not day-resolution, so the import kept
// them as f64 raw seconds rather than truncating to a `date`. Nothing
// was converted on the way in, so nothing needs converting on the way
// out — and the format code is the ONLY thing that makes those seconds
// render as "JAN 2024" instead of 1.4e10. Downgrading them to F would
// lose the meaning of a column whose values are otherwise perfect.
PrintFormat Format
WriteFormat Format
// Encoding is how a cohort value becomes this variable's elements.
Encoding ValueEncoding
// Field is the index into DictionaryRequest.Schema.Fields of the cohort
// column this variable is written from, or -1 when nothing backs it.
Field int
// FieldName is the cohort field's name, kept beside Field so a
// diagnostic does not have to reach back into the schema.
FieldName string
// FieldType is the cohort field's `.pulse` type.
FieldType encoding.FieldType
// Categories maps cohort dictionary ID to SPSS value, for
// EncodeCategoricalCode and EncodeText. Indexed by ID; an ID past the
// end has no recorded value.
Categories []CategoryCode
// SetBit is the source `set_*` mask bit this indicator variable stands
// for, or -1 when the variable is not a set member.
SetBit int
// CountedValue is the numeric value meaning "selected", for
// EncodeSetMember.
CountedValue float64
// MissingField is the cohort field index of the `<var>_missing` reason
// sibling this variable folds back from, or -1 when it has none.
//
// It is the E5-S5 fold: the sibling emits no SPSS variable of its own,
// and what it does instead is decide what THIS variable writes wherever
// the cohort holds a null — the recorded user-missing code rather than
// the system-missing sentinel. Only a plain numeric variable can carry
// one; a categorical's missing codes never left its dictionary.
MissingField int
// MissingCodes maps that sibling's dictionary ID to the SPSS value to
// write, indexed by ID. Nil when MissingField is -1.
//
// It comes from the sidecar's recorded [Derived.Reasons] and is never
// re-derived from the missing specification plus the value labels: a
// second derivation that disagreed with the import's would produce a
// file that looks authoritative and names the wrong missing state.
MissingCodes []MissingCode
}
ColumnPlan is one emitted SPSS variable, bound to the cohort field it is written from.
One cohort field can produce SEVERAL variables — a `set_*` column becomes one indicator variable per member — and a variable can be backed by no field at all in principle, so this is not a per-field list.
type DataEncoder ¶
type DataEncoder struct {
// contains filtered or unexported fields
}
DataEncoder writes the data section of a `.sav`, one case at a time.
It is single-threaded state: one encoder, one file, cases in order.
func NewDataEncoder ¶
func NewDataEncoder(plan *DictionaryPlan, schema *encoding.Schema) (*DataEncoder, error)
NewDataEncoder prepares a data-section encoder for an emitted dictionary.
schema is the cohort schema the plan was built from; it is what a Case is indexed by, so a mismatch is caught here rather than as a wrong column.
func (*DataEncoder) Cases ¶
func (e *DataEncoder) Cases() int64
Cases is how many cases have been written so far.
func (*DataEncoder) Finish ¶
func (e *DataEncoder) Finish() ([]byte, error)
Finish closes the data section and patches the case count into the dictionary.
It returns the section's bytes; the complete file is plan.Bytes followed by them, in that order, and plan.Bytes has been mutated by the time this returns. Calling it twice is an error rather than a second flush.
func (*DataEncoder) WriteCase ¶
func (e *DataEncoder) WriteCase(c Case) error
WriteCase encodes one cohort record.
c is indexed by cohort schema field, and is not retained.
func (*DataEncoder) WriteCohort ¶
func (e *DataEncoder) WriteCohort(r io.Reader) error
WriteCohort encodes every record of a `.pulse` record stream.
r must be positioned at the first record — after encoding.ReadHeader and encoding.ReadSchema — and is read to EOF. A record that ends part way through is a truncated cohort and is reported rather than written as a short case.
type Derived ¶
type Derived struct {
// Name is the derived Pulse field name.
Name string `json:"name"`
// Kind says what derived it. E4-S2/S3/S4 own the vocabulary; the
// values this package emits are named constants —
// [DerivedKindNumericMissing] and [DerivedKindMultipleDichotomy].
Kind string `json:"kind"`
// SetName is the SPSS multiple-response set this column was derived
// from, INCLUDING its leading '$'. Non-empty only for
// [DerivedKindMultipleDichotomy].
//
// It is recorded rather than reconstructed from Name because the
// derivation drops the '$' — a leading sigil is not a legal
// expr-lang identifier, so a field named "$media" would be
// unreachable from ATTR_FORMULA — and because this is the key into
// the payload's MultipleResponseSets block, where the counted value
// and the set label live.
//
// Additive and `omitempty`: a document over a file with no derivable
// response set is byte-identical to the shape before the slot
// existed, and SidecarFormatVersion is unchanged.
SetName string `json:"set_name,omitempty"`
// Sources names the source variables it was derived FROM, by Pulse
// field name.
//
// For [DerivedKindMultipleDichotomy] the ORDER is load-bearing: it is
// bit order, so bit i of the mask and dictionary entry i are
// Sources[i]. For [DerivedKindNumericMissing] it is the single source
// variable.
Sources []string `json:"sources,omitempty"`
// Position is the 0-based column position in the cohort. Derived
// columns are INTERLEAVED with the real ones — a `<var>_missing`
// sibling sits immediately after the variable it belongs to — so
// neither this slice nor Variables can be read as a contiguous run,
// and a consumer reconstructing the source's own column order needs
// both sorted together.
Position int `json:"position"`
// Reasons is the derived column's dictionary, one entry per ID, for
// a `<var>_missing` sibling. It is what makes the column FOLDABLE:
// an export re-emitting the source variable reads the sibling's ID,
// finds the entry, and writes back the system-missing sentinel or
// the original SPSS code. Without it the fold would have to
// re-derive the mapping from the source variable's missing
// specification and value labels, which is exactly the guess this
// document exists to remove.
//
// Empty for a derived kind that is not a reason column.
Reasons []DerivedReason `json:"reasons,omitempty"`
}
Derived is one column this import synthesised rather than read.
Reserved by E4-S1 and populated by E4-S2 / E4-S3 (the `<var>_missing` siblings) and E4-S4 (the multiple-dichotomy `set_*` convenience columns). An export drops derived columns and reconstructs the source from the real ones, so it needs to know which is which without pattern-matching on names.
The two kinds need different amounts of help, and the difference is which of them is the only home for what it holds. A `<var>_missing` sibling is: its Reasons dictionary is the sole record of which SPSS state each ID stands for, so the fold reads it. A multiple-dichotomy set column is not: every value it shows is already in the cohort under its constituents' own names, so the fold simply drops it, and Sources plus SetName are enough to say what it was.
func (Derived) Complete ¶
Complete reports whether the entry carries everything its fold needs, so a consumer can reject an under-populated registry entry BEFORE it has written half an output file rather than discovering the gap mid-fold.
It is a shape check over the document, not a check against a cohort: it says the entry is self-sufficient, not that the columns it names exist.
func (Derived) Fold ¶
func (d Derived) Fold() (DerivedFold, bool)
Fold reports this entry's fold action, or false when its Kind is outside the vocabulary this binary knows.
type DerivedFold ¶
type DerivedFold int
DerivedFold is what an export does with a derived column: the single mechanical action its Derived.Kind prescribes.
It is a typed value rather than a string comparison at each call site so an unhandled kind is a compiler-visible switch arm instead of a silently skipped column.
const ( // DerivedFoldUnknown is the zero value and is never a legal fold. It is // what [DerivedFoldFor] returns alongside `false` for a kind this binary // does not recognise — a column whose fold-back is unknown, which an // export must refuse rather than guess at. DerivedFoldUnknown DerivedFold = iota // DerivedFoldDrop: emit no SPSS variable for this column and take // nothing from it. Everything it held is already carried by the columns // named in [Derived.Sources], which are real variables the export writes // normally. DerivedFoldDrop // DerivedFoldRestore: emit no SPSS variable for this column either, but // CONSUME it — its per-row value decides what the single variable named // in [Derived.Sources] writes wherever that variable is null. The mapping // from stored value to SPSS state is [Derived.Reasons] and is not to be // re-derived. DerivedFoldRestore )
func DerivedFoldFor ¶
func DerivedFoldFor(kind string) (DerivedFold, bool)
DerivedFoldFor reports the fold action a derived-column kind prescribes.
The second result is false for a kind this binary does not recognise — which is a document written by a newer import, not a corrupt one. A caller MUST treat that as a refusal: the column's fold-back is genuinely unknown, and both available guesses (emit it as a variable, or drop it) are silent data faults.
func (DerivedFold) String ¶
func (f DerivedFold) String() string
String renders the fold action for diagnostics.
type DerivedReason ¶
type DerivedReason struct {
// ID is the Pulse dictionary ID, which is the entry's position and
// the value stored on the wire.
ID uint32 `json:"id"`
// Reason is the dictionary entry text.
Reason string `json:"reason"`
// Sysmis marks the one entry standing for the system-missing
// sentinel rather than for a user-missing code. It always occupies
// ID 0. Code is meaningless when it is set.
Sysmis bool `json:"sysmis,omitempty"`
// Code is the SPSS numeric value this reason stands for, nil for
// the Sysmis entry.
Code *Float `json:"code,omitempty"`
// Label is the value label the file declared for Code, "" when it
// declared none. It is recorded even where Reason fell back to the
// numeric code because the label collided with another reason, so a
// consumer can still see what the file said.
Label string `json:"label,omitempty"`
// Declared reports that Code appears in the variable's
// missing-value SPECIFICATION as a discrete value. False for a
// value only a RANGE plus the data section put here: a range is not
// a finite vocabulary, so its members are collected as observed
// rather than enumerated.
Declared bool `json:"declared"`
// Observed reports that at least one case carried this state. False
// for a declared code nothing used, which still occupies its ID so
// the specification's own order survives.
Observed bool `json:"observed"`
}
DerivedReason is one entry of a `<var>_missing` sibling's dictionary: the reason text the cohort stores, and the SPSS state it stands for.
The empty reason — a value that is PRESENT — has no entry. It is the sibling's null bitmap bit: the shared import path reads an empty cell as null before it consults any dictionary, so an entry for it could never be referenced by a record.
type DictionaryPlan ¶
type DictionaryPlan struct {
// Bytes are the dictionary section: the file header through the record
// type 999 terminator, inclusive. The data section starts at
// len(Bytes).
Bytes []byte
// ByteOrder is the order every multi-byte field in Bytes was written
// in, and the order the data section must be written in.
//
// Always little-endian. Byte order carries no information about the
// DATA — the values are identical either way — so re-emitting a
// big-endian source's order would buy nothing and cost compatibility
// with the many tools that only ever meet little-endian files.
ByteOrder binary.ByteOrder
// Compression is the header compression flag that was written.
Compression int32
// Bias is the header compression bias that was written.
Bias float64
// Sysmis is the system-missing sentinel the data section must use.
//
// It is resolved by READING Bytes back, not by reporting what the
// front-end intended, and that is the contract: a `.sav` may declare its
// own sentinel in record 7/4, a conforming reader ADOPTS a coherent
// declaration (see applyMachineFloat), and a sidecar-driven dictionary
// re-emits the source's 7/4 verbatim. A plan that reported the spec
// default over a file declaring something else would hand a data encoder
// a value that reads back as ORDINARY DATA in every null it was written
// into. Deriving it from the emitted bytes makes the plan and any reader
// of the file agree by construction rather than by two implementations
// of one rule staying in step.
Sysmis float64
// ElementCount is the 8-byte elements per case: the header's
// nominal_case_size, and the stride the data section must produce.
ElementCount int32
// CaseCount is the case count that was written, -1 for unknown.
CaseCount int64
// CaseCountOffset is the byte offset within Bytes of the header's
// int32 ncases field, so a streaming encoder can patch it.
CaseCountOffset int
// CaseCount64Offset is the byte offset of the record 7/16 int64 case
// count, or -1 when no 7/16 was emitted (which is the case exactly when
// CaseCount is -1). Patching one and not the other leaves a file whose
// two case counts disagree.
CaseCount64Offset int
// Columns are the emitted variables in element order.
Columns []ColumnPlan
// UnboundFields lists the cohort schema field indices no emitted
// variable is written from, in schema order.
//
// For a sidecar-driven plan these are the DERIVED columns — the
// `<var>_missing` reason siblings and the multiple-dichotomy `set_*`
// convenience columns — which the document keeps in its own Derived
// registry rather than in Variables. E5-S5 cross-checks this list
// against that registry: a field that is unbound and NOT derived is a
// column about to be silently dropped, which is the one outcome the
// fold must refuse.
UnboundFields []int
// Status is the sidecar resolution the plan was built under.
Status SidecarStatus
// Synthesised reports that the dictionary was built from the `.pulse`
// schema alone.
Synthesised bool
// Renames are the variable names [WriterOptions.SanitizeNames]
// rewrote, in emission order. Nil when the flag is unset, when the
// plan came from a sidecar, and when every cohort name was already a
// legal SPSS name — so an export that renamed nothing is
// indistinguishable from one that could not have.
//
// The same set rides Warnings as a single
// PULSE_SPSS_NAME_SANITIZED. It is repeated here in typed form
// because a caller that wants to map emitted variables back to
// cohort fields should not have to parse it out of a details map.
Renames []NameRename
// Warnings are the diagnostics to surface, the sidecar resolution's own
// warning first when it raised one.
Warnings []*errors.CodedError
}
DictionaryPlan is the emitted dictionary section plus the state the data section encoder needs. It is the E5-S2 / E5-S3 seam.
func BuildDictionary ¶
func BuildDictionary(req DictionaryRequest) (*DictionaryPlan, error)
BuildDictionary emits the `.sav` dictionary section for a cohort.
It branches once, on SidecarResolution.Synthesise: recorded source metadata, or a default synthesised from the `.pulse` schema. Both branches converge on the same record emitter.
func (*DictionaryPlan) SetCaseCount ¶
func (p *DictionaryPlan) SetCaseCount(n int64) error
SetCaseCount patches the case count into an already-emitted dictionary.
A `.sav` states its case count twice — the header's int32 ncases field and the record 7/16 int64 — so this writes BOTH or neither. There is deliberately no way to write one: a file whose two counts disagree is one no reader can adjudicate, and the whole reason a writer patches at all is that it did not know the number when it emitted the header.
The 7/16 record is present only when BuildDictionary was given a non-negative Cases, because a record whose whole content is a case count cannot be emitted "to be filled in later" without also declaring a count. So a plan built with Cases: -1 carries the header field alone, and a count past the int32 ceiling lands there as -1 — the format's "unknown", which every reader answers by counting the cases it finds. That is honest, and it is the only shape available; a wrapped int32 would be a plausible wrong answer.
type DictionaryRequest ¶
type DictionaryRequest struct {
// Schema is the `.pulse` cohort schema, as delivered through
// pio.SchemaAwareWriter.SetPulseSchema. Required.
Schema *encoding.Schema
// Sidecar is [LoadSidecar]'s resolution. A nil resolution, or one whose
// [SidecarResolution.Synthesise] reports true, selects the synthesised
// default dictionary.
//
// Staleness is NOT re-checked here. LoadSidecar returns a resolution
// only when the export may proceed, so a resolution in hand is already
// the permission to use it.
Sidecar *SidecarResolution
// Cases is the number of cases the data section will carry, or -1 when
// the writer does not yet know.
//
// -1 is a legal header value meaning "unknown" and every reader handles
// it, so a streaming encoder may emit the dictionary first and either
// leave it at -1 or patch [DictionaryPlan.CaseCountOffset] (and
// [DictionaryPlan.CaseCount64Offset]) once the last case is written.
Cases int64
// Compression is the header compression flag the data section will be
// written under: 0 for uncompressed, 1 for SPSS bytecode. ZSAV (2) is
// rejected — emission of it is out of scope for this effort.
Compression int32
// Options are the caller's knobs.
Options WriterOptions
}
DictionaryRequest is everything BuildDictionary needs.
It is NOT a second knob bag beside WriterOptions and must not grow into one. WriterOptions holds what a CALLER decides — E5-S6's CLI flags map onto it one for one. This struct holds what the WRITER already knows by the time it emits a dictionary: the cohort's schema, the sidecar resolution E5-S1 produced, and the two facts about the data section that only the data encoder can supply. Caller knobs ride Options.
type Document ¶
type Document struct {
// FormatVersion is SidecarFormatVersion at write time.
FormatVersion int `json:"format_version"`
// Kind is SidecarKind.
Kind string `json:"kind"`
// Fingerprint binds the document to the cohort it describes.
Fingerprint Fingerprint `json:"fingerprint"`
// Payload is the self-contained metadata.
Payload Payload `json:"payload"`
}
Document is the whole sidecar file.
Only Payload is liftable into a future `.pulse` schema metadata block; FormatVersion, Kind and Fingerprint are the envelope that makes it a standalone file. See the package-level commentary above.
func (*Document) Normalise ¶
Normalise fills in what an OLDER document left implicit, so every consumer sees one shape regardless of which Pulse wrote the file.
Today that is exactly one slot. MRSet.Fields — the short-name -> Pulse-field-name projection of a response set's member list — was added additively AFTER the document shape shipped, under `omitempty`, and SidecarFormatVersion did not move for it because nothing was renamed or removed. So the version cannot tell an older document from a newer one, and an ABSENT `fields` key means "this document predates the slot", never "this set has no members". Treating it as an error would reject every sidecar written before the slot existed; the correct reading is to resolve the short names here, exactly as the writer would have.
A `fields` array that is PRESENT but not the same length as `variables` is a different matter and IS rejected. The two are a parallel array whose entire contract is that they are index-for-index — an MC set may legitimately name one variable twice, so a consumer cannot re-derive the pairing by matching — and silently repairing a broken one would hand the caller a member list bound to the wrong columns. That is precisely the silent misread the slot was added to prevent.
Normalise is idempotent: a document that needs nothing is untouched, and running it twice changes nothing the first run did not.
func (*Document) VerifyDigest ¶
VerifyDigest recomputes the cohort's full SHA-256 and compares it against the one this document recorded.
It is the authoritative freshness answer, and the mirror of what `pulse index verify` does for the sidecar point-lookup index: the read path takes the cheap O(1) size+mtime pair on every call, and this is available for a verify-style pass that can afford to read the whole cohort. It closes the one gap the cheap check has — an in-place edit that preserves both size and mtime — at the cost of a full read, which is exactly why it is not on the export path.
A mismatch is PULSE_SPSS_SIDECAR_STALE, the same verdict the cheap check returns, because it is the same fact discovered more expensively.
type Fingerprint ¶
type Fingerprint struct {
// SHA256 is the 32-byte SHA-256 digest of the cohort's full bytes,
// hex-encoded to 64 characters. Decode with [Fingerprint.Digest].
SHA256 string `json:"sha256"`
// SourceSize is the cohort's byte length at write time.
SourceSize uint64 `json:"source_size"`
// SourceModTime is the cohort's modification time at write time, as
// Unix nanoseconds.
SourceModTime int64 `json:"source_mod_time"`
}
Fingerprint is the staleness block, modelled directly on encoding.Index: a 32-byte SHA-256 plus a size and a modification time cheap enough to check on every read.
It fingerprints the `.pulse` COHORT, not the source `.sav`. The cohort is what the document describes and what a consumer holds; the `.sav` may be long gone, and re-importing it would produce a new cohort with its own fingerprint anyway.
The read path (E5-S1) compares SourceSize and SourceModTime, an O(1) stat; SHA256 is the authoritative recompute for a verify pass. A stale sidecar is an ERROR, not a warning — applying a stale dictionary to changed data yields a file that looks authoritative and is wrong. An ABSENT sidecar is only a warning: a cohort that was never SPSS-derived correctly has none.
func (Fingerprint) Digest ¶
func (f Fingerprint) Digest() (encoding.Fingerprint, bool)
Digest decodes SHA256 back into the 32-byte digest, reporting whether the field held a well-formed one.
type Float ¶
type Float float64
Float is a float64 that survives JSON.
encoding/json REFUSES to marshal NaN and +/-Inf, so a single pathological double anywhere in a dictionary would fail the whole sidecar write and, with it, an import that is otherwise perfectly good. Emitting them as the three JSON.parse-compatible-ish string tokens keeps the document writable and, crucially, keeps the failure out of the fidelity path: the authoritative record for a missing value is its raw eight-byte slot, and for a category it is the canonical Value text.
A non-finite value loses its NaN payload bits in this form. That is accepted: the raw slots carry the bits where the bits matter.
func (Float) MarshalJSON ¶
MarshalJSON emits a JSON number for a finite value and one of the three tokens otherwise.
func (*Float) UnmarshalJSON ¶
UnmarshalJSON accepts either form.
type Format ¶
type Format struct {
// Code is the SPSS format type code: 1 = A, 5 = F, 20 = DATE, ...
Code uint8 `json:"code"`
// Width is the field width in characters.
Width int `json:"width"`
// Decimals is the number of decimal places.
Decimals int `json:"decimals"`
}
Format is a print or write format specification, carried verbatim. It is what lets an export reconstruct the display format, and for a TIME / DTIME / precision-downcast column it is the only record of what the raw seconds mean.
type MRSet ¶
type MRSet struct {
// Name is the set name, including its leading '$'.
Name string `json:"name"`
// Kind is [MRSetKindDichotomy] or [MRSetKindCategory].
Kind string `json:"kind"`
// Label is the set label, possibly empty.
Label string `json:"label,omitempty"`
// Subtype is the extension subtype the definition came from: 5, 7
// or 19.
Subtype int32 `json:"subtype"`
// Variables names the member variables by SHORT name, in file
// order — the name the record itself carries, and what a write path
// re-emits verbatim.
//
// ORDER IS LOAD-BEARING and duplicates are NOT collapsed. For a
// multiple-category set the member order is the answer order — "first
// choice, second choice, third choice" — and two slots may
// legitimately hold the same code, which is exactly why an MC set is
// not a Pulse `set_*`: a bitmask is unordered and idempotent, so
// collapsing one would lose both facts.
Variables []string `json:"variables"`
// Fields resolves each entry of Variables to its PULSE FIELD NAME,
// index for index, so a consumer never has to re-derive the mapping
// the import already performed. An entry is "" for a member no
// record type 2 declares, which is the one case where the set names
// a variable this cohort has no column for.
//
// It exists because Variables holds SHORT names and the cohort holds
// long ones: matching them back up means a case-insensitive walk of
// Variables[].ShortName, and a set whose members were renamed,
// truncated or long-name-mapped is precisely where that walk goes
// wrong. Same slot-pair precedent as Variable.ShortName / .Name.
//
// Present whenever Variables is; len(Fields) == len(Variables)
// always, including the duplicate entries an MC set may carry.
// Additive and `omitempty` — a SidecarFormatVersion 1 document
// written before this slot existed simply has no `fields` key.
Fields []string `json:"fields,omitempty"`
// CountedValue is the value meaning "selected", held verbatim as
// the text the record carried; the wire form does not say whether
// it is a number or a string. Non-nil only for a dichotomy set.
CountedValue *string `json:"counted_value,omitempty"`
// LabelFromVariableLabel is the subtype 19 'E' form's "11" label
// source: the label comes from the first member's variable label.
LabelFromVariableLabel bool `json:"label_from_variable_label,omitempty"`
// Extended records the 'E' type code, which only subtype 19 writes.
Extended bool `json:"extended,omitempty"`
}
MRSet is one multiple-response set definition.
It is the write-back record for EVERY set the file declared, whether or not the set produced a derived column: a multiple-CATEGORY set derives nothing by design, and a multiple-dichotomy one may have refused to derive (PULSE_SPSS_MR_SET_NOT_DERIVED). Both must still be re-emitted as record 7/5, 7/7 or 7/19 definitions, so nothing here is consumed by the derived-column registry — see derived.go.
Kind is the JSON discriminant standing in for the two Go types the parser keeps apart, and CountedValue is a POINTER so a consumer that reads it without checking Kind gets nil rather than a meaningless empty string: a multiple-category set has no counted value at all.
type MachineFloat ¶
type MachineFloat struct {
Sysmis Float `json:"sysmis"`
Highest Float `json:"highest"`
Lowest Float `json:"lowest"`
}
MachineFloat is the record 7/4 payload.
type Missing ¶
type Missing struct {
// Code is the raw record type 2 n_missing_values field.
Code int32 `json:"code"`
// Kind is the shape discriminant, per the table above.
Kind string `json:"kind"`
// Range is the lo..hi bound, non-nil for "range" and
// "range_plus_discrete".
Range *MissingRange `json:"range,omitempty"`
// Discrete are the discrete numeric missing codes, in file order.
// For "range_plus_discrete" there is exactly one.
Discrete []Float `json:"discrete,omitempty"`
// DiscreteText are the discrete STRING missing values, trimmed to
// the declared width exactly as a datum of the same variable is.
DiscreteText []string `json:"discrete_text,omitempty"`
// Raw are the abs(Code) eight-byte slots verbatim, base64-encoded
// by encoding/json. Authoritative: the decoded fields above are a
// projection of these bytes, never a replacement.
Raw [][]byte `json:"raw"`
}
Missing is a variable's missing-value specification, in whichever of the three shapes the file used.
All three are representable and the raw slots are kept besides, so a shape this reader interprets narrowly has still lost nothing:
Kind Code Contents -------------------- ----- --------------------------------------- "discrete" 1..3 Discrete (numeric) or DiscreteText "range" -2 Range "range_plus_discrete" -3 Range plus exactly one discrete value
Negative codes are numeric-only; the format has no range form for strings. A record 7/22 long-string specification lands here too, as a discrete DiscreteText list — it means the same thing, and exists only because a record type 2 cannot carry a missing value for a string wider than eight bytes.
type MissingCode ¶
type MissingCode struct {
// Known reports that the ID has a recorded reason at all. A false entry
// is an ID the registry never described, which the encoder refuses
// rather than writing a value for.
Known bool
// Sysmis marks the reason standing for the system-missing sentinel
// rather than for a user-missing code. Code is meaningless when it is
// set.
Sysmis bool
// Code is the SPSS numeric value to write. It is the SOURCE's own
// double, in the source's own units: for a `date` or `datetime`
// variable that is raw SPSS seconds, not epoch days, because the import
// never converted a missing code and so the export must not either.
Code float64
// Reason is the dictionary entry text the cohort stores, carried for
// diagnostics only.
Reason string
}
MissingCode is what one dictionary ID of a `<var>_missing` reason sibling means for the variable it belongs to: the SPSS value to write in place of the cohort's null.
It is the projection of DerivedReason the data encoder indexes by ID, built once at plan time for the same reason CategoryCode is — a mapping resolved per case is a mapping that can be resolved differently per case.
type MissingMode ¶
type MissingMode int
MissingMode selects how an SPSS numeric variable's USER-missing values are represented in the cohort. It has no effect on system-missing, which is a null under every mode, nor on categorical columns, whose codes stay in the main dictionary.
const ( // MissingAuto is the default: the analytic column is null at every // missing position and a generated `<var>_missing` sibling carries // the reason. Lossless — both the arithmetic and the reason survive. MissingAuto MissingMode = iota // MissingNull suppresses the sibling columns. Every user-missing // value still becomes a null in the analytic column — the // arithmetic is the same under both modes — but the REASON is not // represented in the cohort at all. // // It exists for callers who genuinely want the slimmer schema and // have accepted that cost. The full missing-value specification // still rides the metadata sidecar either way, so a re-import can // recover the vocabulary even though this cohort cannot say which // row had which reason. MissingNull )
func ParseMissingMode ¶
func ParseMissingMode(s string) (MissingMode, error)
ParseMissingMode resolves the `--spss-missing` spelling of a mode.
An empty string is not an instruction and resolves to the default. An unrecognised one is PULSE_SPSS_MISSING_MODE_INVALID rather than a silent fall back to the default, because the two modes produce different schemas: substituting one for a typo of the other would hand the caller a cohort they did not ask for.
func (MissingMode) String ¶
func (m MissingMode) String() string
String returns the flag spelling of the mode.
type MissingRange ¶
MissingRange is the lo..hi half of a range specification.
type NameRename ¶
type NameRename struct {
// Field is the cohort field the variable is written from. Empty for a
// name no single column owns, which today is only a multiple-response
// set name.
Field string `json:"field"`
// From is the name the cohort asked for and a `.sav` cannot carry.
From string `json:"from"`
// Name is the legal SPSS name that was emitted instead.
Name string `json:"name"`
}
NameRename records one variable a WriterOptions.SanitizeNames export renamed: the cohort field it came from, and the SPSS name it went out as.
It is a pair rather than a map entry because the report is ORDERED — a caller reading it back wants schema order, which is the order the emitted file's variables are in.
type Option ¶
type Option func(*Reader)
Option configures a Reader at construction. The set is deliberately small: everything about a `.sav` that can be read from the file is read from the file, so an option exists only where the reader has to apply a judgement the format does not supply.
func WithCardinalityWarnFraction ¶
WithCardinalityWarnFraction sets the share of the case count a categorical column's distinct-value count must exceed before the mapping raises PULSE_SPSS_CARDINALITY_HIGH — the schema-bloat signal of a free-text variable. It defaults to 0.5, and any value above 1 disables the check.
The warning never blocks an import. Mapping a near-unique string variable to categorical_u32 is lossless; what it costs is a large inline dictionary block that every read of the cohort pays for, which is a performance concern and not a fidelity one.
The check is skipped entirely below a small case-count floor, because a distinct-count ratio over a handful of cases says nothing.
func WithCharset ¶
WithCharset overrides the character encoding the file declares.
It exists because the commonest reason a real `.sav` fails to decode is that the file is wrong about itself: a dictionary transcoded by one tool and re-saved by another keeps the old record 7/20 name, and a pre-Unicode file often declares nothing at all. Where the bytes and the declaration disagree, only the caller can say which is right, so the override is the documented answer to both PULSE_SPSS_CHARSET_UNSUPPORTED and PULSE_SPSS_CHARSET_INVALID.
The name is resolved by the same lookup the file's own declaration goes through, so "windows-1252", "cp1252" and "1252" are the same request; an unresolvable one is PULSE_SPSS_CHARSET_UNSUPPORTED naming it. An empty string is not an override and leaves the file's declaration in force.
It changes only DECODING. The file's own declaration is still retained verbatim, because the write path has to re-encode into the charset the source declared and not into the one a reader was told to read with.
func WithMissingMode ¶
func WithMissingMode(mode MissingMode) Option
WithMissingMode selects how a numeric variable's USER-missing values are represented in the cohort.
The default, MissingAuto, is the fidelity-preserving split: the analytic column is null at every missing position — so AGG_SUM and AGG_MEAN never add a refusal code — and a generated `<var>_missing` sibling carries WHY each value is missing, as "sysmis", the value label the file declared for that code, or the code itself.
MissingNull suppresses the siblings. The nulls are identical; what is lost is the reason, and with it the refused / don't-know / not-applicable distinction survey analysis reports separately and weights on. It exists for callers who want the slimmer schema and have accepted that; the full missing-value specification still rides the metadata sidecar either way.
Neither mode affects a CATEGORICAL column — a string variable or a value-labelled numeric — where the missing code is already a dictionary entry of its own and a sibling would be pure redundancy.
type Payload ¶
type Payload struct {
// Source is the file-header and geometry metadata.
Source Source `json:"source"`
// Charset is the file's own encoding declaration.
Charset Charset `json:"charset"`
// Weight is the weighting variable, nil for an unweighted file.
Weight *Weight `json:"weight,omitempty"`
// Documents are the record type 6 lines in file order, each still
// the full fixed-width 80-byte field. Untrimmed on purpose: which
// trailing spaces are padding and which are the author's is not
// knowable, and guessing would not round-trip.
Documents []string `json:"documents,omitempty"`
// ProductInfo is record 7/10, extra product identification.
ProductInfo *RawText `json:"product_info,omitempty"`
// FileAttributes is record 7/17: attributes of the FILE.
// Deliberately distinct from VariableAttributes.
FileAttributes *RawText `json:"file_attributes,omitempty"`
// VariableAttributes is record 7/18: attributes of VARIABLES.
// Deliberately distinct from FileAttributes.
VariableAttributes *RawText `json:"variable_attributes,omitempty"`
// MultipleResponseSets are the records 7/5, 7/7 and 7/19 set
// definitions. E4-S5 owns what is DONE with them; they are recorded
// here regardless, because an export has to write them back.
MultipleResponseSets []MRSet `json:"multiple_response_sets,omitempty"`
// VariableSets are the record 7/5 display groupings. They ride the
// same record as multiple-response sets and are NOT response sets;
// they have no Pulse home at all and are sidecar-only.
VariableSets []VarSet `json:"variable_sets,omitempty"`
// VeryLongStrings are the record 7/14 declarations: one
// (head short name, logical byte width) pair per very long string.
// The per-variable physical segmentation is on Variable.VeryLongString.
VeryLongStrings []VLSDeclaration `json:"very_long_strings,omitempty"`
// Variables are the columns in cohort order, one per Pulse field.
Variables []Variable `json:"variables"`
// Derived is the registry of columns this import SYNTHESISED rather
// than read: the `<var>_missing` reason siblings (kind
// [DerivedKindNumericMissing]) and the multiple-dichotomy `set_*`
// convenience columns (kind [DerivedKindMultipleDichotomy]). An
// export drops or consumes them and reconstructs the source from the
// real columns, so it needs to tell "column absent from Variables"
// from "column absent from the source" without pattern-matching on
// names. See derived.go for the closed kind vocabulary and the fold
// action each one prescribes.
//
// Derived columns are INTERLEAVED with the real ones, not appended:
// a reason sibling sits immediately after its source variable, and a
// set column immediately after the LAST of its constituents (a
// summary must not precede its parts). Position on each entry is what
// says where; constituents need not be contiguous.
//
// NOT `omitempty`, deliberately. An import that derived nothing
// writes an EMPTY ARRAY, because "this cohort has no derived columns"
// and "this document cannot tell me" are different answers and an
// absent key collapses them — the second is what an export must
// refuse on, and the first is the overwhelmingly common case it must
// proceed on.
//
// Additive: entries may gain fields without a SidecarFormatVersion
// bump, which is how Reasons arrived after the slot was reserved.
Derived []Derived `json:"derived"`
}
Payload is the liftable half: every SPSS dictionary element with no `.pulse` home, and nothing that depends on being a separate file.
type RawText ¶
type RawText struct {
// Subtype is the record type 7 subtype: 10, 17 or 18.
Subtype int32 `json:"subtype"`
// Raw is the payload bytes, base64-encoded by encoding/json.
Raw []byte `json:"raw"`
// Text is the decoded payload, omitted when undecodable.
Text string `json:"text,omitempty"`
}
RawText is a free-form extension payload captured verbatim.
Raw is authoritative and always present; Text is a convenience that is omitted whenever the bytes did not decode cleanly under the file's charset. A consumer re-emitting the record MUST use Raw.
type Reader ¶
type Reader struct {
// contains filtered or unexported fields
}
Reader reads an SPSS `.sav` system file.
The whole file is read into memory on first use, matching the other Pulse adapters: the dictionary is a linked walk of variable-length records, so there is no seek-friendly index to stream against, and the data section is addressed by an offset the dictionary walk produces.
func NewReaderFromBytes ¶
NewReaderFromBytes creates a `.sav` reader over raw bytes, for callers that already hold the file — tests included.
A nil or empty slice IS a source: it is a source of no bytes, and it reports PULSE_SPSS_FILE_EMPTY exactly as a zero-length file on disk does. Before E3-S5 it reported "no source configured", which described the reader rather than the input and was not a coded error a caller could switch on.
func (*Reader) Close ¶
Close releases the reader's buffers.
It is idempotent: every field it clears is already cleared on a second call, so calling it twice — which a deferred Close plus an explicit one in the happy path routinely does — is a no-op rather than a fault.
A reader built by NewReader can be used again after Close, because init re-reads the file. One built by NewReaderFromBytes cannot: its only copy of the bytes was the buffer Close just dropped, and a subsequent read reports that there is no data source. That matches the csv adapter, whose Close is likewise the end of a byte-backed reader's life.
func (*Reader) PulseSchema ¶
PulseSchema returns the authoritative .pulse schema for the file, satisfying pio.SchemaAwareReader: a `.sav` carries a dictionary that DECLARES each column's type, so the shared import path skips inference entirely rather than sampling rows and voting on what it sees.
Every obligation the interface names is met here. Types come from the SPSS declared type plus its print format code, never from cell text. Nullability is a fact, not a sample — the mapping scans every case, so a field is nullable exactly when some case carries a value the import path reads as null, and no out-of-sample promotion can be needed. CsvColumnIdx is the variable's position in the row ReadRows yields, and every dictionary-bearing field arrives with its dictionary PRE-SEEDED in the file's own order, because entry order is the on-wire encoding and handing over the source's ordering is what preserves the source codes.
A fresh schema, with a fresh dictionary per categorical column, is returned on every call: encoding.Dictionary is mutable and the import path appends to it, so a shared instance would let one import's values leak into another's IDs.
It never declines. A `.sav` always has a dictionary, so there is no (nil, nil) case; a file whose dictionary or data section cannot be read returns the coded error, which fails the import rather than silently falling back to inference.
func (*Reader) ReadHeader ¶
ReadHeader returns the cohort's column names, in file order.
The name is the record 7/13 long name where the file declares one and the 8-byte short name otherwise — see variable.fieldName. String continuation records are not variables and contribute no column.
It is NOT one name per SPSS variable. A numeric variable declaring user-missing values contributes a second, GENERATED column immediately after its own — `<var>_missing`, carrying why each value is missing — unless the reader was built with spss.WithMissingMode(spss.MissingNull). See missing.go. A multiple-DICHOTOMY response set contributes one more, a `set_*` convenience column named after the set (without its leading '$') placed after the last of its constituents — which are all still here, because that column is additive. See mrset.go.
The set-planning warnings this call raises are discarded: they are memoised with the mapping, which raises the same ones, and Warnings reads them from there. A header read is not where a diagnostic about the mapping should first appear, and appending them here would double them for any caller that reads the header and then the rows.
func (*Reader) ReadRows ¶
ReadRows streams the data section, calling fn once per case.
The row slice handed to fn is REUSED between cases, matching the io/parquet adapter: a callback that needs to keep a row past its call must copy it. The strings themselves are immutable and safe to retain.
ctx is checked before every case, so cancellation is observed within one case rather than at the end of the file. Returning pio.ErrStopIteration from fn ends the pass without an error, which is how the inference sampler stops after its sample window.
func (*Reader) Reset ¶
Reset rewinds the reader to the start of the data section.
The dictionary parse is deliberately kept: it is a pure function of bytes that have not changed, and re-walking it would make an infer-then-import sequence pay for the dictionary twice. What Reset drops is the derived per-pass state, so the next ReadRows starts from the first case with a clean diagnostics slate.
func (*Reader) Warnings ¶
func (r *Reader) Warnings() []*errors.CodedError
Warnings returns the non-fatal diagnostics raised so far, in the order they became knowable: the dictionary parse's own (unrecognised or malformed record type 7 extension subtypes), then the schema mapping's (a widened or downcast temporal column, a near-unique categorical, a value collision, a measurement level that disagrees with the mapped type), then the data section's (a case count disagreeing with the file's declaration).
The first two channels are memoised with the parse and the mapping, and the third is rebuilt per pass, so nothing accumulates across repeated reads.
It is a pure accessor and never triggers a parse: called before ReadHeader or ReadRows it returns nothing, because nothing has been read. The returned slice is freshly allocated, so a caller may retain it across a Reset that clears the reader's own.
func (*Reader) WriteSidecar ¶
WriteSidecar builds the metadata sidecar for cohortPath and writes it to SidecarPath(cohortPath), satisfying pio.SidecarEmitter.
It is called by ImportJob.Run AFTER the cohort has been written, which is not incidental: the fingerprint describes the cohort's bytes, so those bytes have to exist first.
A failure here fails the import. The cohort write on the same filesystem has just succeeded, so a sidecar write that then fails is a genuine fault rather than an expected condition — and a cohort silently missing the only record of its value codes is precisely the quiet fidelity loss this document exists to prevent. "Absent sidecar is only a warning" is a READ-path rule about cohorts that never had one; it is not a licence to fail to write one.
type SegmentPlan ¶
type SegmentPlan struct {
// Name is the segment's own 8-byte short name.
Name string
// Width is the segment's DECLARED byte width.
Width int
// Content is how many bytes of the LOGICAL value the segment carries.
// It is not Width: a non-final very-long-string segment declares 255
// and carries 252.
Content int
// Elements is how many 8-byte elements the segment occupies.
Elements int
// Index is the 1-based dictionary element index of the segment's first
// element.
Index int32
}
SegmentPlan is one PHYSICAL record type 2 variable.
type SidecarResolution ¶
type SidecarResolution struct {
// Cohort is the `.pulse` path the resolution is about.
Cohort string
// Path is the sidecar path that was consulted, derived by
// [SidecarPath]. Reported for every status, including
// SidecarStatusAbsent, where it is the path that was looked for.
Path string
// Status is the terminal state.
Status SidecarStatus
// Document is the validated, normalised sidecar. Non-nil if and
// only if Status is SidecarStatusPresent — see [SidecarResolution.Synthesise].
Document *Document
// Warning is the coded diagnostic this resolution raised, nil for
// SidecarStatusPresent. It is a *errors.CodedError so it can be
// appended straight onto the warning slice the io/ reports carry
// (ImportReport.SourceWarnings and its siblings), which is the
// established channel for a non-fatal PULSE_SPSS_* diagnostic.
Warning *errors.CodedError
}
SidecarResolution is what LoadSidecar decided, and the whole of what a write path needs in order to know which dictionary to build.
It is returned only when the export may proceed. A refusal — a stale or invalid sidecar with WriterOptions.IgnoreSidecar unset — is an ERROR return and no resolution at all, so there is no shape in which a caller can hold a resolution that says "do not export" and act on it by accident.
func LoadSidecar ¶
func LoadSidecar(fsys afero.Fs, cohortPath string, opts WriterOptions) (*SidecarResolution, error)
LoadSidecar resolves the metadata sidecar for cohortPath.
It is the entry point for the SPSS write side and the only supported way to obtain a Document from disk. The order of its checks is the contract:
- IgnoreSidecar set and a sidecar present -> SidecarStatusIgnored, warning, no read. Checked before the read so an unreadable file cannot block a caller who has opted out.
- No file at the sidecar path -> SidecarStatusAbsent, warning.
- Unreadable, not JSON, foreign `kind`, unknown `format_version`, malformed fingerprint, or a broken parallel-array contract -> PULSE_SPSS_SIDECAR_INVALID.
- Cohort size or mtime moved since the sidecar was written -> PULSE_SPSS_SIDECAR_STALE.
- Otherwise SidecarStatusPresent, with the document normalised (see Document.Normalise).
Steps 3 and 4 are errors, not warnings, and there is no path on which a stale or unreadable document is applied anyway. IgnoreSidecar downgrades both to a SidecarStatusIgnored warning — but it reaches that decision at step 1, by NOT reading the file, so the downgrade is a property of the flag rather than of the failure, and the warning cannot say which refusal (if any) it silenced.
The cohort itself must exist: staleness is a comparison and there is nothing to compare a sidecar against otherwise. A missing cohort is a DATA_FILE error, not a sidecar diagnostic.
func (*SidecarResolution) Synthesise ¶
func (r *SidecarResolution) Synthesise() bool
Synthesise reports whether the caller must build a default SPSS dictionary from the `.pulse` schema, because no trusted source metadata is available.
It is the single question a write path asks, and it is a method rather than a `Document == nil` check at each call site so the "absent or ignored, same next step" rule is stated once. A nil receiver answers true: no resolution is not a licence to invent one.
type SidecarStatus ¶
type SidecarStatus int
SidecarStatus is which of the four terminal states a sidecar resolution reached. It is a typed value rather than a bool pair so a consumer switching on it gets a compiler-visible arm per state.
const ( // SidecarStatusUnknown is the zero value and is never a resolution // this package returns. It exists so a zero-valued struct cannot be // mistaken for a decided one. SidecarStatusUnknown SidecarStatus = iota // SidecarStatusPresent: a sidecar was found, read, validated and // confirmed to describe this cohort's current bytes. Document is // non-nil and is the authoritative source metadata. SidecarStatusPresent // SidecarStatusAbsent: no file exists at the sidecar's path. The // normal state for a cohort that was never SPSS-derived. Document is // nil and the caller synthesises a default dictionary. SidecarStatusAbsent // SidecarStatusIgnored: a sidecar exists and was not read, because // the caller set [WriterOptions.IgnoreSidecar]. Document is nil and // the caller synthesises a default dictionary, exactly as for // SidecarStatusAbsent — the two differ in what is TRUE, not in what // happens next. SidecarStatusIgnored )
func (SidecarStatus) String ¶
func (s SidecarStatus) String() string
String renders the status for diagnostics.
type Source ¶
type Source struct {
// Magic is "$FL2" (a plain system file) or "$FL3" (ZSAV).
Magic string `json:"magic"`
// ProductName is the 60-byte header prod_name field.
ProductName string `json:"product_name"`
// FileLabel is the 64-byte header file label.
FileLabel string `json:"file_label,omitempty"`
// CreationDate is the header "dd mmm yy" field.
CreationDate string `json:"creation_date,omitempty"`
// CreationTime is the header "hh:mm:ss" field.
CreationTime string `json:"creation_time,omitempty"`
// ByteOrder is [SourceByteOrderLittle] or [SourceByteOrderBig].
//
// It describes the SOURCE file's bytes, which matters on the write
// side for one thing beyond provenance: [Missing.Raw] holds flt64
// slots verbatim, so a big-endian source's slots have to be reversed
// before they are re-emitted into the always-little-endian file this
// package writes.
ByteOrder string `json:"byte_order"`
// LayoutCode is the header layout probe (2 or 3) the byte order was
// derived from.
LayoutCode int32 `json:"layout_code"`
// Compression is "none", "bytecode" or "zsav".
Compression string `json:"compression"`
// CompressionBias is the header bias field, normally 100. It is
// written even for an uncompressed file, so it is recorded
// unconditionally.
CompressionBias Float `json:"compression_bias"`
// NominalCaseSize is the header's CLAIM about 8-byte elements per
// case. It is a claim, not a fact.
NominalCaseSize int32 `json:"nominal_case_size"`
// ElementCount is the AUTHORITATIVE elements-per-case, counted from
// the record type 2 stream. Both are kept so a consumer can see a
// disagreement rather than inherit a resolution.
ElementCount int32 `json:"element_count"`
// CaseCount is the number of cases the reader actually walked.
CaseCount int64 `json:"case_count"`
// DeclaredCaseCount is what the file claimed: the record 7/16
// 64-bit count where present, else the header's int32, which is -1
// when the writer did not know.
DeclaredCaseCount int64 `json:"declared_case_count"`
// Sysmis is the system-missing sentinel in force, which is the spec
// default (-DBL_MAX) unless record 7/4 declared a coherent one.
Sysmis Float `json:"sysmis"`
// MachineFloat is the record 7/4 sentinel triple, nil when the file
// carries no 7/4 — the common case.
MachineFloat *MachineFloat `json:"machine_float,omitempty"`
}
Source is the file-header metadata plus the case geometry.
type VLSDeclaration ¶
type VLSDeclaration struct {
// Name is the HEAD physical variable's short name; 7/14 keys by
// short name.
Name string `json:"name"`
// Width is the LOGICAL byte width, 256..32767.
Width int `json:"width"`
}
VLSDeclaration is one record 7/14 entry.
type VLSLayout ¶
type VLSLayout struct {
// Width is the LOGICAL byte width and the sum of every segment's
// Content.
Width int `json:"width"`
// Segments are the physical variables in file order, head first.
// Always at least two.
Segments []VLSSegment `json:"segments"`
}
VLSLayout is one very long string's physical segmentation, retained so a write path can reproduce the source's own layout rather than re-deriving one.
type VLSSegment ¶
type VLSSegment struct {
// Name is the physical variable's record type 2 short name.
Name string `json:"name"`
// Width is the physical variable's DECLARED byte width.
Width int `json:"width"`
// Content is how many bytes of the LOGICAL value this segment
// carries. NOT Width: a non-final segment's last three declared
// bytes are unused padding.
Content int `json:"content"`
// Elements is how many 8-byte data elements the physical variable
// occupies.
Elements int `json:"elements"`
}
VLSSegment is one PHYSICAL variable of a very long string.
type ValueEncoding ¶
type ValueEncoding int
ValueEncoding says how a cohort field's value becomes the 8-byte SPSS elements of one variable. It is the data encoder's dispatch, resolved once here so E5-S3 never has to re-derive it from a field type plus a format code plus a sidecar kind.
const ( // EncodeUnbound is a variable no cohort field backs. It is the zero // value so an unfilled plan cannot be mistaken for a numeric one, and // no plan this package returns carries it. EncodeUnbound ValueEncoding = iota // EncodeNumeric writes the cohort's f64 straight through. // // It covers plain measurements AND the temporal formats that were never // converted on the way in: TIME and DTIME durations, and the MOYR (28) / // QYR (29) / WKYR (30) formats E2-S6 mapped to raw SPSS seconds. See // [ColumnPlan.PrintFormat] for why that is the right answer for them. EncodeNumeric // EncodeDateDays writes a `date` column: epoch DAYS, converted to // seconds since the SPSS epoch. EncodeDateDays // EncodeDateTimeSeconds writes a `datetime` column: epoch SECONDS, // rebased onto the SPSS epoch. EncodeDateTimeSeconds // EncodeCategoricalCode writes a NUMERIC variable from a categorical // column: the dictionary ID indexes [ColumnPlan.Categories] and the // entry's Code is what goes on the wire. Never the ID. EncodeCategoricalCode // EncodeText writes a STRING variable: the dictionary ID indexes // [ColumnPlan.Categories] and the entry's Text is space-padded out to // the declared width. EncodeText // EncodeSetMember writes one indicator variable of a multiple-dichotomy // set: [ColumnPlan.SetBit] of the source `set_*` mask decides between // the counted value and 0. EncodeSetMember )
func (ValueEncoding) String ¶
func (e ValueEncoding) String() string
String renders the encoding for diagnostics.
type Variable ¶
type Variable struct {
// Name is the Pulse field name: the record 7/13 long name where the
// file declares one, else the short name.
Name string `json:"name"`
// ShortName is the ORIGINAL 8-byte record type 2 name. Retained
// because the record 7/13 mapping is short -> long and an export
// has to write the short name back; and because records 7/5, 7/7,
// 7/14 and 7/19 all key by it.
ShortName string `json:"short_name"`
// LongName is the record 7/13 declaration, "" when the file
// declares none. Held apart from Name so "the file declared a long
// name equal to the short one" stays distinguishable from "the file
// declared none".
LongName string `json:"long_name,omitempty"`
// Index is the 1-based dictionary ELEMENT index of the variable's
// first element — what every index-bearing field in the file counts.
Index int32 `json:"index"`
// Position is the 0-based column position in the cohort.
Position int `json:"position"`
// Label is the SPSS variable label, which also became the Pulse
// field description.
Label string `json:"label,omitempty"`
// HasLabel distinguishes "carried an empty label" from "carried
// none", which the record type 2 flag really does distinguish.
HasLabel bool `json:"has_label"`
// TypeCode is the raw record type 2 `type` field: 0 for numeric,
// else the declared byte width.
TypeCode int32 `json:"type_code"`
// DeclaredWidth is the declared width in BYTES — never runes — 0
// for a numeric. SPSS space-pads to it and this reader trims, so
// this is what an export re-pads to. For a very long string it is
// the LOGICAL total, not the 255 a single segment declares.
DeclaredWidth int `json:"declared_width"`
// Segments is how many 8-byte elements the variable occupies.
Segments int `json:"segments"`
// PrintFormat and WriteFormat are the SPSS output formats.
PrintFormat Format `json:"print_format"`
WriteFormat Format `json:"write_format"`
// Measure is the record 7/11 level: "unset", "nominal", "ordinal"
// or "scale". It informs Pulse smart defaults on import but is not
// derivable back from a Pulse schema, so it is stored regardless.
Measure string `json:"measure"`
// Alignment is the record 7/11 alignment: "left", "right" or
// "center". 7/11 has no unset alignment.
Alignment string `json:"alignment"`
// DisplayWidth is the record 7/11 display column width. Nil for the
// older two-int32-per-variable form of the record, which omits it.
DisplayWidth *int32 `json:"display_width,omitempty"`
// HasDisplayParams reports whether a record 7/11 was applied at
// all, so an absent record stays distinguishable from one declaring
// every field unset.
HasDisplayParams bool `json:"has_display_params"`
// PulseType is the resolved .pulse field type, as
// encoding.FieldType.String().
PulseType string `json:"pulse_type"`
// Kind is the mapping family: "numeric", "duration", "date",
// "datetime" or "categorical". A duration is f64 exactly as a
// numeric is; the distinction is recorded so a TIME / DTIME column
// is never mistaken for an instant.
Kind string `json:"kind"`
// Nullable is a fact, not a sample: the whole data section was
// scanned.
Nullable bool `json:"nullable"`
// DefaultAggregation and DefaultGrouper are the smart-default hints
// the measure level implied. Empty means no default applies.
DefaultAggregation string `json:"default_aggregation,omitempty"`
DefaultGrouper string `json:"default_grouper,omitempty"`
// Missing is the missing-value specification, nil when the variable
// declares none.
Missing *Missing `json:"missing,omitempty"`
// Categories is the code <-> label <-> ID triple, one per
// dictionary entry, in ID order. Empty for a non-categorical column.
Categories []Category `json:"categories,omitempty"`
// VeryLongString is the record 7/14 physical segmentation, non-nil
// only for a logical string wider than 255 bytes.
VeryLongString *VLSLayout `json:"very_long_string,omitempty"`
}
Variable is one source variable's full record.
type Weight ¶
type Weight struct {
// Index is the 1-based dictionary ELEMENT index the header stores.
Index int32 `json:"index"`
// Variable is the Pulse field name it resolves to, "" when the
// index names no variable's first element.
Variable string `json:"variable,omitempty"`
}
Weight is the header weighting variable.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer emits a `.sav` file.
It satisfies pio.Writer, pio.SchemaAwareWriter, pio.CohortWriter, pio.CohortValidator and pio.TargetWarningEmitter. See the file comment for why the middle two are there — the row-oriented contract alone cannot express what a `.sav` variable's value is derived from, nor whether it could be derived at all.
A Writer is single-use: one Writer, one file. Close is where the bytes land, matching every other adapter in io/.
func NewWriter ¶
func NewWriter(fs afero.Fs, path string, opts WriterOptions) *Writer
NewWriter creates a `.sav` writer that lands its bytes at path on fs.
opts is the caller's knob bag — WriterOptions — passed by value because it is settled before the first byte is written and nothing here mutates it. The CLI's four flags map onto it one for one.
func NewWriterToBuffer ¶
func NewWriterToBuffer(opts WriterOptions) *Writer
NewWriterToBuffer creates a `.sav` writer with no filesystem target; the emitted bytes are read back with Writer.Bytes. It is the fs-free unit-test path every adapter in io/ offers.
func (*Writer) Close ¶
Close runs the ROW path's encode if it has not already run, then writes the emitted bytes.
A second Close does not re-encode and does not re-write.
func (*Writer) OverlayWarnings ¶
func (w *Writer) OverlayWarnings() []*errors.CodedError
OverlayWarnings returns the one warn-and-skip diagnostic for the recorded layers, or nil when none were recorded.
nil / empty layers produce no warning: the diagnostic is "you asked for overlays and a .sav cannot carry them", not "a .sav cannot carry overlays in general". ExportJob.Run skips SetOverlays entirely when IncludeOverlays explicitly opts out, so the opt-out is silent by construction.
func (*Writer) Renames ¶
func (w *Writer) Renames() []NameRename
Renames returns the variable renames WriterOptions.SanitizeNames performed, in emission order. Nil when the flag was unset or nothing needed rewriting. The same set rides Warnings as a single PULSE_SPSS_NAME_SANITIZED; this is the typed form.
func (*Writer) SetOverlays ¶
func (w *Writer) SetOverlays(layers []*types.OverlayLayer)
SetOverlays records the layers the export pipeline wanted embedded. They are never written into the `.sav`; the emitted file is byte-identical to an overlay-free export. pio.OverlayAwareWriter.
Calling it twice overwrites the recorded set, so the warning describes the last slate handed over.
func (*Writer) SetPulseSchema ¶
SetPulseSchema receives the source cohort's schema. pio.SchemaAwareWriter.
func (*Writer) ValidateCohort ¶
func (w *Writer) ValidateCohort(ctx context.Context, src pio.CohortSource) ([]*errors.CodedError, error)
ValidateCohort reports whether this cohort could be written as a `.sav`, without writing one. pio.CohortValidator.
It runs the writer's NON-DATA pass — the sidecar resolution, the dictionary build, the name policy, the charset transcode, the derived fold and the encoder's own column checks — and throws the result away. That is the same code Writer.WriteCohort runs, called through the same helper rather than re-implemented, so a refusal predicted here is by construction the refusal the export returns: same check, same message, same code.
What it CANNOT see is anything that needs a record. A value too wide for a declared string, a character the target charset cannot encode, a categorical ID the plan records no SPSS code for — every one of those is found by the data pass, which is never entered. Predict is a sound but incomplete filter, and that asymmetry is deliberate: refusing on a guess would block exports that would have succeeded.
It leaves the Writer untouched — no bytes, no recorded warnings, no renames, and `done` unset — so validating is not a half-performed encode and a Writer stays usable afterwards.
func (*Writer) Warnings ¶
func (w *Writer) Warnings() []*errors.CodedError
Warnings returns the encode's non-fatal diagnostics. pio.TargetWarningEmitter.
It is a pure accessor: it triggers nothing and is safe to call twice. The set is complete once the encode has run — which is inside WriteCohort on the cohort path and inside Close on the row path, so a caller that wants the row path's warnings must ask after Close.
func (*Writer) WriteCohort ¶
WriteCohort encodes the cohort described by src. pio.CohortWriter.
It is the fidelity path: raw storage in, the metadata sidecar consulted, nothing re-derived from rendered text.
func (*Writer) WriteHeader ¶
WriteHeader records the emitted column names.
On the cohort path the names are informational — the dictionary is built from the cohort's schema and, where there is one, its metadata sidecar. On the row path they are the header the intermediate import infers against.
type WriterOptions ¶
type WriterOptions struct {
// IgnoreSidecar suppresses the metadata sidecar entirely: the file
// is not read even if it is present and healthy, and the export
// proceeds on a dictionary synthesised from the `.pulse` schema
// alone, exactly as it would for a cohort that never had one.
//
// It suppresses the READ rather than only the staleness verdict, and
// that is the deliberate reading of the flag's name. Two
// consequences follow, both wanted. Output no longer depends on the
// state of a file the caller has explicitly opted out of — a flag
// whose effect flipped with an mtime would be a trap. And an
// UNREADABLE sidecar cannot block the export either, so the flag is
// a genuine escape hatch rather than one that works only for the
// failure mode it was named after.
//
// It never causes a stale dictionary to be applied. There is no
// option that does; the choice is between the recorded metadata and
// the synthesised default, never between fresh and stale.
IgnoreSidecar bool
// Uncompressed writes the data section as flat 8-byte elements
// instead of SPSS's bytecode compression.
//
// The DEFAULT is compressed, because that is what SPSS's own SAVE
// writes: almost every `.sav` in the world is bytecode-compressed,
// so it is the path every tool that opens one exercises daily. The
// two encodings are losslessly equivalent — identical case data,
// re-importing to identical cohorts — so this knob trades size for
// a data section a human can read in a hex dump, and nothing else.
//
// It does NOT select ZSAV. ZSAV emission is not implemented; asking
// for it is PULSE_SPSS_COMPRESSION_UNSUPPORTED.
Uncompressed bool
// Charset overrides the character encoding the emitted file's strings
// are written in, and the record 7/20 declaration that names it.
//
// The DEFAULT — an empty string — writes the file in the charset its
// SOURCE declared, in the source's own spelling, which is the whole
// point of the write-side transcode: emitting UTF-8 into a file whose
// header says windows-1252 corrupts every non-ASCII label. A cohort
// with no SPSS provenance, or one whose source declared no encoding,
// gets UTF-8.
//
// Setting it is the answer to two situations the default cannot serve.
// A cohort has been edited since it was imported and now holds text the
// source's legacy codepage cannot express, which is otherwise a hard
// PULSE_SPSS_CHARSET_UNENCODABLE — writing UTF-8 keeps the text, and
// modern SPSS, PSPP, haven and pyreadstat all read a UTF-8 `.sav`. Or
// the source MISLABELLED itself and was read through
// spss.WithCharset: that override changes decoding only and is
// deliberately not consulted here, because it says nothing about what
// the new file should declare, so a caller who wants it honoured on
// the way out states it again here.
//
// The spelling is quoted verbatim into record 7/20, so "cp1252" is
// declared as "cp1252". A name this package cannot write in is
// PULSE_SPSS_CHARSET_UNSUPPORTED, never a silent fall back to UTF-8.
Charset string
// SanitizeNames rewrites cohort field names that cannot be SPSS
// variable names, instead of refusing the export.
//
// The DEFAULT is the refusal, and that is not timidity. A `.pulse`
// field name is any UTF-8 string; an SPSS variable name is a
// constrained identifier, and the three ways an illegal one fails
// (see dict_names.go) all produce a well-formed file that says
// something other than what the cohort said. Renaming a caller's
// columns behind their back to avoid that is the same class of quiet
// substitution, one step further downstream.
//
// But the refusal is a usability cliff on the SYNTHESISED path, where
// the names came from a CSV header rather than from SPSS: a space, a
// bracket, a hyphen or a leading digit is ordinary there, and
// `pulse convert data.csv out.sav` on a column called
// "household income (gross)" would otherwise have no way through at
// all. This flag is that way through, and it pays for itself by being
// LOUD: every rename is reported as a PULSE_SPSS_NAME_SANITIZED
// warning carrying the full field -> name list, so nothing about the
// emitted file is a surprise. Renames are deterministic — the same
// cohort always yields the same names — and collision-safe against
// both other renames and the names that were already legal.
//
// It has no effect on the SIDECAR-driven path. Those names came from
// a `.sav` and are legal by construction, so there is nothing to
// rewrite, and rewriting a source's own names would be the fidelity
// loss the whole write side exists to avoid.
SanitizeNames bool
}
WriterOptions is the knob bag for the SPSS WRITE side.
It exists ahead of the writer itself because the sidecar read is the writer's first act and already needs a caller knob. Later write-side stories add their own fields here rather than growing a parallel struct, and E5-S6's CLI leaf maps flags onto it one-for-one — the struct is the contract, the flags are a projection of it.
func (WriterOptions) Compression ¶
func (o WriterOptions) Compression() int32
Compression is the header compression flag these options select: SPSS bytecode by default, uncompressed when WriterOptions.Uncompressed is set. It is what a caller passes as DictionaryRequest.Compression, so the flag the header declares and the encoding the data section is written in are decided in one place.
Source Files
¶
- bytecode.go
- charset.go
- charset_write.go
- data.go
- data_write.go
- derived.go
- dict_fold.go
- dict_names.go
- dict_parse.go
- dict_source.go
- dict_synth.go
- dict_write.go
- dictionary.go
- extension.go
- longstring.go
- mapping.go
- missing.go
- missing_categorical.go
- mrset.go
- overlay.go
- sidecar.go
- sidecar_build.go
- sidecar_read.go
- spss.go
- writer.go
- zsav.go