Documentation
¶
Overview ¶
Package encoding is the module-internal twin of the public github.com/frankbardon/pulse/encoding package. The public package keeps the schema nouns and the ungrouped raw-byte primitives; everything else in the .pulse codec lives here: decode plans and record readers, parent group encode/decode, the read/write preamble, shard archives and cohesion, the sidecar point-lookup index and its manifest, set-field widening and the set ladder.
This package imports the public one, never the reverse. The few unexported public-side helpers it shares are reached through internal/encodingbridge. Callers that import both name this one encx.
Index ¶
- Constants
- Variables
- func BucketIndex(key []byte, bucketCount uint32) uint32
- func CheckSetWiden(from, to encoding.FieldType) error
- func CloneGroups(groups []encoding.Group) []encoding.Group
- func CloneVectors(vs []encoding.VectorField) []encoding.VectorField
- func DecodeGroupEntry(s *encoding.Schema, g, e int, sink IndexedReusableRecord) error
- func DedupCohort(dst io.Writer, src io.Reader, specs []GroupSpec) (*encoding.Schema, int64, error)
- func FlattenCohortBytes(cohort []byte) ([]byte, *encoding.Schema, error)
- func ForEachRecord(r io.Reader, stride int, fn func(rec []byte) error) (int64, error)
- func HashKey(key []byte) uint64
- func IndexManifestPath(cohortPath string) string
- func IsArchive(r io.ReaderAt, size int64) (bool, error)
- func IsLowQualityDescription(desc string) bool
- func JoinKeyTypesCompatible(a, b encoding.FieldType) bool
- func JoinKeysRefusal(left, right *encoding.Schema, spec *types.JoinSpec) error
- func JoinKeysRefusals(left, right *encoding.Schema, spec *types.JoinSpec) []*errors.CodedError
- func JoinedSchema(left, right *encoding.Schema, as string) (*encoding.Schema, error)
- func LayoutFields(s *encoding.Schema) int
- func MergeDictUnion(canonical, incoming *encoding.Schema) (*encoding.Schema, map[int]DictRemap, error)
- func NewLogicalStream(r io.Reader, s *encoding.Schema) (io.Reader, *encoding.Schema, error)
- func PromoteAdd(p1, s1, p2, s2 uint8) (uint8, uint8)
- func PromoteDiv(p1, s1, p2, s2 uint8) (uint8, uint8)
- func PromoteMul(p1, s1, p2, s2 uint8) (uint8, uint8)
- func ReadIndexHeader(r io.Reader) error
- func ReadPreamble(r io.Reader) (*encoding.Schema, byte, error)
- func ReadRecordAt(loc *encoding.RecordLocator, r io.ReadSeeker, i uint64, ...) error
- func ReadRecordAtRaw(loc *encoding.RecordLocator, r io.ReadSeeker, i uint64, ...) error
- func ReadSchemaLayout(r io.Reader, v byte) (*encoding.Schema, error)
- func RefuseGroups(s *encoding.Schema, operation string, code errors.Code) error
- func RewriteShardCategoricals(shardBytes []byte, targetSchema *encoding.Schema, remap map[int]DictRemap) ([]byte, error)
- func RewriteShardCategoricalsKeepNulls(shardBytes []byte, targetSchema *encoding.Schema, remap map[int]DictRemap) ([]byte, error)
- func SetLadder() []encoding.FieldType
- func SetTypeFor(elements int) (encoding.FieldType, bool)
- func SetWidthHeadroomFor(s *encoding.Schema) []encoding.SetWidthHeadroom
- func SidecarIndexPath(cohortPath string, keyFields []string) string
- func ValidateDictPrefixRule(canonical, incoming *encoding.Schema) (*encoding.Schema, error)
- func ValidateGroupCohesion(canonical, incoming *encoding.Schema) error
- func ValidatePrecisionScale(precision, scale uint8) error
- func VectorFromSpan(ft encoding.FieldType, sub []byte, dst []float64) ([]float64, error)
- func WidenSchemaSetField(s *encoding.Schema, field string, target encoding.FieldType) (*encoding.Schema, error)
- func WidenSetFieldBytes(cohort []byte, field string, target encoding.FieldType) ([]byte, *encoding.WidenReport, error)
- func WidenSetFieldFile(fsys afero.Fs, path, field string, target encoding.FieldType) (*encoding.WidenReport, error)
- func WidestSetType() encoding.FieldType
- func WriteIndex(w io.Writer, idx *Index) error
- func WriteIndexFile(fsys afero.Fs, path string, idx *Index) error
- func WriteIndexHeader(w io.Writer) error
- func WriteIndexManifest(fsys afero.Fs, cohortPath string, m *IndexManifest) error
- func WritePreamble(w io.Writer, s *encoding.Schema) error
- func WriteSchemaDoc(w io.Writer, schema *encoding.Schema, agg uint64, shardCount uint16) error
- type Archive
- func (a *Archive) Entries() []ArchiveEntry
- func (a *Archive) IsStored(name string) bool
- func (a *Archive) Open(name string) (io.ReadCloser, error)
- func (a *Archive) OpenAt(name string) (io.SectionReader, error)
- func (a *Archive) PeekShardHeader(name string) error
- func (a *Archive) PeekShardRecordCount(name string) (int64, error)
- type ArchiveEntry
- type CohesionWarning
- type ConstantDetector
- type ConstantPlan
- type DecodeFields
- type DecodePlan
- type DedupGate
- func (gt DedupGate) AssessRatios(grouped *encoding.Schema, specs []GroupSpec, rows int64) ([]GroupViability, []*errors.CodedError, error)
- func (gt DedupGate) Floor() float64
- func (gt DedupGate) ScreenWidths(flat *encoding.Schema, specs []GroupSpec) ([]GroupSpec, []GroupViability, []*errors.CodedError, error)
- type DictRemap
- type ExactU64Record
- type FieldFilter
- type Fingerprint
- type GroupEncoder
- func (e *GroupEncoder) EncodeRow(dst, lrow []byte) ([]byte, error)
- func (e *GroupEncoder) EncodeStream(spool io.Writer, lr io.Reader) (int64, error)
- func (e *GroupEncoder) LogicalStride() int
- func (e *GroupEncoder) PhysicalStride() int
- func (e *GroupEncoder) Rows() int64
- func (e *GroupEncoder) Schema() *encoding.Schema
- func (e *GroupEncoder) Seed(seed *encoding.Schema) error
- type GroupEntryDecoder
- type GroupIndexHeadroom
- type GroupIndexRecord
- type GroupSpec
- type GroupViability
- type Index
- type IndexBucket
- type IndexEntry
- type IndexKeySpec
- type IndexManifest
- type IndexManifestEntry
- type IndexManifestKey
- type IndexMeta
- type IndexedReusableRecord
- type RecordReader
- func (rr *RecordReader) GroupIndex(g int) (uint32, bool)
- func (rr *RecordReader) ReadRecord(values map[string]float64, nulls map[string]bool) error
- func (rr *RecordReader) ReadRecordReused(rec ReusableRecord) error
- func (rr *RecordReader) ReadRecordReusedWithPlan(rec ReusableRecord, keep FieldFilter, plan *DecodePlan) error
- func (rr *RecordReader) ReadRecordWithWide(values map[string]float64, nulls map[string]bool, wide map[string]any) error
- func (rr *RecordReader) ReadRecordWithWidePlan(values map[string]float64, nulls map[string]bool, wide map[string]any, ...) error
- func (rr *RecordReader) ReadRecordWithWideProjected(values map[string]float64, nulls map[string]bool, wide map[string]any, ...) error
- type ReusableRecord
- type RowExpander
- type RunSkipRecord
- type SchemaDoc
- type Segment
- type SetWidenPlan
- type SkipBytes
- type TypedSetRecord
- type VectorRecord
Constants ¶
const ( // GroupVerdictAdmitted: the group is written and passed both floors. GroupVerdictAdmitted = "admitted" // GroupVerdictLowRatio: the group is written, with a // PULSE_DEDUP_LOW_RATIO warning. GroupVerdictLowRatio = "low_ratio" // GroupVerdictDroppedTooNarrow: the group was not formed; its // members stay row fields (PULSE_GROUP_TOO_NARROW). GroupVerdictDroppedTooNarrow = "dropped_too_narrow" )
Group viability verdicts, as reported per group.
const DefaultDedupRatioFloor = 2.0
DefaultDedupRatioFloor is the ratio floor DedupGate applies when none is configured: 2 rows per distinct tuple.
Why 2. For a group whose members are wide against the 4-byte index, the resident dictionary is (member bytes per row) × rows ÷ ratio, so the ratio is the factor by which the parent block shrinks: at 2 the dictionary still holds half the bytes the rows used to carry, and below it most of the parent data becomes resident memory while the saving tends to zero (a ratio of 1.25 keeps 80% of it resident for a 20% cut). A floor of 2 therefore reads "each stored tuple is shared by at least two rows on average" and flags exactly the case the planning measurements called out — the same declaration measured 2.73x on one cohort (passes) and 1.24x on another (warns). It is a judgement, not a law: it is overridable (DedupGate.RatioFloor, CLI --dedup-ratio-floor) and only ever warns. The grows-the-file check is separate and applies at any floor, because a narrow-ish group (members only slightly wider than the index) can grow the file at a ratio well above 2 — its break-even ratio is reported alongside.
const FingerprintSize = sha256.Size // 32
FingerprintSize is the byte length of the embedded .pulse content-hash fingerprint (a raw SHA-256 digest).
const IndexFormatVersion byte = 0x03
IndexFormatVersion is the current point-lookup sidecar index format version. Independent of encoding.FormatVersion (the .pulse envelope version) — this is a separate, standalone sidecar format that versions on its own schedule.
v3 (current) adds a fixed-width bucket-offset table immediately before the bucket-data region, and moves the source-stat snapshot (size + mtime, introduced in v2) earlier — right after the key-spec block, before the bucket count — so ReadIndexMeta can read every non-bucket field with one short linear pass and never touch bucket data. The offset table is what makes ReadBucketByKey an O(1) seek: a reader computes bucketIndex = BucketIndex(key, bucketCount), seeks to offsetTableStart + bucketIndex*8, reads that one u64, seeks to bucketDataStart + offset, and parses only that bucket's self-delimited entry_count-prefixed data — never the whole table, never the whole file. See WriteIndex's format comment for the full v3 layout and IndexMeta's doc comment for the seek anchors.
v1/v2 sidecars are no longer readable: ReadIndexHeader rejects any version byte other than the current one with ENCODING_INVALID, forcing an explicit `pulse index build` rebuild rather than a silent partial read. There is no in-place migration path — sidecars are cheap, deterministic rebuild artifacts, never hand-authored or long-lived across binary versions.
const IndexHeaderSize = 9
IndexHeaderSize is the total byte size of the sidecar index header (magic + version), mirroring HeaderSize for the .pulse envelope.
const IndexManifestFormatVersion = "1"
IndexManifestFormatVersion is the manifest document's own version, independent of both the .pulse FormatVersion and the sidecar IndexFormatVersion: the manifest is a JSON catalog, not a binary layout, and versioning it separately means adding a field to it can never force a cohort or index rebuild. An unrecognised value is REFUSED, for the same reason a foreign kind is.
const IndexManifestKind = "pulse.index-manifest"
IndexManifestKind is the manifest document's self-identifying kind. A document carrying any other kind is REFUSED rather than read as an empty manifest — silently reporting "this cohort has no indexes" over a file that is simply something else is exactly the failure the manifest exists to remove.
const IndexManifestSuffix = ".indexes.json"
IndexManifestSuffix is appended to a cohort's filename to derive its sidecar index MANIFEST — "cohort.pulse.indexes.json". It follows the same "suffix appended to the cohort filename" convention imports.SidecarSuffix (".meta.json") and spss.SidecarSuffix (".spss.json") already use, and is deliberately distinct from both.
It exists because SidecarIndexPath derives an index's filename from a HASH OF ITS KEY TUPLE, which makes a sidecar impossible to open without already knowing the thing a caller is trying to learn. The key tuple is recorded INSIDE the sidecar (IndexMeta.Keys), so on a local disk the escape is to glob "<cohort>.*.idx" and read each match's meta — but OBJECT STORAGE CANNOT LIST A DIRECTORY, so a caller reading cohorts out of a bucket has neither the hash nor a listing and cannot discover an index at all. Guessing is a permutation search, because key ORDER is significant end to end.
This manifest is the keyless, deterministically-named answer: one predictable path, reachable with a single GET, carrying every sidecar's ordered key tuple. Service.ListIndexes reads it first and falls back to the directory listing only when it is absent, so a corpus written by a Pulse that predates it keeps working unchanged.
const JoinKeySetRejection = "" /* 269-byte string literal not displayed */
JoinKeySetRejection is the one explanation for refusing a set_* column as a join key, whichever side asks.
A set mask has no single unambiguous equality value — the empty selection, a single-member mask and a multi-member mask are all distinct legal states — and the equality the hash join would use is worse than ambiguous: its key stringifies the LOSSY float64 echo of the mask (the low 64 bits for set_u128 / set_u256, a value already past float64's mantissa above 2^53 for set_u64), so two different selections collapse onto one key and rows join that share no answer.
const JoinKeyVectorRejection = "" /* 194-byte string literal not displayed */
JoinKeyVectorRejection is the one explanation for refusing a native vector column (vec_f32 / vec_f64) as a join key, whichever side asks: a vector has dim values per row and no single equality value, and the hash join's key reads a row's ONE numeric value, which a vector does not have — every row would key as missing and join nothing.
const MaxFormatVersion byte = encoding.FormatVersionV2
MaxFormatVersion is the newest .pulse format version this binary reads and writes.
const MinElisionRows = 2
MinElisionRows is the smallest cohort constant elision considers. Over a single row every field is trivially "constant", which says nothing about the data; over zero rows a constant group has no value to hold and cannot be written.
const ReservedSchemaName = "_schema.pulse"
ReservedSchemaName is the canonical entry name inside a Pulse shard archive that carries the cohort's canonical schema, dictionaries, and sharding metadata (aggregate record count, shard count). The name is reserved — inserting a shard with this basename raises PULSE_SHARD_RESERVED_NAME at write time. Read-side enumeration of shards EXCLUDES this entry.
Variables ¶
var IndexMagicBytes = [8]byte{'P', 'U', 'L', 'S', 'E', 'I', 'D', 'X'}
IndexMagicBytes identifies a Pulse point-lookup sidecar index file. 8 bytes: "PULSEIDX". Distinct from MagicBytes ("PULSE\x00\x00\x00") so magic-byte dispatch never confuses a sidecar index with a .pulse cohort file — the two formats are never read through the same code path, but sharing the discriminant style keeps every Pulse binary artifact self-identifying the same way.
var SchemaDocMagic = [4]byte{'S', 'H', 'R', 'D'}
SchemaDocMagic is the four-byte marker that introduces the sharding metadata extension appended to a `_schema.pulse` entry after the standard schema block. Readers detect the marker and parse the extension; absence means the metadata is unavailable (the canonical fallback is to peek each shard's header for record counts).
var ZipMagic = [4]byte{'P', 'K', 0x03, 0x04}
ZipMagic is the standard PKZip local-file-header magic. Pulse shard archives begin with these four bytes when written as zip containers; single-file .pulse cohorts begin with MagicBytes ("PULSE\x00\x00\x00"). Pulse.Open dispatches on the first four bytes to pick the read path.
Functions ¶
func BucketIndex ¶
BucketIndex maps a key's hash onto a bucket slot in a table sized bucketCount. bucketCount == 0 always resolves to 0 — callers must guard the empty-index case themselves via len(Index.Buckets) before indexing.
func CheckSetWiden ¶
CheckSetWiden reports whether a set field of type from may be widened to type to, returning the refusal as a coded error otherwise. It is the whole admission policy, exported so a caller can pre-flight a widen (a CLI predict, a shard-admin decision) without touching bytes.
Every refusal is ENCODING_TYPE_MISMATCH: each one is a caller asking for a type transition the format does not define, which is exactly what that code names. This effort introduces no new error codes.
func CloneGroups ¶
CloneGroups is cloneGroups for callers outside the package that build a schema copy of their own (the shard-archive canonical seed).
func CloneVectors ¶
func CloneVectors(vs []encoding.VectorField) []encoding.VectorField
CloneVectors deep-copies vector-field metadata (labels included), so a schema clone keeps every vec field's dim — a clone that dropped it would size each vec field at 0 bytes and misalign every later field. nil for none.
func DecodeGroupEntry ¶
func DecodeGroupEntry(s *encoding.Schema, g, e int, sink IndexedReusableRecord) error
DecodeGroupEntry writes entry e of group g into sink at the members' LOGICAL field indices, with exactly the side effects a full row decode has for those fields (value + typed wide value; a null member gets SetNullFieldAt + SetNumericAt(0)). It does not call ClearForRow and touches no non-member field. It is the per-entry evaluation hook: a predicate over member fields can be evaluated once per entry through a record built over this schema.
func DedupCohort ¶
DedupCohort reads a whole cohort (header, schema, records — any version, grouped or not) from src and writes its grouped equivalent, grouped by specs, to dst. The source is read through its LOGICAL stream, so a grouped source is regrouped from scratch. The physical rows are spooled in memory because the dictionaries precede the records on the wire — it is the small-cohort form of the one encode path (GroupEncoder.EncodeStream) that io.DedupJob drives with a temp-file spool. A truncated trailing record is ENCODING_INVALID rather than silently dropped. Returns the written schema and the record count.
func FlattenCohortBytes ¶
FlattenCohortBytes returns the ungrouped (0x01) twin of a single-file cohort: the logical schema's preamble followed by every logical row, in order. A cohort without groups is returned unchanged (the same slice). The logical rows are exactly what every decoder yields, so the twin decodes identically; for a cohort deduped from a 0x01 original it reproduces that original byte-for-byte.
A payload ending mid-record is ENCODING_INVALID rather than a silently dropped tail (the walk is ForEachRecord's).
func ForEachRecord ¶
ForEachRecord reads fixed-width records of stride bytes from r and calls fn with each one, returning how many it read. The slice fn receives is reused between calls. A clean end of input between two records ends the walk; input ending MID-record is ENCODING_INVALID (details trailing_bytes, records_read) rather than a silently dropped tail. Every whole-cohort record pass of the dedup family — encoding, constant detection, candidate detection — walks through here, so the three cannot disagree about where a cohort ends.
func HashKey ¶
HashKey returns the 64-bit FNV-1a digest of raw key bytes. Builders and lookup callers both route through this (and BucketIndex) so the two sides never diverge on hash choice.
func IndexManifestPath ¶
IndexManifestPath derives the deterministic, KEYLESS on-disk path of the sidecar index manifest for cohortPath. Pure — no filesystem access. Unlike SidecarIndexPath there is nothing to know in advance: that is the whole point.
func IsArchive ¶
IsArchive reports whether the first four bytes of r identify a zip container. It does no central-directory parsing — magic-byte check only — so it is cheap enough to call on every Open. Errors are returned for I/O failures; a short file returns (false, nil).
func IsLowQualityDescription ¶
IsLowQualityDescription reports whether a field description is empty, shorter than 10 bytes once trimmed, or one of the generic placeholders — the PULSE_FIELD_DESCRIPTION_LOW_QUALITY rule. It is the one predicate predict's description check and the cohort builder's schema check share, so the two cannot disagree on which fields draw the finding.
func JoinKeyTypesCompatible ¶
JoinKeyTypesCompatible reports whether two schema types can be compared as equi-join keys after normalisation. The conservative v1 rule: a set_* column never (not even against an identical rung — see JoinKeySetRejection), nor a vector column (JoinKeyVectorRejection); identical types match; categorical types of any width match each other (dict strings normalise to text); the unsigned-int / float / date family matches within itself. decimal128 matches only decimal128 (precision differences matter).
func JoinKeysRefusal ¶
JoinKeysRefusal is the first of JoinKeysRefusals, or nil — the refusal the runtime returns.
func JoinKeysRefusals ¶
JoinKeysRefusals is the ONE join-key rule. It judges a JoinSpec's kind and OnPairs against the left and right cohort schemas and returns every refusal, in the order the runtime meets them:
- no OnPair at all — PULSE_JOIN_KEYS_EMPTY (no details);
- a kind other than inner — PULSE_JOIN_KIND_NOT_IMPLEMENTED {kind};
- per OnPair i, in order: a blank side — PULSE_JOIN_KEYS_EMPTY {index}; a LeftField the left schema lacks, then a RightField the right schema lacks — PULSE_JOIN_FIELD_UNKNOWN {field, index}; key types that cannot be compared — PULSE_JOIN_TYPE_MISMATCH {left_field, left_type, right_field, right_type, index}, plus reason "set_key" with JoinKeySetRejection for a set_* key, reason "vector_key" with JoinKeyVectorRejection for a vec_f32 / vec_f64 key.
The runtime (processing.NewHashJoinIterator, and the service before it materialises the right side) refuses with the first entry; predict, ValidateJoin and the Compose / chain validators report them — so a caller sees the same code, message and details from either side. A nil spec or schema yields nil: there is nothing to judge.
func JoinedSchema ¶
JoinedSchema synthesises the schema a single inner hash join produces: left fields unchanged, then every right field renamed with the `as` prefix (when non-empty). A right field whose final name collides with an earlier field is PULSE_JOIN_FIELD_COLLISION with {field, as} details.
It is the ONE join-schema rule: the runtime (processing.JoinedSchema, which the hash-join iterator builds its records over) and predict (internal/descriptor, which may not import the engine) both call it, so zone resolution, smart defaults and field validation see the same field set and the same field types on both sides. The result has no on-wire byte layout; it exists for field lookups over in-memory joined records.
func LayoutFields ¶
LayoutFields is the canonical record layout: it assigns every field's ByteOffset (the running sum of Schema.FieldWidth over the fields before it) and BitPosition (always 0 — each bit-packed field owns its own byte, never sharing it with a neighbour) in place, and returns the payload width (the stride minus any trailing null bitmap).
The stored offset is a cache of this derivation — readers are cursor-driven — but it is written into the schema block and compared by shard cohesion and the shard dictionary rewrite, so every path that builds a schema (inference, explicit schema files, the cohort builder, adapters that read a typed source schema, synth, width promotion) lays it out HERE. A second hand-rolled sum that summed ByteSize() gave every field after a bit-packed one an offset one byte short.
It takes the whole schema, not a field slice: a field's width is the field-aware Schema.FieldWidth, never a function of its type byte alone (TestNoTypeOnlyWidthInStridePaths).
func MergeDictUnion ¶
func MergeDictUnion(canonical, incoming *encoding.Schema) (*encoding.Schema, map[int]DictRemap, error)
MergeDictUnion is the relaxed dictionary cohesion check. For each categorical field it computes the union of canonical's and incoming's dictionaries: canonical entries first in their existing order, then any new entries from incoming in their order. Returns the extended canonical schema (deep copy when an extension is adopted; identical pointer when not) and a per-field-index map of DictRemap entries describing how to translate incoming record indices into canonical indices.
The structural validator (ValidateStructuralCohesion) must run FIRST — this validator assumes field positions, names, and categorical widths already match.
Errors:
- PULSE_SHARD_DICT_WIDTH_OVERFLOW when the union would exceed the field's categorical width capacity.
MergeDictUnion is the default cohesion mode at insert time (CreateShardArchive / AddShard). The stricter prefix-only rule (ValidateDictPrefixRule) is retained for callers that want to surface divergence as an error instead of merging — used by VerifyShardArchive when checking archives written before the union-merge default landed and by embedders that prefer to align dictionaries upstream.
func NewLogicalStream ¶
NewLogicalStream returns a reader of the logical record stream of s over r (positioned at the first physical record), and the schema that describes that stream (s.Logical()). Byte-level record consumers — a decoder of their own, an exporter — read through it and walk the returned schema exactly as they walk a 0x01 cohort. For a schema without groups it returns (r, s) unchanged.
func PromoteAdd ¶
PromoteAdd returns the (precision, scale) of a SUM/SUB result given two operand types per SQL:2016 / Arrow Decimal128 rules:
(p1, s1) ± (p2, s2) => (max(p1-s1, p2-s2) + max(s1, s2) + 1, max(s1, s2))
The result precision is clamped at MaxDecimalPrecision; clamping callers must check ClampedPrecision and emit PULSE_DECIMAL_OVERFLOW when overflow surfaces at runtime.
func PromoteDiv ¶
PromoteDiv returns the (precision, scale) of a DIV result.
(p1, s1) ÷ (p2, s2) => (p1 + s2 + 1, max(s1+s2, MIN_SCALE))
func PromoteMul ¶
PromoteMul returns the (precision, scale) of a MUL result.
(p1, s1) × (p2, s2) => (p1 + p2, s1 + s2)
func ReadIndexHeader ¶
ReadIndexHeader reads and validates the sidecar index header from r. Returns ENCODING_INVALID on a truncated header, wrong magic, or an unsupported version byte — mirroring ReadHeader's contract for the .pulse envelope.
func ReadPreamble ¶
ReadPreamble reads the header and the version-matched schema block, leaving r positioned at the first record byte. It is the one-call form of [ReadHeader] + [ReadSchema] for callers that do not need to wrap the two failures differently.
func ReadRecordAt ¶
func ReadRecordAt(loc *encoding.RecordLocator, r io.ReadSeeker, i uint64, values map[string]float64, nulls map[string]bool, wide map[string]any, keep FieldFilter, plan *DecodePlan, ) error
ReadRecordAt decodes the record at index i directly: it seeks r to loc.Offset(i) and decodes from there, without iterating any preceding record. r must be an io.ReadSeeker positioned over the same underlying bytes the locator was built from (the same *bytes.Reader passed to NewRecordLocator, or a fresh reader over the same byte slice) — the seek is absolute, so r's current cursor position on entry is irrelevant.
plan is optional: pass a *DecodePlan (from BuildDecodePlan(schema, retained)) to decode only the columns the plan retains — the O(1) projection variant required by point-lookup callers that only need a handful of return columns out of a wide schema. Pass nil to decode every field via the existing full-record path. keep mirrors the FieldFilter contract used throughout this package (see ReadRecordWithWidePlan / ReadRecordWithWideProjected) — pass the same filter used to build plan's retained set so map writes for incidental group members (a bit-packed neighbour or a bitmap-adjacent nullable field) stay suppressed; pass nil to keep every field the plan/schema visits.
Returns a coded ENCODING_INVALID error — never a panic, never an out-of-bounds read — when i >= loc.TotalRecords.
func ReadRecordAtRaw ¶
func ReadRecordAtRaw(loc *encoding.RecordLocator, r io.ReadSeeker, i uint64, values map[string]float64, nulls map[string]bool, wide map[string]any, raw map[string]uint64, ) error
ReadRecordAtRaw is ReadRecordAt's full-record exact variant: it decodes record i into values / nulls / wide exactly as ReadRecordAt with a nil plan and a nil keep does, and ALSO writes the exact on-wire word of every field read through ReadFieldValue into raw (integers u8..u64, f32/f64 bit patterns, date, datetime, categorical dictionary IDs and the narrow set masks). Bit-packed fields (u4, packed_bool) are exact in values; decimal128 and the wide set rungs are exact in wide. A null field is absent from raw and wide.
It is the decode path for an index-addressed reader that must hand back the stored value rather than the float64 echo (u64 above 2^53, datetime beyond 2^53 seconds). A grouped (0x02) locator decodes the logical record, identical to the ungrouped twin.
func ReadSchemaLayout ¶
ReadSchemaLayout reads a schema block at version v without its dictionaries or group entry tables (nil Field.Dictionary and Group.Entries) — enough for the record stride, at a fraction of a full decode's allocation. r is left at the first record byte. Used wherever a shard's own schema block is only read past: the record-count peek (Archive.PeekShardRecordCount) and the archive scan (internal/service shardIter.openShard / processOneShard), which decodes every row through the canonical schema, the lookup locator (openRecordLocator), the shard-admin count (recordCountFromBytes) and the shard-archive filter (skipShardPreamble).
func RefuseGroups ¶
RefuseGroups returns a coded error when s declares parent groups and the caller's operation rewrites or reinterprets physical row bytes without understanding them yet (shard-archive assembly, set-field widening, categorical byte remap). It keeps such a path loud rather than silently misreading a grouped row by its logical offsets. nil for an ungrouped schema.
func RewriteShardCategoricals ¶
func RewriteShardCategoricals(shardBytes []byte, targetSchema *encoding.Schema, remap map[int]DictRemap) ([]byte, error)
RewriteShardCategoricals re-emits a single-file .pulse shard with its schema block replaced by targetSchema and its records' categorical indices translated according to remap. The on-wire byte layout of every non-categorical field is preserved exactly.
targetSchema must be structurally identical to the shard's own schema (field count, names, types, byte offsets, bit positions) — only the per-categorical dictionaries differ. The record bytes are addressed by the type-derived layout the decoder walks (widenFieldOffsets / LayoutFields), not by the stored offsets.
When remap is empty (every incoming dict was a prefix of canonical), the function still re-emits the shard with targetSchema so the shard's standalone dictionary matches the archive's canonical (callers reading the shard via the `archive.pulse#shard.pulse` anchor see the union dictionary). Record bytes are copied verbatim.
Returns PULSE_SHARD_HEADER_INVALID if the input bytes are not a valid single-file Pulse cohort, and PULSE_SHARD_SCHEMA_MISMATCH if targetSchema is structurally incompatible with the shard's own schema.
func RewriteShardCategoricalsKeepNulls ¶
func RewriteShardCategoricalsKeepNulls(shardBytes []byte, targetSchema *encoding.Schema, remap map[int]DictRemap) ([]byte, error)
RewriteShardCategoricalsKeepNulls is RewriteShardCategoricals except that a field which is NULL in a record keeps its placeholder bytes instead of having them remapped. A null's bytes carry no value, but they are part of a parent-group ENTRY's identity (an entry is compared byte for byte): remapping a null categorical member's placeholder would make a shard's (region = null) tuple a different entry from the archive's identical one — a duplicate entry in an unkeyed group, and a spurious PULSE_GROUP_MEMBER_NOT_CONSTANT on a keyed group whose non-key member is null. The shard-archive group path remaps the LOGICAL (flattened) form of a shard through this variant before re-encoding it against the canonical dictionaries; the ungrouped archive path keeps RewriteShardCategoricals' byte-for-byte behaviour.
func SetLadder ¶
SetLadder returns the set-width ladder narrowest rung first. The returned slice is a fresh copy; callers may mutate it freely.
func SetTypeFor ¶
SetTypeFor returns the narrowest set_* type with a bit for each of `elements` elements, and reports false when no rung is wide enough (or when `elements` is not positive — a set with no elements has no type, and that is a caller error rather than a width verdict).
func SetWidthHeadroomFor ¶
func SetWidthHeadroomFor(s *encoding.Schema) []encoding.SetWidthHeadroom
SetWidthHeadroomFor returns one SetWidthHeadroom per set field in s, in field order. A schema with no set fields returns nil.
func SidecarIndexPath ¶
SidecarIndexPath derives the deterministic on-disk path for a point-lookup sidecar index built against cohortPath's key column set: "<cohortPath>.<keyhash>.idx". keyhash is the 16-hex-digit FNV-1a digest (HashKey) of the ordered key column names, joined with a NUL byte separator that cannot appear inside a schema field name — so ["ab", "c"] and ["a", "bc"] hash to distinct paths rather than colliding on naive concatenation.
Key column order is significant: SidecarIndexPath(p, []string{"a", "b"}) and SidecarIndexPath(p, []string{"b", "a"}) derive different, stable paths, matching the composite-key contract ("key column order is significant") the format's Keys spec carries. Single-key builds (this story) pass a one-element slice; the derivation is already N-column-general so E2-S1's composite-key work and E4-S1's CLI / E1-S4's lookup service need no path-derivation change.
Deterministic and pure — no filesystem access, no ordering beyond what the caller supplies in keyFields. Callers own key-order normalization (or intentional non-normalization) before calling.
func ValidateDictPrefixRule ¶
ValidateDictPrefixRule enforces the append-only prefix rule for every categorical field shared by canonical and incoming. For each `categorical_*` field:
- If incoming's dictionary is a prefix of canonical's, accept the shard as-is; no canonical change. (Older shard that never saw newer dictionary values.)
- If canonical's dictionary is a prefix of incoming's, the canonical schema adopts the extension. The returned extendedCanonical carries the merged dictionaries and the caller is responsible for rewriting `_schema.pulse` to publish them before the new shard is placed (crash-safety ordering documented in placeholder §3.2).
- Neither is a prefix of the other → PULSE_SHARD_DICT_DIVERGENCE.
- An extension that would exceed the field's width capacity (256 for u8, 65 536 for u16, 2^32 for u32) → PULSE_SHARD_DICT_WIDTH_OVERFLOW.
The structural validator (ValidateStructuralCohesion) must run FIRST — this validator assumes field positions, names, and categorical widths already match.
The returned extendedCanonical is a deep copy when an extension is adopted, and is identically the canonical input otherwise. Callers can compare pointers to detect whether a rewrite is required.
func ValidateGroupCohesion ¶
ValidateGroupCohesion is the parent-group dimension of structural cohesion: canonical and incoming must declare the identical group layout — the same group count and, per group, the same kind, members and key flags. It is what makes the physical stride of every shard equal the canonical stride, so a 0x01 shard in a 0x02 archive (or the reverse, or two different groupings) is PULSE_SHARD_SCHEMA_MISMATCH. Group DICTIONARIES are not compared here; they follow the prefix rule (ValidateDictPrefixRule). ValidateStructuralCohesion calls it, so `pulse shard verify` refuses a genuinely mixed-layout archive.
func ValidatePrecisionScale ¶
ValidatePrecisionScale reports whether (precision, scale) form a legal decimal128 type spec (1 ≤ precision ≤ 38, 0 ≤ scale ≤ precision).
func VectorFromSpan ¶
VectorFromSpan decodes a vector field's whole on-wire span — exactly its Schema.FieldWidth bytes — appending the elements to dst[:0]. The dim is the span's element count; a span that is not a whole multiple of the element size, or whose count is outside MinVectorDim .. MaxVectorDim, is ENCODING_INVALID (encoding.VectorFromBytes).
func WidenSchemaSetField ¶
func WidenSchemaSetField(s *encoding.Schema, field string, target encoding.FieldType) (*encoding.Schema, error)
WidenSchemaSetField returns a copy of s in which the named set field carries the wider rung target, with every field's ByteOffset recomputed for the new stride.
It is the SCHEMA half of a widen, split out because the shard auto-widen path has to publish a widened canonical `_schema.pulse` that has no records of its own alongside shard payloads widened by WidenSetFieldBytes. Both go through this one function, so the canonical schema block and every shard's schema block cannot end up describing different layouts — a divergence that would pass ValidateStructuralCohesion nowhere and decode as garbage everywhere.
Names, nullability, descriptions, CSV indices, decimal metadata and the inline dictionary pointer ride across by value, so bit i still means dictionary entry i. The input schema is never modified.
func WidenSetFieldBytes ¶
func WidenSetFieldBytes(cohort []byte, field string, target encoding.FieldType) ([]byte, *encoding.WidenReport, error)
WidenSetFieldBytes rewrites a single-file .pulse cohort held in memory so the named set field is stored at the wider rung target, returning the new cohort bytes. The input slice is never modified.
It is the entry point for callers that already hold a cohort as bytes — a shard payload lifted out of a Zip64 archive, most of all. Callers holding a path want WidenSetFieldFile, which adds the atomic temp/fsync/rename dance around the same engine.
func WidenSetFieldFile ¶
func WidenSetFieldFile(fsys afero.Fs, path, field string, target encoding.FieldType) (*encoding.WidenReport, error)
WidenSetFieldFile rewrites the cohort at path in place, widening the named set field to target.
The rewrite is ATOMIC, and that is a correctness requirement rather than a nicety: widening changes the field's stride, so every record is re-laid-out and every field after the widened one moves. A half- written cohort is not a cohort that fails to open — it is one that opens and decodes every row past the failure point as garbage, with the schema block confidently describing a layout the tail does not have. So the new cohort is built into a temp file beside the original, fsynced, and only then renamed over it. Any failure before the rename removes the temp file and leaves the original byte-identical.
Sidecars are NOT updated: a widened cohort changes length, so the point-lookup index (`cohort.pulse.<keyhash>.idx`) and the SPSS metadata sidecar both invalidate themselves through their own size+mtime fingerprints on the next read. Rebuilding them is the caller's business.
func WidestSetType ¶
WidestSetType returns the top rung of the ladder — the widest set_* type Pulse has. Callers that must NAME the ceiling in a diagnostic use this rather than writing "set_u256" into a string, so the message cannot outlive the type it names.
func WriteIndex ¶
WriteIndex serializes idx to w.
Format (v3):
9-byte header: magic "PULSEIDX" + version 0x03
32-byte fingerprint: raw SHA-256 digest
key-spec block:
u16 key_count
per key: u16 name_len + utf8 name, u8 type
source-stat snapshot:
u64 source_size
i64 source_mod_time_unix_nano
u32 bucket_count
bucket-offset table (fixed-width, bucket_count entries):
per bucket: u64 byte_offset — relative to the start of the
bucket-data region (i.e. relative to the byte immediately
following this table). offsets[0] is always 0.
bucket-data region (bucket_count self-delimited blocks, in order):
per bucket:
u32 entry_count
per entry:
u16 key_len + raw key bytes
u32 row_id_count
per row_id: u64 (little-endian)
The offset table's fixed width is what lets a reader (ReadBucketByKey) seek directly to entry bucketIndex without parsing any other bucket: offsetTableStart + bucketIndex*8 locates the one relevant offset, and bucketDataStart + offsets[bucketIndex] locates that bucket's self-delimited data. See IndexMeta's doc comment for the anchor definitions.
func WriteIndexFile ¶
WriteIndexFile serializes idx and writes it to path on fsys, creating or truncating the file as needed. All sidecar index I/O goes through afero.Fs so callers get the same hermetic-test story as the rest of Pulse's file surface (fs.NewMemMap() in tests, fs.Default() / a caller-injected afero.Fs in production).
func WriteIndexHeader ¶
WriteIndexHeader writes the sidecar index header (magic + version) to w.
func WriteIndexManifest ¶
func WriteIndexManifest(fsys afero.Fs, cohortPath string, m *IndexManifest) error
WriteIndexManifest serialises m to cohortPath's derived manifest path through fsys (never raw os), indented because the document's second job is to be read by a human debugging a corpus. Entries are sorted first, so the same set of indexes always produces the same bytes.
func WritePreamble ¶
WritePreamble writes the header and schema block for s at the version its content requires ([Schema.RequiredFormatVersion]). A schema that uses no 0x02 feature produces bytes identical to [WriteHeader] + [WriteSchema].
func WriteSchemaDoc ¶
WriteSchemaDoc emits a `_schema.pulse` payload: standard Pulse header, standard schema block, then the sharding metadata extension (magic "SHRD" + u64 aggregate_record_count + u16 shard_count, little-endian). The result is a valid single-file Pulse cohort with zero records, so legacy readers that don't know about the SHRD extension can still parse the header + schema and stop at the (empty) record region — the extension bytes live where records would otherwise begin and a header-only reader (`Inspect`) ignores them.
Types ¶
type Archive ¶
type Archive struct {
// contains filtered or unexported fields
}
Archive is a parsed Pulse shard archive: zip central-directory cache plus the underlying ReaderAt. Callers Open or OpenAt entries by name. The zero value is not usable; construct via OpenArchive.
func OpenArchive ¶
OpenArchive parses the zip central directory at the tail of r and returns an Archive ready for entry enumeration. The ReaderAt is retained — the caller is responsible for its lifetime. Returns PULSE_COHORT_COMPRESSED when r is a zstd transfer artifact (every archive entry point — shard verify / compact / remove / add / list / extract — routes through here, so none of them misreports "decompress first" as a bad zip magic), PULSE_ARCHIVE_MAGIC_INVALID when r does not start with the zip magic, and PULSE_ARCHIVE_CORRUPT when the EOCD or central directory cannot be parsed.
func (*Archive) Entries ¶
func (a *Archive) Entries() []ArchiveEntry
Entries returns every entry in central-directory order (equals shard insertion order for Pulse-written archives). The slice is a fresh copy; callers may mutate freely.
func (*Archive) IsStored ¶
IsStored reports whether the named entry uses store-only (Method 0) compression. Pulse-written shards are always store-only; this helper is exposed for callers that want to confirm the contract before using OpenAt on a section reader.
func (*Archive) Open ¶
func (a *Archive) Open(name string) (io.ReadCloser, error)
Open returns an io.ReadCloser over the named entry's payload. Returns PULSE_SHARD_MISSING when no entry of that name exists in the central directory. Suitable for streaming reads; the closer must be drained or closed when done.
func (*Archive) OpenAt ¶
func (a *Archive) OpenAt(name string) (io.SectionReader, error)
OpenAt returns an io.SectionReader covering the named entry's stored payload. Only safe for store-only (Method 0) entries — Pulse always writes Method 0, but third-party archives that pass through this path may carry deflated entries; the caller should verify the entry's Method (see Archive.IsStored) before consuming the section reader. Returns PULSE_SHARD_MISSING for unknown names.
func (*Archive) PeekShardHeader ¶
PeekShardHeader reads the first encoding.HeaderSize bytes of the named entry and validates them as a single-file Pulse header (magic + format version). Returns PULSE_SHARD_HEADER_INVALID on mismatch so callers can surface a structured error without parsing the full schema. Used when peeking shard metadata on first read.
func (*Archive) PeekShardRecordCount ¶
PeekShardRecordCount opens the named entry, reads its Pulse header and schema block, validates both, and computes the record count from the remaining bytes divided by the schema's per-record size. Used by `Pulse.Open` to populate `ShardEntry.RecordCount` from the per-shard headers (the source of truth — `_schema.pulse`'s AggregateRecordCount is only a sanity check).
The schema block is read LAYOUT-ONLY (ReadSchemaLayout): every categorical / set dictionary and parent-group entry table is read past and discarded, never allocated — the stride needs only the field types, nullability, vector dims and group layout, all still read and validated. A full decode of a shard with 100,000-entry dictionaries allocated tens of megabytes only to throw them away (TestPeekShardRecordCount_SkipsDictionaries); the count is the one a full decode derives (TestPeekShardRecordCount_LayoutMatchesFullDecode).
The records themselves are never read from a stored (Method 0) entry — every Pulse-written shard: the count is the central directory's entry size less the header and schema bytes just consumed, over the stride, so peeking every shard of an archive reads its headers, not its payload (TestPeekShardRecordCount_ReadsNoRecords). A deflated entry has no payload length short of inflating it, so it is drained.
Returns PULSE_SHARD_HEADER_INVALID when the shard's bytes are not a valid single-file Pulse cohort (bad magic, unsupported version, truncated header or schema).
type ArchiveEntry ¶
type ArchiveEntry struct {
// Name is the zip entry name (basename inside the archive). For
// flat archives this is the shard filename, e.g. "20190101.pulse".
Name string
// Size is the uncompressed payload size in bytes.
Size int64
// Offset is the byte offset of the payload within the archive.
Offset int64
}
ArchiveEntry is a single payload inside a Pulse shard archive. Offset is the byte offset of the entry's compressed/stored payload within the archive (the equivalent of zip.File.DataOffset), NOT the offset of the local file header. Pulse stores entries uncompressed (Method 0), so Offset is a direct pointer into the shard's bytes.
type CohesionWarning ¶
type CohesionWarning struct {
Code string `json:"code"`
Message string `json:"message"`
Details map[string]any `json:"details,omitempty"`
}
CohesionWarning is a structured non-fatal divergence emitted by the schema-cohesion validators. Its shape mirrors the descriptor envelope's {code, message, details} entries so callers (typically `pulse shard add` and `pulse shard verify`) can forward warnings through the standard --json output without reshaping.
encoding/ stays free of descriptor/ imports — this is the local equivalent.
func ValidateStructuralCohesion ¶
func ValidateStructuralCohesion(canonical, incoming *encoding.Schema) ([]CohesionWarning, error)
ValidateStructuralCohesion verifies that incoming's structural schema is byte-equal to canonical's. The check is field-count strict and per-field strict on:
- Name
- Type byte
- ByteOffset
- BitPosition
- Categorical-width identity (a categorical_u8 cannot become a categorical_u16 across shards — the width is fixed at folder creation)
- Vector dim, for a vec_f32 / vec_f64 field (the element type is the type byte). The dim decides the stride but is not in the type byte, so a trailing vector's dim mismatch is caught nowhere else.
- Nullable, one-directionally (details.dimension "nullable"; see nullabilityCohesion): a non-nullable canonical field refuses a nullable incoming one, and a nullable canonical field admits a non-nullable incoming one only when the null bitmaps are the same width and the field is no parent-group member.
Descriptions are advisory and may diverge across shards; per-field divergence emits a PULSE_SHARD_DESCRIPTION_DIVERGENCE warning but does NOT fail validation. A vec field's labels, kind and scale range are description-class the same way (details.dimension vector_labels / vector_kind / vector_scale). Any other mismatch returns a coded PULSE_SHARD_SCHEMA_MISMATCH error.
This validator does NOT compare dictionaries — dictionary cohesion is governed by the append-only prefix rule (see ValidateDictPrefixRule).
type ConstantDetector ¶
type ConstantDetector struct {
// contains filtered or unexported fields
}
ConstantDetector tracks, over a stream of logical rows of an ungrouped schema, which fields still hold the value of the first row. Two cells are the same value iff their on-wire bytes are equal AND their null bits are equal: a field that is null on every row is a (null) constant, a field that is one value except for some nulls is NOT constant. The comparison is conservative on null cells — two nulls whose placeholder bytes differ count as different, which can only forgo an elision, never make one wrong.
func NewConstantDetector ¶
func NewConstantDetector(flat *encoding.Schema) (*ConstantDetector, error)
NewConstantDetector prepares a detector for logical rows of flat, an ungrouped schema (pass a grouped schema's Logical() view).
func (*ConstantDetector) ConstantFields ¶
func (d *ConstantDetector) ConstantFields() []int
ConstantFields returns the logical indices of the fields that held one value across every observed row, ascending. Nil before any row: over zero rows no field has a value to be constant at.
func (*ConstantDetector) FirstRow ¶
func (d *ConstantDetector) FirstRow() []byte
FirstRow returns a copy-owned view of the first observed row (nil before any row). It is the value every still-constant field holds.
func (*ConstantDetector) IsNullConstant ¶
func (d *ConstantDetector) IsNullConstant(fi int) bool
IsNullConstant reports whether constant field fi is constant at NULL (as opposed to constant at a value). Meaningful only for a field ConstantFields returned.
func (*ConstantDetector) Observe ¶
func (d *ConstantDetector) Observe(lrow []byte) error
Observe folds one logical row into the detector. A row of the wrong width is ENCODING_INVALID.
func (*ConstantDetector) Rows ¶
func (d *ConstantDetector) Rows() int64
Rows is the number of rows observed.
func (*ConstantDetector) Stride ¶
func (d *ConstantDetector) Stride() int
Stride is the logical row width Observe expects.
type ConstantPlan ¶
type ConstantPlan struct {
// Spec is the constant group to encode, or nil when nothing is
// elided (see Skipped).
Spec *GroupSpec
// Fields names the elided fields in logical order (Spec.Members).
Fields []string
// Retained names a constant field deliberately kept in the row
// because eliding every field would leave a zero-byte stride, from
// which no record count can be derived. Empty otherwise.
Retained string
// BytesSaved is the net file-size reduction: the per-row bytes
// removed times the row count, minus the schema-block growth. Zero
// when nothing is elided.
BytesSaved int64
// Skipped says why nothing was elided ("" when Spec is set):
// "too_few_rows", "no_constant_fields" or "not_smaller".
Skipped string
}
ConstantPlan is PlanConstantElision's decision.
func PlanConstantElision ¶
func PlanConstantElision(d *ConstantDetector, reserved []string) (*ConstantPlan, error)
PlanConstantElision decides, from a detector that has observed EVERY row of the cohort, which fields to elide as one constant group. reserved names fields already claimed by another group (a field belongs to at most one); they are never elided. The rules:
- fewer than MinElisionRows rows: nothing is elided;
- when the candidates would leave no field in the row (every field constant), the LOWEST-index candidate stays in the row, so the physical stride stays positive;
- the elision must make the file strictly smaller: the per-row bytes removed times the row count must exceed the schema block's growth (the constant group's descriptor and entry, and the extension block framing). A small cohort whose constants cost more to declare than they save is left alone.
It never changes a value: the plan's group is encoded by GroupEncoder, which refuses a constant member that changes on any row.
func PlanConstantElisionFor ¶
func PlanConstantElisionFor(flat *encoding.Schema, constant []int, rows int64, reserved []string) (*ConstantPlan, error)
PlanConstantElisionFor is PlanConstantElision for a caller that decided constancy itself over EVERY row of a cohort whose final schema is flat: constant lists the constant fields' logical indices (ascending) and rows the row count. The rules and the arithmetic are PlanConstantElision's — only the row the saving is sized on is a zeroed stand-in, which cannot change a byte count because entries are fixed-width. Import predict uses it: it sees every row but not in the final schema's layout (a later null can still promote a field).
type DecodeFields ¶
DecodeFields runs the existing per-field decoder loop for the listed fields together. The Fields slice is in schema order; the caller walks it exactly as the unprojected decoder would, so bit-packed neighbours (which on-wire each consume one byte via ReadBit / ReadNibble) stay correctly aligned.
Fields is non-empty for any segment the builder emits.
Indices is parallel to Fields: Indices[k] is the position of Fields[k] in Schema.Fields, recorded by BuildDecodePlan's own schema walk. It is what lets the reuse decoder key an IndexedReusableRecord write by schema position across SkipBytes gaps without a name lookup. A hand-built segment that omits it (len(Indices) != len(Fields)) still decodes: the reader recovers the positions on a slow path.
type DecodePlan ¶
type DecodePlan struct {
Segments []Segment
}
DecodePlan is a pre-computed walk of one record's on-wire bytes that elides ranges the caller will not consume. It is produced by BuildDecodePlan(schema, retained) given a retained-field set, and consumed by the streaming iterator so unprojected byte ranges turn into a single advance-cursor step instead of a per-field typed read.
A DecodePlan is a pure function of (Schema, retained-set). It carries no file content, no offsets into a particular reader, and no mutable state. Two calls with the same inputs return byte-equivalent Segments in identical order.
func BuildDecodePlan ¶
func BuildDecodePlan(s *encoding.Schema, retained []string) (*DecodePlan, error)
BuildDecodePlan produces a deterministic DecodePlan for this schema given the set of field names the caller intends to consume.
Rules:
- Contiguous unprojected, non-bit-packed fields coalesce into a single SkipBytes whose N is the sum of those fields' ByteSize().
- Bit-packed neighbours (any contiguous run of FieldType.IsBitPacked() fields) form one group. The group becomes one DecodeFields segment if any member is retained; otherwise it becomes one SkipBytes whose N equals the group's on-wire byte size — which mirrors Schema.RecordByteSize: 1 byte per bit-packed field. (BitPosition is a per-field annotation; ReadBit / ReadNibble each consume a full byte today, so a run of K bit-packed fields occupies K bytes on-wire.)
- Trailing null bitmap: if Schema.HasBitmap() and any nullable field is retained, append a DecodeFields segment carrying every nullable field in schema order (the iterator decodes the bitmap, then walks this list to surface null flags). If HasBitmap() but no nullable field is retained, append a SkipBytes{N: Schema.BitmapByteSize()}. If !HasBitmap(), no bitmap segment is emitted.
- Empty retained set: the plan is a single SkipBytes covering the full record stride (RecordByteSize, which already includes the bitmap if any).
- Full retained set (every schema field present in retained): no SkipBytes segments — the output is equivalent to the existing per-field walk.
retained is consulted as a set (order does not matter); duplicates and names not present in the schema are silently ignored. The function never returns an error today, but the signature carries one for forward-compatibility with future shape validation (e.g. once extension-driven retained sets carry wildcard tokens).
A grouped (0x02) schema's plan walks its LOGICAL row — the stream a RecordReader over that schema decodes — so it is the plan of Logical(): SkipBytes widths are logical widths, and the empty plan covers the logical stride.
type DedupGate ¶
type DedupGate struct {
// RatioFloor is the rows-per-tuple floor below which a group draws
// PULSE_DEDUP_LOW_RATIO. Zero, negative or NaN selects
// DefaultDedupRatioFloor. A floor in (0, 1] disables the
// floor — every ratio is ≥ 1 — leaving only the grows-the-file
// check.
RatioFloor float64
// Strict returns the first finding as a fatal error with the same
// code instead of a warning.
Strict bool
}
DedupGate is the per-group viability policy. The zero value is the default: DefaultDedupRatioFloor, findings as warnings.
func (DedupGate) AssessRatios ¶
func (gt DedupGate) AssessRatios(grouped *encoding.Schema, specs []GroupSpec, rows int64) ([]GroupViability, []*errors.CodedError, error)
AssessRatios applies the ratio floor to the first len(specs) groups of grouped, a schema whose dictionaries hold rows records (the schema the encoder produced). specs are the admitted specs in group order — used only for labels. It returns one GroupViability per spec and one PULSE_DEDUP_LOW_RATIO warning per finding; under Strict the first finding is returned as an error instead. With rows == 0 nothing is measured and nothing is flagged. Constant groups are reported admitted, unjudged.
func (DedupGate) ScreenWidths ¶
func (gt DedupGate) ScreenWidths(flat *encoding.Schema, specs []GroupSpec) ([]GroupSpec, []GroupViability, []*errors.CodedError, error)
ScreenWidths applies the width floor to specs over flat, an ungrouped schema, before any row is read. It returns the specs that survive (in order, each with Ordinal set to its 1-based position in specs unless already set, so labels do not shift when an earlier group is dropped), one GroupViability per INPUT spec (dropped ones carry their widths; admitted ones are completed by AssessRatios), and one PULSE_GROUP_TOO_NARROW warning per dropped group. Under Strict the first narrow group is returned as an error instead. Constant specs pass through unjudged. An unknown member name is PULSE_GROUP_FIELD_UNKNOWN (NewGroupEncoder is the full declaration check; callers normally run it first).
type DictRemap ¶
DictRemap is the per-field index translation produced by MergeDictUnion. The key is the incoming dictionary index; the value is the corresponding canonical (union) dictionary index. Fields whose incoming dictionary is already a prefix of the canonical (union) dictionary are absent from the returned map — callers can skip remapping their record bytes.
type ExactU64Record ¶
ExactU64Record is an optional extension of IndexedReusableRecord that keeps a u64 field's EXACT on-wire word beside its float64 echo. The echo (SetNumericAt) loses every bit past float64's 53-bit mantissa, so two distinct u64 values above 2^53 can share one echo; a consumer that keys or matches on a u64 value (a comparison key_field or centroid `by`, ATTR_CODE_IN) reads the exact word instead.
The reuse decoders call SetExactU64At right AFTER the SetNumericAt echo of every retained u64 field they write, on every path (full stride, plan, run-skip rewrite, grouped members). A sink that does not implement it sees exactly the writes it saw before.
type FieldFilter ¶
FieldFilter returns true for field names whose values should be written into the caller's maps. Used by ReadRecordWithWideProjected to skip map writes for fields the request doesn't read. A nil FieldFilter is equivalent to "keep every field."
type Fingerprint ¶
type Fingerprint [FingerprintSize]byte
Fingerprint is a raw SHA-256 content-hash digest of the source .pulse file an Index was built from. A later story (index build) computes one from the live cohort's bytes at build time; the lookup path recomputes it and compares against the embedded value to detect a stale index without decoding the cohort's records.
func ComputeFingerprint ¶
func ComputeFingerprint(r io.Reader) (Fingerprint, error)
ComputeFingerprint hashes the full contents of r with SHA-256. Callers pass the raw bytes of the source .pulse file (header + schema + records) — identical content always yields an identical Fingerprint, regardless of platform or filesystem.
type GroupEncoder ¶
type GroupEncoder struct {
// contains filtered or unexported fields
}
GroupEncoder turns logical rows (the 0x01 row of an ungrouped schema) into physical rows of the grouped schema, building each group's dictionary as it goes: a row's tuple is looked up, or appended as a new entry. It is the one encode path shared by import and retro-dedup; the dictionary must precede the records on the wire, so callers spool the physical rows and write the preamble (Schema) last.
func NewGroupEncoder ¶
func NewGroupEncoder(flat *encoding.Schema, specs []GroupSpec) (*GroupEncoder, error)
NewGroupEncoder prepares an encoder that groups flat (a schema without groups — pass a grouped schema's Logical() view) by specs. Field names are resolved here: an unknown name is PULSE_GROUP_FIELD_UNKNOWN, a field named by two groups PULSE_GROUP_FIELD_CONFLICT (naming both groups by Label), and an empty group, a field named twice in one group or a key naming a non-member PULSE_GROUP_DECLARATION_INVALID. A shape the format cannot hold (e.g. a zero stride) is ENCODING_INVALID.
func (*GroupEncoder) EncodeRow ¶
func (e *GroupEncoder) EncodeRow(dst, lrow []byte) ([]byte, error)
EncodeRow appends the physical row for logical row lrow to dst.
Failures carry details naming the group (index and Label), the offending field and the row index (0-based, counted by this encoder): a constant group whose members change, or a keyed group whose non-key member disagrees with the entry its key already selected, is PULSE_GROUP_MEMBER_NOT_CONSTANT; a dictionary that would outgrow MaxGroupEntries is PULSE_GROUP_ENTRIES_EXHAUSTED (never a wrap). A logical row of the wrong width is ENCODING_INVALID.
func (*GroupEncoder) EncodeStream ¶
EncodeStream encodes every logical row read from lr (a logical record stream of the encoder's input schema, e.g. from NewLogicalStream) and writes the physical rows to spool, in order. It returns the number of rows encoded. The dictionaries precede the records on the wire, so spool is NOT the cohort: once the stream is drained the caller writes WritePreamble(Schema()) and then the spooled bytes. Holding the spool is the caller's choice — memory (DedupCohort) or a temp file (io.DedupJob, whose memory stays O(dictionaries) whatever the cohort size). A failing row leaves spool holding only the rows before it.
func (*GroupEncoder) LogicalStride ¶
func (e *GroupEncoder) LogicalStride() int
LogicalStride is the width of the logical rows EncodeRow takes.
func (*GroupEncoder) PhysicalStride ¶
func (e *GroupEncoder) PhysicalStride() int
PhysicalStride is the grouped schema's RecordByteSize.
func (*GroupEncoder) Rows ¶
func (e *GroupEncoder) Rows() int64
Rows is the number of rows encoded so far.
func (*GroupEncoder) Schema ¶
func (e *GroupEncoder) Schema() *encoding.Schema
Schema returns the grouped schema with every dictionary built so far. Its Entries alias the encoder's storage: take it after the last row.
func (*GroupEncoder) Seed ¶
func (e *GroupEncoder) Seed(seed *encoding.Schema) error
Seed pre-loads every group dictionary with seed's entries, in order, before any row is encoded: a row whose tuple (or key) is already there gets that entry's index, and a new tuple is appended AFTER them. It is how a shard joins an archive — the archive's canonical dictionary is the seed, so every entry the archive's stored shards reference keeps its index (the union is canonical-first, the prefix rule every shard relies on) and only the arriving shard's rows are renumbered.
seed must declare the encoder's exact group layout — same group count, kinds, members and key flags — with the same entry widths; anything else is ENCODING_INVALID (a layout reconciliation is the caller's job, before the encoder is built). Seeding goes through the same key rule EncodeRow uses, so a keyed group whose seed repeats a key is refused as a corrupt dictionary, and the u32 ceiling applies to the seeded count exactly as to an appended one (PULSE_GROUP_ENTRIES_EXHAUSTED). Seed must be called before the first EncodeRow.
type GroupEntryDecoder ¶
type GroupEntryDecoder struct {
// contains filtered or unexported fields
}
GroupEntryDecoder writes a chosen subset of one group's members from any entry into a record, with the member geometry resolved once. It is DecodeGroupEntry restricted to the members a per-entry consumer (filter precompute) actually reads, at O(chosen members) per entry instead of O(all members²): DecodeGroupEntry re-derives each member's offset and the entry geometry per member, which dominates on a wide parent block.
func NewGroupEntryDecoder ¶
NewGroupEntryDecoder compiles a decoder for the members of group g at the given LOGICAL field indices (each must be a member of g).
func (*GroupEntryDecoder) Decode ¶
func (d *GroupEntryDecoder) Decode(e int, sink IndexedReusableRecord) error
Decode writes the chosen members of entry e into sink with exactly DecodeGroupEntry's side effects for those members.
type GroupIndexHeadroom ¶
type GroupIndexHeadroom struct {
Group int `json:"group"`
Label string `json:"label"`
// Kind is "indexed" or "constant".
Kind string `json:"kind"`
// Entries is the canonical dictionary's entry count.
Entries int `json:"entries"`
// Capacity is the entry ceiling: the u32 index space for an indexed
// group, exactly 1 for a constant group.
Capacity uint64 `json:"capacity"`
// Headroom is Capacity - Entries: how many more distinct tuples the
// archive can absorb. A constant group always reports 0 — the next
// distinct value promotes it to an indexed group across the archive.
Headroom uint64 `json:"headroom"`
}
GroupIndexHeadroom reports how much of one parent group's index space the canonical dictionary has consumed. It is the group counterpart of SetWidthHeadroom and exists for the same reason: a union past the u32 index space is fatal (PULSE_SHARD_DICT_WIDTH_OVERFLOW) with nowhere to widen to, and a constant group that an arriving shard disagrees with is promoted to an indexed group by an ARCHIVE-WIDE rewrite — both are foreseeable from here rather than discovered by paying for them.
func GroupIndexHeadroomFor ¶
func GroupIndexHeadroomFor(s *encoding.Schema) []GroupIndexHeadroom
GroupIndexHeadroomFor returns one GroupIndexHeadroom per parent group of s, in group order; nil for a schema without groups.
type GroupIndexRecord ¶
GroupIndexRecord is an optional extension of IndexedReusableRecord for grouped (0x02) schemas. After the reuse decoders (ReadRecordReused / ReadRecordReusedWithPlan) finish a grouped row, they hand the record the row's dictionary entry per group: idx[g] is the entry of Schema.Groups[g] every member of g carries on this row (0 for a constant group), and schema is the reader's schema — the one whose group dictionaries the entries address. idx is the reader's own buffer — an implementation copies it and must not retain the slice. A nil idx means "no index for this row" (the row was skipped whole by a plan that decodes nothing); an implementation then reports no index, and it must also report none when schema is not the schema it resolves group dictionaries against, since an entry index is meaningless against any other dictionary.
It is how a per-dictionary-entry precompute (filter precompute) learns which entry a row holds without encoding/ importing internal/processing/ and without the precompute reaching into reader internals. An ungrouped schema never calls it; neither do the map decoders.
type GroupSpec ¶
type GroupSpec struct {
// Kind is GroupKindIndexed (a dictionary + a u32 per row) or
// GroupKindConstant (one entry, nothing per row).
Kind encoding.GroupKind
// Members names the member fields, in any order.
Members []string
// Key optionally names the subset of Members whose values determine
// the tuple. When set, the encoder REFUSES a row whose non-key
// members disagree with an earlier row carrying the same key — a
// declaration that is not actually a parent group is an error, not
// a silently larger dictionary. Empty means every member is key.
Key []string
// Ordinal, when > 0, is the 1-based position Label reports instead
// of the spec's position in the list it was passed in. A caller
// that drops a declared group before encoding (the viability gate)
// sets it so the surviving groups keep the names the user declared
// them under. Never written to the file.
Ordinal int
}
GroupSpec declares one parent group by member field NAME.
func GroupSpecOf ¶
GroupSpecOf reconstructs the name-level declaration of group g from the descriptor, for display: Members in member (logical field) order, and Key only when the key is a strict subset of the members — the encoder marks every member key when none was declared, so an all-key group reads back as "no key", exactly as the declaration that produced it most likely did. The format carries no group name and no declaration ordinal, so the spec's Label numbers the group by its position in THIS file: a group dropped by the viability gate at import shifts later groups down by one relative to their declared names.
func GroupSpecsOf ¶
GroupSpecsOf returns the name-level declaration of every group of s, in group order — the specs a GroupEncoder needs to reproduce s's exact group layout (members, kinds, key flags) over s.Logical().
type GroupViability ¶
type GroupViability struct {
// Group is the 0-based position in the spec list given to the gate.
Group int `json:"group"`
// Label names the group ("group 2 [key: order_id]").
Label string `json:"label"`
// Verdict is one of the GroupVerdict* constants.
Verdict string `json:"verdict"`
// Reason is the coded finding behind a non-admitted verdict
// (PULSE_GROUP_TOO_NARROW or PULSE_DEDUP_LOW_RATIO); empty when
// admitted.
Reason string `json:"reason,omitempty"`
// Rows is the record count the ratio was measured over (0 for a
// group dropped before the row pass).
Rows int64 `json:"rows"`
// EntryCount is the number of distinct tuples (0 when dropped).
EntryCount int `json:"entry_count"`
// EntryWidth is the bytes per dictionary entry.
EntryWidth int `json:"entry_width"`
// MemberRowBytes is the bytes the members occupy in a flat row.
MemberRowBytes int `json:"member_row_bytes"`
// IndexWidth is the bytes of the per-row index replacing them.
IndexWidth int `json:"index_width"`
// DictionaryBytes is EntryCount × EntryWidth: what the group holds
// RESIDENT in memory for as long as the cohort is open.
DictionaryBytes int64 `json:"dictionary_bytes"`
// DescriptorBytes is the group's descriptor in the schema block,
// dictionary excluded.
DescriptorBytes int `json:"descriptor_bytes"`
// Ratio is Rows ÷ EntryCount (0 when not measured).
Ratio float64 `json:"ratio"`
// BreakEvenRatio is the ratio at which the dictionary exactly eats
// the per-row saving: EntryWidth ÷ (MemberRowBytes − IndexWidth).
// 0 when the group is too narrow to ever break even.
BreakEvenRatio float64 `json:"break_even_ratio"`
// RatioFloor is the floor the ratio was judged against.
RatioFloor float64 `json:"ratio_floor"`
// UndedupedBytes is Rows × MemberRowBytes: the members stored flat.
UndedupedBytes int64 `json:"undeduped_bytes"`
// DedupedBytes is Rows × IndexWidth + DictionaryBytes +
// DescriptorBytes: the same information grouped.
DedupedBytes int64 `json:"deduped_bytes"`
// ByteDelta is DedupedBytes − UndedupedBytes: negative is a saving,
// zero or positive means grouping made the file no smaller.
ByteDelta int64 `json:"byte_delta"`
}
GroupViability is one group's gate verdict and the numbers behind it. Every byte figure is exact for the group in isolation; the per-row null bitmap is left out (moving a nullable member out of the row can shrink it by at most one byte per row, so ByteDelta can only understate a saving, never invent one).
func AssessGroup ¶
AssessGroup measures group g of grouped over rows records against floor, without judging it (Verdict is admitted, Label empty). Any caller that reports ratio figures — import predict, retro-dedup — derives them here so the arithmetic exists once.
func (GroupViability) Finding ¶
func (v GroupViability) Finding() (low, grows bool)
Finding applies the ratio-floor rule to an assessed indexed group: low is true when the ratio is below RatioFloor or the group makes the file no smaller (grows). It is the ONE judgement AssessRatios and a read-only report (inspect) share, so the two cannot disagree about which group is low-ratio. A constant group, or one measured over zero rows, is never low.
func (GroupViability) Viable ¶
func (v GroupViability) Viable() bool
Viable reports whether the group was written without a finding.
type Index ¶
type Index struct {
Fingerprint Fingerprint
Keys []IndexKeySpec
Buckets []IndexBucket
// SourceSize is the byte length of the source .pulse file, as
// reported by the filesystem at build time. Paired with
// SourceModTime to give a freshness check (Service.VerifyIndex) a
// cheap fast-path that avoids re-hashing the whole cohort: if
// either value no longer matches the current file's stat, the
// cohort has definitely changed and the index is definitely stale
// — no need to pay for a full content hash to know that. A
// matching size+mtime pair is NOT by itself sufficient proof of
// freshness (mtime resolution and pathological same-size rewrites
// both make silent false negatives possible), so a match always
// falls through to a full Fingerprint recompute for a conclusive
// answer. See Service.Lookup / Service.VerifyIndex.
SourceSize uint64
// SourceModTime is the source .pulse file's modification time, as
// Unix nanoseconds, at build time. See SourceSize's doc comment for
// the fast-path contract this pairs with.
SourceModTime int64
}
Index is the full in-memory representation of a point-lookup sidecar index: the .pulse Fingerprint it was built from, the ordered key spec, the fixed-size hash-bucket table (len(Buckets) is the table's bucket count), and a source-stat snapshot (SourceSize / SourceModTime) taken at build time. Encoding/decoding here is pure codec — populating an Index from a live cohort and serving lookups against one are later stories' responsibility; this package stays free of internal/service and processing imports.
func ReadIndex ¶
ReadIndex deserializes a sidecar Index from r. Returns ENCODING_INVALID on a truncated/corrupt header, an unknown key FieldType byte, or a truncated body. A full read — every bucket's every entry — for callers that genuinely need the whole index (e.g. Service.ListIndexes' distinct-key / indexed-record summary). Callers that only need metadata should prefer ReadIndexMeta; callers that only need one bucket should prefer ReadBucketByKey.
type IndexBucket ¶
type IndexBucket struct {
Entries []IndexEntry
}
IndexBucket is one slot of the on-disk hash table. A well-distributed build populates each bucket with zero or one entries on average; more than one entry means a hash collision on BucketIndex, resolved by the reader scanning Entries for a byte-equal Key.
func ReadBucketByKey ¶
func ReadBucketByKey(rs io.ReadSeeker, meta *IndexMeta, key []byte) (*IndexBucket, error)
ReadBucketByKey seeks directly to and parses exactly ONE bucket of a v3 sidecar index — the bucket key hashes to via BucketIndex(key, meta.BucketCount) — never reading the bucket-offset table's other entries or any other bucket's data. meta must have been produced by ReadIndexMeta (or ReadIndexMetaFile) against the SAME sidecar rs reads from; rs's absolute byte offset 0 must correspond to the start of that sidecar (a freshly opened file handle, or one explicitly Seek'd back to 0), since meta.OffsetTableStart is an absolute file offset.
Returns an empty *IndexBucket (no error) when meta.BucketCount == 0 (an empty index has no buckets to seek into — mirrors the read side's existing empty-index handling). Returns ENCODING_INVALID on a seek failure, a truncated/corrupt bucket-offset entry, an offset that decodes to a value larger than a signed 64-bit byte offset can represent, or a truncated/corrupt bucket-data block at the resolved position.
type IndexEntry ¶
IndexEntry is one hash-bucket slot's payload: the exact on-wire key bytes (the concatenation of every key field's on-wire representation, in key-spec order) plus every row-id sharing that key. RowIDs holds more than one value whenever the source cohort has duplicate key values — the sidecar format is multimap-capable from day one so a later epic's multiplicity work needs no format change.
type IndexKeySpec ¶
IndexKeySpec describes one ordered key column carried in the sidecar index's key-spec block: the schema field name and its on-wire FieldType. Composite keys (more than one IndexKeySpec) concatenate every key field's on-wire byte representation, in key-spec order, to form a single IndexEntry.Key.
type IndexManifest ¶
type IndexManifest struct {
FormatVersion string `json:"format_version"`
Kind string `json:"kind"`
Cohort string `json:"cohort"`
Indexes []IndexManifestEntry `json:"indexes"`
}
IndexManifest is the catalog document itself. Cohort carries the cohort's BASENAME for the same location-independence reason IndexManifestEntry.IndexPath does.
func NewIndexManifest ¶
func NewIndexManifest(cohortPath string) *IndexManifest
NewIndexManifest returns an empty, well-formed manifest for cohortPath.
func ReadIndexManifest ¶
ReadIndexManifest loads the sidecar index manifest for cohortPath. The boolean reports PRESENCE: false with a nil error means no manifest exists, which is not an error — a cohort whose indexes were built by a Pulse that predates the manifest legitimately has none, and Service.ListIndexes falls back to the directory listing there.
A manifest that EXISTS but cannot be trusted is refused with PULSE_INDEX_MANIFEST_INVALID naming the path: malformed JSON, a foreign Kind, an unrecognised FormatVersion, or an entry with no index_path. The alternative — degrading to the directory listing — would succeed on a local disk and answer "no indexes" on a bucket, which is the same silent, backend-dependent divergence this whole mechanism exists to remove.
func (*IndexManifest) Remove ¶
func (m *IndexManifest) Remove(indexBase string) bool
Remove drops the entry for the given sidecar basename, reporting whether one was present.
func (*IndexManifest) Upsert ¶
func (m *IndexManifest) Upsert(entry IndexManifestEntry)
Upsert records entry, replacing any earlier entry for the same sidecar basename, and keeps Indexes sorted by basename so the document is byte-stable across rebuilds regardless of the order indexes were built in.
type IndexManifestEntry ¶
type IndexManifestEntry struct {
IndexPath string `json:"index_path"`
KeyHash string `json:"key_hash"`
Keys []IndexManifestKey `json:"keys"`
DistinctKeys int `json:"distinct_keys"`
IndexedRecords int `json:"indexed_records"`
}
IndexManifestEntry describes one sidecar index. IndexPath is the sidecar's BASENAME, not a full path, so the document survives the cohort tree being moved, copied or served under a different prefix — a manifest holding absolute paths would be wrong the first time a corpus was synced anywhere.
DistinctKeys / IndexedRecords are recorded at build time so a listing served from this manifest needs no read of any sidecar. They are facts about a deterministic rebuild artifact (BuildIndex over unchanged content produces a byte-identical sidecar), not a freshness claim: freshness lives in each sidecar's own Fingerprint and source-stat snapshot, and nothing here is consulted for it.
func IndexManifestEntryFor ¶
func IndexManifestEntryFor(indexPath string, idx *Index) IndexManifestEntry
IndexManifestEntryFor builds the manifest entry describing idx, the index just written to indexPath. Counts come from the index's own bucket table rather than from a re-scan of the cohort.
func (IndexManifestEntry) KeyNames ¶
func (e IndexManifestEntry) KeyNames() []string
KeyNames returns the entry's ordered key column names — the tuple a caller passes to Lookup / BuildIndex / VerifyIndex, in the order those calls require.
type IndexManifestKey ¶
IndexManifestKey is one key column in a manifest entry's ordered key tuple. Type carries FieldType.String() — the manifest reports the type as well as the name because a caller composing a lookup key has to render its value as a literal, and the per-type literal rules (a categorical's dictionary entry, a datetime's parse form) differ.
type IndexMeta ¶
type IndexMeta struct {
Fingerprint Fingerprint
Keys []IndexKeySpec
SourceSize uint64
SourceModTime int64
// BucketCount is the sidecar's hash-table bucket count — the same
// value BucketIndex(key, bucketCount) needs to resolve a lookup key
// to a bucket slot.
BucketCount uint32
// OffsetTableStart is the absolute byte offset, from the start of
// the sidecar file, where the fixed-width bucket-offset table
// begins. See the type doc comment for the full seek-math contract.
OffsetTableStart int64
}
IndexMeta is the cheap, bucket-data-free subset of a sidecar index: everything ReadIndexMeta can read with one short linear pass — header, fingerprint, key-spec, source-stat snapshot, and bucket count — plus the two byte-offset anchors a seekable reader needs to jump straight to one bucket's data without reading the offset table or any other bucket:
- OffsetTableStart: the absolute byte offset (from the start of the sidecar) where the fixed-width bucket-offset table begins. Entry i of that table lives at OffsetTableStart + i*8.
- BucketDataStart (a derived method, not a stored field): the absolute byte offset where the bucket-data region begins, immediately after the offset table (OffsetTableStart + bucketCount*8). Every bucket-offset table entry is a byte offset RELATIVE to this anchor, so bucket i's absolute data position is BucketDataStart() + offsets[i].
Staleness checks (Service.VerifyIndex) and arity checks (matching a lookup request's key-component count against the sidecar's own key-spec) only ever need this struct — never bucket data.
func ReadIndexMeta ¶
ReadIndexMeta deserializes only the bucket-data-free prefix of a sidecar index from r: header, fingerprint, key-spec, source-stat snapshot, and bucket count — never touching the bucket-offset table or any bucket data. r must start at byte 0 of the sidecar (the same assumption ReadIndex and ReadIndexHeader already make) so the returned IndexMeta.OffsetTableStart — computed analytically from the exact byte lengths of the sections just read, not by seeking — is a correct absolute file offset.
This is the cheap path Service.VerifyIndex's staleness check and a lookup request's key-arity check both want: neither needs a single bucket, let alone the whole table.
func ReadIndexMetaFile ¶
ReadIndexMetaFile opens path on fsys and deserializes only the bucket-data-free IndexMeta prefix — the afero-backed convenience counterpart to ReadIndexMeta, mirroring ReadIndexFile's relationship to ReadIndex. Callers that only need staleness/arity metadata (e.g. Service.VerifyIndex) should prefer this over ReadIndexFile.
func (*IndexMeta) BucketDataStart ¶
BucketDataStart returns the absolute byte offset, from the start of the sidecar file, where the bucket-data region begins — immediately after the fixed-width bucket-offset table. Every bucket-offset table entry is a byte offset relative to this anchor.
type IndexedReusableRecord ¶
type IndexedReusableRecord interface {
SetNumericAt(idx int, value float64)
SetNullFieldAt(idx int)
SetWideFieldAt(idx int, value any)
ClearForRow()
}
IndexedReusableRecord is the index-keyed sibling of ReusableRecord. It exists for the same reason ReusableRecord does — it lets the decoder populate a *processing.Record without encoding/ importing internal/processing/ — but its writes are keyed by the field's POSITION in the reader's schema (the index into Schema.Fields) rather than by name, so a positional record implementation can store the value with a slice index instead of a map hash.
The position is the decoder's own schema-walk counter, never a name→index lookup: ReadRecordReused counts it as it walks Schema.Fields, ReadRecordReusedWithPlan carries it in DecodeFields.Indices (filled by BuildDecodePlan's walk), and the bitmap decoder uses the bitmap bit index, which IS the field position. An implementation therefore MUST be built over the same schema (or a structurally identical one — same field order) the RecordReader was constructed with; the index is meaningless against any other.
Side-effect contract is identical to ReusableRecord, field for field: ClearForRow once per row; a null surfaces as SetNullFieldAt(i) followed by SetNumericAt(i, 0); decimal128 and set_* fields write both the numeric echo and the typed wide value.
When a record implements both interfaces the index-keyed methods win and the name-keyed ones are never called by the reuse decoders. A record that also implements TypedSetRecord receives set masks through it instead of SetWideFieldAt, and one that implements RunSkipRecord gets BeginRunRow in place of ClearForRow and only its changed fields rewritten (reader_runskip.go).
type RecordReader ¶
type RecordReader struct {
// contains filtered or unexported fields
}
RecordReader reads records one at a time from a binary stream. It reads directly from the io.Reader without buffering the entire file.
func NewRecordReader ¶
func NewRecordReader(r io.Reader, schema *encoding.Schema) *RecordReader
NewRecordReader creates a RecordReader. The reader must be positioned immediately after the header and schema (i.e., at the first record byte).
For a grouped (0x02) schema the reader decodes the LOGICAL record: same field indices, same values, same nulls as the ungrouped twin. The map decoders read r through the logical stream (every physical row expanded into the exact row the twin stores); the reuse decoders (ReadRecordReused*, run-skip included) decode the physical row directly and never expand it (group_decode.go). Exactly one physical row is consumed per record on every path, so r's position after a record is the end of that physical record.
func (*RecordReader) GroupIndex ¶
func (rr *RecordReader) GroupIndex(g int) (uint32, bool)
GroupIndex reports the dictionary entry index group g (a position in the grouped schema's Groups) resolved to on the record just decoded: every member of g carries that entry's values. ok is false for an ungrouped schema, before the first record, or when the last record was skipped whole by a decode plan. It is the row -> entry map a per-entry precompute (filter precompute) tests instead of decoding the members.
func (*RecordReader) ReadRecord ¶
ReadRecord reads a single record from the stream, populating the values and nulls maps. Returns io.EOF when no more records are available.
The caller provides pre-allocated maps to avoid per-record allocation. Maps are cleared at the start of each call.
Reuse contract: the maps are owned by the caller. ReadRecord does not retain references to them after returning. If the caller plans to reuse the same maps across calls (the typical pattern), they must consume the populated values BEFORE invoking ReadRecord again, because the next call clears and repopulates the maps in-place. If the caller needs to retain the values past the next call (e.g., collecting Records into a slice for later aggregation), it must pass distinct map instances per record OR copy the contents out before the next ReadRecord call.
To populate typed wide values for fields whose representation does not fit in float64 (decimal128), call ReadRecordWithWide instead and pass a third map.
func (*RecordReader) ReadRecordReused ¶
func (rr *RecordReader) ReadRecordReused(rec ReusableRecord) error
ReadRecordReused reads one record into an existing ReusableRecord, reusing the record's internal maps. Returns io.EOF when the underlying reader is exhausted.
Hot path semantics:
- Caller MUST consume the populated rec before the next call.
- The whole record stride (Schema.RecordByteSize(), including the trailing null bitmap) is read into a reusable per-RecordReader buffer with a SINGLE io.ReadFull, then every field is decoded from a running cursor subslice of that buffer — no per-field read, no copy. This eliminates the per-field io.ReadFull that dominates wide-schema decode.
- Fields are walked in schema order with a running byte cursor, NOT by Field.ByteOffset (bit-packed layout is cursor-driven; stored offsets are unreliable). Each bit-packed field (u4, packed_bool) occupies a whole on-wire byte, matching ReadBit/ReadNibble semantics.
- A short read at end-of-stream surfaces io.EOF exactly as before via mapEOF; a partial trailing record surfaces as io.EOF as well (io.ReadFull maps a nonzero short read to io.ErrUnexpectedEOF, which mapEOF normalizes to io.EOF).
- A record implementing RunSkipRecord is decoded by readStrideRunSkip: same result, but fields whose bytes repeat the previous row are not rewritten.
func (*RecordReader) ReadRecordReusedWithPlan ¶
func (rr *RecordReader) ReadRecordReusedWithPlan(rec ReusableRecord, keep FieldFilter, plan *DecodePlan) error
ReadRecordReusedWithPlan reads one record into an existing ReusableRecord by walking a precomputed DecodePlan, decoding only the fields the plan's DecodeFields segments carry and seeking past the SkipBytes ranges the caller will not consume. It is the plan-aware sibling of ReadRecordReused: same reuse contract (the record's null/wide maps are cleared once via ClearForRow, then populated in place), same io.EOF surfacing, but projection-honoring under reuse.
plan == nil falls back to ReadRecordReused (full-decode reuse path), so an iterator that never installed a plan is byte-identical to today.
keep governs record writes within DecodeFields segments, mirroring ReadRecordWithWidePlan / decodeFieldGroup / decodeBitmap exactly:
- Bit-packed members (u4, packed_bool) of a retained group are ALWAYS consumed for cursor alignment (1 whole on-wire byte each), but their record writes are suppressed when keep rejects them.
- The trailing bitmap segment reads the bitmap once and surfaces nulls only for nullable fields the caller retains.
Side effects match ReadRecordReused field-for-field:
- null → SetNullField(name) + SetNumeric(name, 0);
- decimal128 → SetNumeric(mean-ish scalar) + SetWideField(Decimal128);
- set_u8..set_u64 → SetNumeric(float64 echo) + SetWideField(uint64 mask); set_u128 / set_u256 → SetNumeric(low-64-bit echo) + SetWideField(SetMask).
Unlike ReadRecordReused, this path does NOT read the whole record stride: each DecodeFields group reads only its own on-wire bytes into the reusable buffer with a single io.ReadFull, and SkipBytes segments advance the reader past unread ranges with a single Seek (io.CopyN fallback). On a wide schema with a small retained set that is the projection win under reuse.
func (*RecordReader) ReadRecordWithWide ¶
func (rr *RecordReader) ReadRecordWithWide(values map[string]float64, nulls map[string]bool, wide map[string]any) error
ReadRecordWithWide reads a record and populates a wide map with typed values for decimal128 fields. The wide map may be nil to skip wide population.
func (*RecordReader) ReadRecordWithWidePlan ¶
func (rr *RecordReader) ReadRecordWithWidePlan( values map[string]float64, nulls map[string]bool, wide map[string]any, keep FieldFilter, plan *DecodePlan, ) error
ReadRecordWithWidePlan reads one record by walking a precomputed DecodePlan instead of iterating every schema field. SkipBytes segments advance the underlying reader by N bytes with a single seek (or io.CopyN fallback); DecodeFields segments run the same per-field decode loop the unprojected reader uses, but only for the fields the segment carries.
keep governs map writes within DecodeFields segments. The plan builder emits bit-packed groups together (every member of a group whose retained subset is non-empty), so bit-packed members that the caller did NOT request are still decoded — their on-wire bytes must be consumed to keep the cursor aligned — but their map writes are suppressed by keep. Similarly the trailing bitmap segment carries every nullable field so the iterator can read the bitmap once and surface nulls; keep filters those map writes.
keep == nil and plan == nil both fall back to the existing full-decode behaviour. Passing keep == nil with a non-nil plan widens every DecodeFields segment to "keep all members" (useful for golden parity tests).
Behaviour invariants matched against the per-field readRecord loop:
- Caller-supplied maps are cleared in place before population.
- Decimal128 fields populate wide[name] with the typed Decimal128 value (only when wide != nil and the field is retained).
- Narrow set fields (set_u8..set_u64) populate wide[name] with the raw uint64 mask; the wide rungs (set_u128 / set_u256) populate it with a SetMask. Both only when wide != nil and the field is retained.
- Null surfacing clears values[name] back to 0 and deletes wide[name] for nullable fields the bitmap marks null AND the caller retains.
func (*RecordReader) ReadRecordWithWideProjected ¶
func (rr *RecordReader) ReadRecordWithWideProjected(values map[string]float64, nulls map[string]bool, wide map[string]any, keep FieldFilter) error
ReadRecordWithWideProjected reads a record but only writes the fields for which keep(name) returns true into the caller's maps. Bytes for excluded fields are still consumed from the underlying reader so byte offsets stay aligned — projection saves map allocations, not decode work.
keep == nil falls back to the full-decode path.
type ReusableRecord ¶
type ReusableRecord interface {
SetNumeric(name string, value float64)
SetNullField(name string)
SetWideField(name string, value any)
ClearForRow()
}
ReusableRecord is the subset of *processing.Record needed by the reuse fast path. Declared here as an interface so encoding/ does not depend on internal/processing/. Implementations (processing.Record) MUST clear their own null/wide maps before this call returns successfully; the reader populates them in place but only on fields where the value applies.
ReusableRecord is the NAME-keyed contract: every write pays a hash of the field name. It is retained as a compatibility shim — an implementation of only this interface still decodes correctly, routed through nameKeyedShim — but a record that also implements IndexedReusableRecord is driven through the index-keyed sibling instead. The reader methods still take a ReusableRecord so an existing implementation keeps compiling unchanged.
type RowExpander ¶
type RowExpander struct {
// contains filtered or unexported fields
}
RowExpander turns physical rows of a grouped schema into logical rows: the exact 0x01 row the ungrouped twin stores. Not safe for concurrent use (it caches the last entry spliced per group).
func NewRowExpander ¶
func NewRowExpander(s *encoding.Schema) (*RowExpander, error)
NewRowExpander compiles the expansion for a grouped schema.
func (*RowExpander) Expand ¶
func (x *RowExpander) Expand(dst, phys []byte) ([]byte, error)
Expand writes the logical row for physical row phys into dst (grown to LogicalStride) and returns it. An index past its group's dictionary is ENCODING_INVALID. When dst is the same buffer as the previous call, a group whose index did not change is not re-spliced — its member bytes are already in place.
func (*RowExpander) GroupIndex ¶
func (x *RowExpander) GroupIndex(g int) uint32
GroupIndex returns the entry index group g resolved to in the most recent successful Expand (0 for a constant group).
func (*RowExpander) LogicalStride ¶
func (x *RowExpander) LogicalStride() int
LogicalStride is the logical row width: Logical().RecordByteSize().
func (*RowExpander) PhysicalStride ¶
func (x *RowExpander) PhysicalStride() int
PhysicalStride is the grouped schema's RecordByteSize.
type RunSkipRecord ¶
type RunSkipRecord interface {
IndexedReusableRecord
// BeginRunRow starts a row for the reader identified by token
// (never 0). See the type documentation for the keep / return
// contract.
BeginRunRow(token uint64, keep bool) bool
// ClearNullAt clears field idx's null mark, leaving its value and
// wide value as they are.
ClearNullAt(idx int)
}
RunSkipRecord is an optional extension of IndexedReusableRecord that lets the reuse decoders skip repopulating fields whose on-wire bytes did not change since the previous row.
On a cohort sorted by a parent key most of a row's fields repeat the previous row byte for byte (a denormalised join holds the whole parent block constant across the parent's child rows). Comparing a field's 1–32 on-wire bytes against the previous row's is far cheaper than decoding them and writing the record, so when the record still holds exactly what the decoder wrote for the previous row, only the fields that changed are rewritten. The optimisation is self-activating: no declaration, no metadata, no format change.
The contract has two halves, one per side:
- The READER guarantees the comparison baseline: it keeps the previous row's bytes, and passes keep=true to BeginRunRow only when those bytes were decoded, successfully and by this same decode shape (full stride, or the same *DecodePlan), into this same record.
- The RECORD guarantees its state is still what the decoder left: BeginRunRow returns true only when keep is true, token names the reader that last began a row on it, and nothing but that reader's index-keyed writes has touched a schema field since. Any other mutation of a schema field (Set, SetNull, SetWide, a name-keyed decode write, ClearForRow) must make the next BeginRunRow return false. It must also return false for any storage in which two schema positions share state (a duplicated field name), because a skipped position could then carry its sibling's value.
BeginRunRow replaces ClearForRow on this path. When it returns false the record must have done a full ClearForRow and the decoder writes every field exactly as ReadRecordReused always has. When it returns true the record clears only per-row state the decoder never writes (off-schema null / wide marks, memoised views) and the decoder writes just the changed fields:
- a field whose value bytes AND null bit are unchanged is not written at all;
- a field that is non-null and changed (value bytes differ, or it was null on the previous row) gets its full value write — numeric, plus the typed wide value for decimal128 / set_*;
- a field that turned null gets SetNullFieldAt + SetNumericAt(0), exactly the full path's null signal; one that turned non-null gets ClearNullAt. A field null on both rows is left alone.
Because every write is a function of the field's own bytes and null bit, the record after a partial rewrite is observably identical to a full decode of the same row.
type SchemaDoc ¶
SchemaDoc carries the contents of a `_schema.pulse` entry: the canonical Schema plus the sharding metadata extension. AggregateRecordCount is the cached sum of every shard's record count at the time the archive was last written; ShardCount mirrors the zip central directory's entry count (excluding the reserved schema entry).
Both metadata fields are a SANITY CHECK, not the source of truth. Per the design contract, the per-shard headers are authoritative — callers populate live `ShardEntry.RecordCount` values by peeking each shard's header. AggregateRecordCount is exposed so cheap `pulse inspect` calls can report an approximate total without opening every shard.
func ReadSchemaDoc ¶
ReadSchemaDoc parses a `_schema.pulse` payload. It always reads the Pulse header + standard schema block. The sharding metadata extension (magic "SHRD" + agg + shard count) is read when present and silently skipped when absent — older `_schema.pulse` artifacts or single-file cohorts addressed by mistake still parse cleanly, returning AggregateRecordCount=0 and ShardCount=0.
type Segment ¶
type Segment interface {
// contains filtered or unexported methods
}
Segment is the sealed sum type of decode steps emitted by BuildDecodePlan(schema, retained). The unexported marker method blocks out-of-package implementations so the iterator's switch is exhaustive.
type SetWidenPlan ¶
type SetWidenPlan struct {
// FieldIndex is the field's position in both schemas (structural
// cohesion has already established they are the same position).
FieldIndex int
// Field is the field name, for diagnostics.
Field string
// From is the rung the CANONICAL schema declares. From == To means
// the archive itself does not move — only the incoming shard is
// promoted to meet it.
From encoding.FieldType
// IncomingFrom is the rung the ARRIVING shard declares. It may be
// narrower than From (the shard is promoted to canonical), wider
// than From (the archive is promoted to the shard), or equal.
//
// The two sides are recorded separately because the executor widens
// them separately: a payload already at To must not be handed to
// WidenSetFieldBytes, which refuses a non-widening target, and a
// caller reading the warning needs to tell an archive-wide rewrite
// from a one-shard re-stride.
IncomingFrom encoding.FieldType
// To is the rung both sides carry after the plan is applied: the
// widest of From, IncomingFrom and the narrowest rung holding the
// merged dictionary. It is never narrower than either input rung —
// narrowing would drop every selection above the target's ceiling.
To encoding.FieldType
// UnionEntries is the size of the merged dictionary the two schemas
// imply.
UnionEntries int
}
SetWidenPlan describes the rung promotion one set field needs before a shard can join an archive: the merged dictionary the two schemas imply no longer fits the rung both currently declare.
func PlanSetWidening ¶
func PlanSetWidening(canonical, incoming *encoding.Schema) ([]SetWidenPlan, error)
PlanSetWidening reports which set fields must be promoted to a wider rung before canonical and incoming can be dictionary-union-merged, in field order.
It answers the question MergeDictUnion cannot: MergeDictUnion sees a union that exceeds the declared bitmask width and can only refuse (PULSE_SHARD_DICT_WIDTH_OVERFLOW), because widening is an archive-wide byte rewrite and nothing at the schema layer may perform one. Running this FIRST lets the caller widen the whole archive and then merge into a rung that fits.
TWO forces produce a plan, and they compose:
- The merged DICTIONARY outgrows the declared bitmask, the original case.
- The two schemas DECLARE DIFFERENT RUNGS for the same set column. A re-import of a new period infers the rung its own data needs, so an archive seeded at set_u64 legitimately meets a set_u128 shard — and refusing that would force a full re-import of every prior period to reach a layout a mechanical re-stride already produces.
Because of (2) this function must run BEFORE ValidateStructuralCohesion rather than after it: cohesion is strict on the type byte and on every ByteOffset a rung change shifts, so a divergent rung is fatal there. The contract is that the caller applies the returned plans and THEN runs cohesion over the reconciled schemas, which stays strict on everything else. Only the set-rung dimension relaxes.
The target rung is never narrower than either declared rung: To is the widest of canonical's rung, incoming's rung and the narrowest rung holding the union. Narrowing would drop every selection above the target's ceiling, and it would do so SILENTLY — the record still decodes, to a smaller and entirely plausible selection.
An empty plan and a nil error is the ordinary case: the rungs agree and every merged dictionary already fits.
Errors:
- PULSE_SHARD_DICT_WIDTH_OVERFLOW when the union exceeds the WIDEST rung. There is nowhere left to widen to, so this stays fatal — it is the one set-width overflow auto-widen does not remove.
- PULSE_SHARD_SCHEMA_MISMATCH on nil schemas, differing field counts, or a set column meeting a non-set column of the same position. Other structural divergence is ValidateStructuralCohesion's to report; run it over the reconciled schemas.
Categorical fields are ignored entirely: a categorical's width is fixed at folder creation by contract, and its capacity (256 / 65 536 / 2^32) is not a bitmask a record rewrite can widen the same way.
type SkipBytes ¶
type SkipBytes struct {
N int
}
SkipBytes advances the underlying reader by N bytes without decoding. Coalesces a run of contiguous unprojected, non-bit-packed fields, or an entire bit-packed group when no member is retained, or the trailing null bitmap when no nullable field is retained. N > 0 always.
type TypedSetRecord ¶
type TypedSetRecord interface {
SetNarrowSetAt(idx int, mask uint64)
SetWideSetAt(idx int, m encoding.SetMask)
}
TypedSetRecord is an optional extension of IndexedReusableRecord that takes a set field's mask with its concrete type instead of through `any`. Boxing a uint64 mask (256 and above) or an encoding.SetMask into an interface costs one heap allocation per set field per row; a record implementing this interface stores the mask in typed storage and the reuse decoders never box it. Decimal128 needs no typed twin: it is a single-pointer struct, which Go stores in an interface without allocating.
The decoder checks for this interface once per decode call (a row on the full-stride path, a group on the plan path) and, when it is present, calls SetNarrowSetAt for set_u8..set_u64 and SetWideSetAt for set_u128 / set_u256 IN PLACE OF SetWideFieldAt. The value, and the SetNumericAt echo written before it, are identical to the boxed path.
type VectorRecord ¶
VectorRecord is an optional extension of IndexedReusableRecord that takes a vector field's (vec_f32 / vec_f64) on-wire payload directly: the record decodes the dim elements into storage it owns, so a reuse decode of a vector allocates nothing. payload is exactly the field's FieldWidth bytes (VectorElemSize × dim) and is the reader's buffer — an implementation decodes it before returning and never retains it.
A sink that does not implement it receives the vector boxed through SetWideFieldAt as a freshly allocated []float64 (the map decoders' wide value). Either way a vector field gets NO SetNumericAt echo: a vector has no scalar value, so its presence rides the wide value alone. A null vector surfaces exactly like any other null (SetNullFieldAt + SetNumericAt(0)).
Source Files
¶
- archive.go
- bridge.go
- cohesion.go
- decimal_promote.go
- decode_plan.go
- description_quality.go
- doc.go
- group_constant.go
- group_decode.go
- group_encode.go
- group_entry.go
- group_stream.go
- group_viability.go
- index.go
- index_manifest.go
- index_path.go
- join_keys.go
- join_schema.go
- layout.go
- preamble.go
- reader.go
- reader_fast.go
- reader_plan.go
- reader_runskip.go
- record_at_func.go
- schema_doc.go
- set_ladder.go
- shard_groups.go
- shard_rewrite.go
- widen.go