Skip to content

Final pass: correctness and speed across readers, writers, index, editing and join (0.10.0) - #3

Merged
xrfez merged 80 commits into
mainfrom
final-pass
Nov 13, 2026
Merged

xrfez merged 80 commits into
mainfrom
final-pass

Conversation

@xrfez

@xrfez xrfez commented Nov 13, 2026

Copy link
Copy Markdown
Owner

The final pass over parseSEG before viewer development starts: a review of every module with reproducers, then fixes with a regression test each. 80 commits; the release notes below are the summary.

parseSEG 0.10.0 is a correctness and speed pass over the whole library: readers, writers, the trace index and prefetcher, header editing and the survey join. Every fix has a regression test; the suite now also runs in an x86-64-v3 release build, as the CLI ships.

Install

atlas use https://github.com/xrfez/parseSEG

Or in your .nimble file: requires "https://github.com/xrfez/parseSEG >= 0.10.0".

New

  • OpenOptions.kind forces the standard openSeismic reads a file as. An index cache written for another standard is refused and rebuilt.
  • SEG-Y keys through the layout. Channel, trace code, delay, date, a fractional source point and the stations of a file's Layout stanza are read into TraceKeys. A SEG-Y file converted from SEG-D keeps its stations, so a survey join matches it by stations.
  • relationRuns iterates a record's relation as runs of channels (RelationRun), and relationsDisjoint says whether records share channels: lookups no longer scan per channel.
  • changedGroups: a join marks only the values it changed (JoinGroup is finer, see below), and the rest keep following header edits.
  • Seg2Writer.abandon closes a writer whose output is discarded. SpsOptions.keepRaw keeps the raw record text only when asked.
  • Prefetchers of one file can be made and closed on different threads.
  • looksLikeBinaryHeader and usableExtInterval expose the rules the readers use for detection and for a Rev 2 extended sample interval.
  • nim testV3 runs the suite in a release build for x86-64-v3.

Faster

Numbers are in docs/benchmarks.md.

  • openSeismic(keys = true) on fixed-length SEG-Y takes the sampled index and reads the keys from the map; standard-layout keys use direct loads; a layout is resolved once per file.
  • IBM float to float32 decodes 8 wide in AVX2 builds. The encoders use unchecked kernels with a static byte order, an integer IBM kernel, and take the SEG-D exponent from the float's bits.
  • The prefetcher hands a short chunk's buffer over instead of copying it, and a seek or a close no longer waits for the chunk being decoded.
  • SPS records are parsed where they lie in the chunk read. DiskSink writes a MiB at a time. Gather classification compares a few stations instead of hashing them. Geometry copies are written without a journal.

Fixed

  • Writers. A failed last flush or a write failure is an error and discards the output, not a truncated file. A directory as destination is refused first, and no stray temp file is left. The binary header holds the largest trace count; Rev 0 traces that give 0 samples are written with their count.
  • Converters. convertSegy rewrites the bytes whose meaning changes with the revision and keeps a Rev 2 sample count in bytes 3269-3272. SEG-D/SEG-2 to SEG-Y writes trace keys with the keys writer's builder. A whole interval with float noise is that whole number.
  • Encoders. Every format refuses what it cannot hold (NaN, infinity, out of range) instead of writing garbage; SEG-D 8015 writes two's complement when the file is read so.
  • Readers. A read that stops early always leaves a fatal entry. A trace sample count of 0 falls back to the file's count in any revision. A multiplexed SEG-D record with fewer than 8 bytes per scan is refused. A run of Table of Contents files is read in a loop, not by recursion. Malformed SEG-2 ACQUISITION_DATE/ACQUISITION_TIME give no time instead of a crash. Arguments near the ends of int raise ValueError. An invalid record length is NaN. descaleFactor and startTimeMs are per scan type and channel set.
  • Detection. SEG-Y with a textual header that is not card text is recognized. Headerless SPS Rev 1.0 files are read as 1.0.
  • Index and prefetch. A cache starting with NUL is refused instead of crashing, and is synced before the rename. Keys stay alive when the file is handed to an editor.
  • Editing. The journal undoes and redoes an interrupted edit over or beside an earlier one. Record edits wait for the batch, and the edit that fills a batch refreshes its keys. Write-back counts and "rounded" warnings describe what was written.
  • Join. A trace that already holds the survey's values is not changed. A header station part that the relation contradicts is a conflict. A SEG-D station whose line and point are both 0 is unknown; unset SEG-Y datum elevations are NaN, not 0.
  • Tape images. SIMH erase gaps are 0xFFFFFFFE, and 0xFFFEFFFF is half a gap.
  • Diagnostics. A damaged log raises SegFormatError instead of ending the process. Survey readers cap their diagnostics and give a reason for an empty P1 read.
  • CLI. Option values may come before the command; bad revisions are usage errors.

Breaking changes

  • JoinGroup is finer: jgSourceXy, jgReceiverXy, jgSourceZ, jgReceiverZ, jgDerived, jgSourceStation, jgReceiverStation, jgComponent.
  • The tensor writeTrace takes its trace count as count (default -1: all).
  • SeismicWriter.close raises IOError when the final rename fails.
  • encodeSamples raises for NaN, infinity or out-of-range values in the IBM and SEG-D exponent formats.
  • generalHeaderUtc reads a two-digit year as 1950-2049 by default.
  • fieldNames returns lent seq[string].
  • SEG-Y keys now hold what the layout gives (stations, channel, trace code, delay, date). A join that used to fall back to record and channel for such a file now matches by stations, and never replaces a station the header holds.

Verification

  • nim test on the merged tree with the sample files: 58 of 58 test files pass.
  • CI runs the suite in debug, release, danger and x86-64-v3 builds, the threaded tests under ASan/LSan, the docs build and the CLI smoke tests.

🤖 Generated with Claude Code

xrfez and others added 30 commits November 12, 2026 18:32
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`remapWritable` (`openEditor(f)`) parked the entries and codecs of a pinned
map but not the keys, and gave the file a new map with no pins, so neither
`close` nor the destructor parked them later: the keys were freed with the
file while the prefetcher's view, its chunks' `rows` and its transform
still read them (ASan: heap-use-after-free in `c.rows.traceKeys(0)`).
The keys are parked with the other two tables now, as `close` does; the
prefetcher's chunks show the keys as of the hand-over.

Test: tprefetchrows `keys_outlive_a_file_handed_to_an_editor` (fails in a
plain build too: a seq allocated after the file dies takes the freed
block); clean under `nim asan`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Nim's `close(File)` discards the result of `fclose`, and `flushFile` that
of `fflush`. When the disk filled up while the last writes still fitted
the stdio buffer, `DiskSink.close`, `Seg2Writer.close` and the SEG-Y
binary header patch of `SeismicWriter.close` returned normally, and the
truncated temporary file was renamed into place with a clean log (a
10-trace SEG-Y file under a 44 kB limit: 45056 of 46000 bytes, 9 traces).

`closeChecked` (common/outputfile) flushes and closes with both results
checked and raises `IOError`; the three places use it. Their callers
already turn an `IOError` from `close` into a discarded output
(`SeismicWriter.close`) or a fatal diagnostic (the converters).

tests/twritefail.nim writes each standard through `SeismicWriter` and
`convertFile` under a file size limit 900 bytes short of the output.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…before the rename

`readIndexCache` took the bytes before the first NUL as the header and
did `addr stored[0]` on them: for a cache whose first byte is NUL (a
zero-filled file, which a crash between the rename and the data reaching
the disk can leave) that is an IndexDefect out of a `raises: []` proc, so
`openSeismic` died instead of indexing the file. Such a file now "has no
header" and the file is walked, with the usual info diagnostic.

`writeIndexCache` also syncs the temporary file before renaming it (POSIX),
so that crash leaves the old cache or a whole new one. Measured: +10 to
12 ms per `saveIndex` for a 12 MB and a 100 MB cache (500 000 traces,
without and with keys).

Test: tindexcache `damaged_caches` (4096 NUL bytes, one NUL byte).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…e process

parseDiagnostics and readDiagnostics read the document with the toolchain's
NIF reader, which calls quit on a line-information suffix it cannot read
("(~diag2ostics" ended the process with "Parsed integer outside of valid
range") and raised RangeDefect on "(offset 4340@99999999999,1)". Both are
documented to raise SegFormatError.

The strict tokenizer of the header import (NifIn, next, skipTree) moves to
common/niftext and reads from a string as well as from a file; the
diagnostics log is parsed with it and written with nifString. The layout of
the written document is unchanged; strings now escape only quotes,
backslashes, control and non-ASCII bytes, and documents written by earlier
versions (punctuation as \HH) still read back.

common/diagnostics no longer imports $nim/dist/nimony/src/lib, so the
library compiles with a Nim install that does not ship it.

The reviewer's fuzzer (60000 mutated logs) had 6 process exits and 7
RangeDefects; now 4732 parse and 55268 raise SegFormatError.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`writeTrace` raised `IOError` when the disk filled up, but the writer
stayed open: a `close` after it (from a `finally`, say) wrote the trailer
and renamed the torn file into place, with a clean log (10 traces
intended, 4 installed under a 20 kB limit).

Both `writeTrace` forms now discard the output on `IOError`, which closes
the writer, so a later `writeTrace` or `close` raises `ValueError` and no
file appears. Tested from keys and like an input under a 1.5 MiB limit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A destination that is a directory was found only by the final rename,
after the whole file had been written, and the failed rename left
`<dst>.<random>.tmp` behind (inside the parent directory, under a name
nobody knows).

`createTempFor` now refuses a directory (`IOError`, so `openSeismicWriter`
raises at once and every caller is covered), the converters check it next
to `sameAsInput`, before they read the input (`isDirectory`), and
`finishOutput` removes an output it cannot move into place.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The survey_apply block expected "src station: 1 traces" for the copy of
63cli.sgy, and failed since the commit that added it. The converted file
already holds the survey's source station: espnum 100 (99.5 rounded), sp
9950 with sp_scal -100 (99.5 exactly) and 13 / 99.5 in the layout's
station fields. The join refines the key from 100 to 99.5, the planner
rewrites the same bytes, and the count is 0. The block now expects 0 and
checks that the copy equals the input byte for byte.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…nized

openSeismic took a file for SEG-Y only when its textual header started
with C and was 60 % printable. Blank EBCDIC or ASCII headers, zero-filled
ones, free text, a lower-case c and C cards padded with NUL all gave "not a
SEG-Y, SEG-D or SEG-2 file", although readSegy read every one of them.

detectKind now has a last resort behind the SEG-D General Header test: the
400 bytes at 3200 (or at 3328 behind a tape label) are a plausible binary
header in some byte order, with a defined format code and a sample interval
and count above 0 (looksLikeBinaryHeader, new in segy/binheader). The reader
keeps a tape label before such a header by the same test, and logs an info
entry when the textual header does not start with C.

On 200000 random heads the new branch fires 76 times (0.04 %, the odds of a
defined format code); on the 22 SEG-D and SEG-2 files at hand (bench/data,
the samples, the demo files, the reviewer's bases) it never does, and they
are detected before it is reached.

OpenOptions gets kind (default skUnknown: detect), the standard to read a
file as; openSeismic has to pass it on (model/traceindex).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Lengths and sums were computed from the arguments before they were
checked, so in a release build:
- readTracesInto(0 .. 3, 0 .. high(int), buf) never returned
  (`samples.len` in checkWindow);
- readTraces(0 .. 3, 1000 .. high(int)) raised OverflowDefect
  (`max(samples.len, 0)` before any check);
- readTracesInto(low(int) + 1 .. 3, buf) raised OverflowDefect (`spanLen`
  before the selection was checked; also readTraces and traceStatics);
- readTracesInto(0 .. 3, buf, row = high(int)) raised OverflowDefect
  (`row + count`);
- newPrefetcher(cols = 64, firstSample = high(int) - 10) ended the process
  (`first + cols` in adviseTraces on the worker thread).

The selection is now checked before its length is taken, the window's
column count comes from `windowCols` (ordered bounds first, a length that
does not fit is "too long"), the row check subtracts instead of adding,
`readTraces` refuses a window whose tensor size overflows, `adviseTraces`
clamps without the sum, and `newPrefetcher` refuses a window or a chunk
size beyond the int range (its ring depth is computed without `workers +
1`). A window of the tensor's width that ends at high(int) is what it
always was: past every trace's end, so zero rows.

Not covered: an absurd `depth` or `workers` count is a resource request
and fails as an allocation of that size does.

Test: tviewerreads `arguments_near_the_ends_of_int_raise_value_error`
(hangs before the fix in -d:release).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ot a crash

The two strings are free text. A part of 20 or more digits made
parseutils.parseInt raise ValueError, which escaped openSeismic(keys =
true), summary and the converters (acquisitionTime runs per trace); an hour
near high(int) overflowed in h * 3600 (OverflowDefect) because the seconds
were computed before the range check.

Date and clock parts are now 1 to 4 digits and nothing else, and the
seconds are computed after the ranges are checked. An empty hour (":30")
and a signed part ("+9/11/2026") no longer parse.

integer() asked `x == float64(int64(x))`, which converts NaN, the
infinities and 1e300 to int64: undefined behaviour in C. It now checks the
magnitude first.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The converter relabelled the revision and copied the headers as they
were, so bytes changed meaning without a diagnostic:

- Rev 0 to Rev 1/2: trace bytes 181-240 and binary bytes 3261-3600, which
  Rev 0 leaves unassigned, became fields (Sweep0.sgy: CDP X 999994907,
  time scalar -32768; a 2 in bytes 3505-3506 made the output unreadable).
  Rev 1 to Rev 2 did the same with binary bytes 3261-3300 and 3507-3532.
  They are cleared, with a note when any was set.
- Rev 1/2 to Rev 0: the time scalar stayed in bytes Rev 0 ignores, so a
  delay of 0.25 ms read back as 250 and 40000 ms as 4000. The scalar is
  now applied to bytes 95-114 (rounded: a note; beyond int16: clamped,
  with a warning) and bytes 181-240 are cleared with a note.
- A Layout stanza lost with the extended text of a Rev 0 target was an
  info line although every field it places then reads differently
  (Inglewood: offset 0 becomes 1794249.84 in 200 of 200 traces): a warning.
- Rev 2 to Rev 0/1: an interval over 65535 was written as 65535 and logged
  as "rounded". Such a trace is skipped with an error, as a trace with
  too many samples already was; a fixed-length file of them is refused.
  The binary header takes the effective interval (bytes 3273-3280 when
  set), not only when bytes 3217-3218 are 0.

tests/tsegyrevconvert.nim compares the keys before and after each change
of revision on files written from keys.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…e an earlier one

replayOn merged the journal per byte and required every contiguous run of
touched bytes to be wholly original or wholly final. A session killed
between the journal sync and the map write of its last edit leaves an
intermediate state: with two edits of one field, or of two adjacent fields
(sourceX then sourceY), rollbackJournal and applyJournal both reported a
conflict and left the bytes (reproduced with a real kill under gdb).

Edits whose spans share a byte now form a component, replayed as a chain
of states (the originals, then the bytes after each edit in journal
order); the file may hold any state of the chain. Spans that only abut
are independent. The chain is walked with a count of differing bytes, so
the work stays linear in the journal's size.

This also removes the table entry per journaled byte, and the hex codec
is table driven instead of toHex + toLowerAscii per byte. 200 000 traces
of a geometry write-back (-d:release, bench_writeback):
  rollbackJournal  21.9 s, 5257 MiB peak  ->  2.4 s, 113 MiB
  readJournal       3.0 s                 ->  1.3 s
  writeGeometry     5.4 s                 ->  2.6 s

A conflict's diagnostic quotes at most 32 bytes of the span.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Converting to Rev 0/1 zeroed the extended count without moving it to
bytes 3221-3222, so a fixed-length Rev 2 file that holds its count there
alone became unreadable (3 traces of 1000 samples read back as 53 traces
of 0). The "more than 65535 samples" check also sat inside
`if t.hasExt1`, so such traces without an Extension 1 were written with a
wrapped count.

The 16-bit count now takes the effective one; every trace is checked;
and a fixed-length file of more than 65535 samples per trace is refused
up front ("N samples per trace need Rev 2") instead of skipping each trace.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…rsion

nextRecord called itself once per TOC file. A stream of 50000 back-to-back
TOC files (448 bytes each, 22 MB) overflowed the stack: a segmentation
fault in release builds, the call depth limit in debug builds. The step to
the next record (tape marks, the peek, the TOC file) is now a loop, and the
peek is inside the boundary that turns an I/O failure into a diagnostic.

Tests: 50000 TOC files end the read cleanly with 50000 entries in `toc`;
TOC files before, between and after records; a TOC file cut short is fatal.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The reader takes the binary header's count for a Rev 0 trace whose bytes
115-116 are 0 (with a warning). Written like such a file, the trace kept
the 0 and the writer refused it ("the trace header declares 0 samples,
the trace has 100"); converted to Rev 2, the 0 went out and the output
was unreadable. Both only set the header count when the new count
differed from the reader's.

`writeTrace` and `convertSegy` (for targets whose traces give their own
count) now compare with what the headers declare, and the converter also
writes out an interval the trace left to the binary header, as it
already did for Rev 0/1 targets. The count logic is shared
(`setSampleCount`, `declaredSamples` in private/segyencode).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
General Header #1 bytes 20-22 give the bytes per scan of a multiplexed
record. A value of 1 to 7 was only refused by comparing it with the channel
set descriptors, so a record with no scan types took it: walkScans then
wrote the 8-byte start of scan and timing word into a 3-byte slot
(IndexDefect, also out of openSeismic through the demultiplexed copy; a
96-byte file does it).

muxStride now raises the unlocatable error for a count below the scan
header's 8 bytes, with or without descriptors: a fatal badValue entry at
the field. A record with no scan types and whole scans reads as a record
without traces.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ader edits

JoinGroup had two members; applySurvey marked jgCoordinates when any
coordinate changed and the editor's key refresh then kept all nine
coordinate values of the trace. After a join that filled the source X/Y
only, an edit of trace.groupX did not reach the stored key, and
writeGeometry wrote the old receiver X back over the edit.

JoinGroup is now jgSourceXy, jgReceiverXy, jgSourceZ, jgReceiverZ,
jgDerived (offset, midpoint), jgSourceStation, jgReceiverStation and
jgComponent, still one byte per trace. applySurvey and applyGeometry mark
the groups whose values differ from before (changedGroups, exported for
code that joins geometry itself), and the refresh restores group by
group. An offset and midpoint the join derived are derived again when an
end it left to the headers moves; a header value the join kept stays.

Breaking: the two old enum members are gone.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…any revision

SEG-Y: the reader used the binary header's count for a trace whose bytes
115-116 are 0 only under Rev 0 rules. In a variable-length file of Rev 1 or
later (fixed-length flag 0) such traces were read as traces without
samples, and their samples as the next headers: 3 traces, maxSamples 24576
and a fatal truncation where the file has 50 traces of 1000 samples. The
fallback, with its warning, now applies to every revision.

SEG-D Rev 0-2: a Trace Header Extension #1 whose sample count (bytes 8-10)
is 0 is sized from its channel set's window, the rule a trace without
extensions already follows, with a warning; before, the walk ran into the
samples and ended in garbage headers. A window that gives no length leaves
the trace without samples.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
keys.applyGeometry returned true whenever a station lookup succeeded, so
applySurvey(overwrite = true) reported every matched trace as changed and
bumped keysRevision on every call; and both index-level joins set
TraceIndex.joined before knowing whether anything changed, so saveIndex
refused an index after a join that changed no trace.

applyGeometry now compares the nine coordinate values before and after
(NaN equals NaN). applySurvey counts a trace when one of its groups
changed (changedGroups), and `joined` is set together with keysRevision,
only when a trace changed.

tindexcache's save_refusals expected saveIndex to refuse after a join with
an empty table; it now checks that such an index is saved, and that one a
join changed is refused.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… threads

`pinMap` and the pin's destructor changed the map's pin count and copied
or dropped the map's ref without any synchronisation, and neither the
count nor ORC's reference counts are atomic. A background job making and
closing a prefetcher on a file while the UI thread did the same got the
reference count wrong: ThreadSanitizer reports the race in `pinMap` and
`nimIncRef`, and a plain build of the new test crashed in 4 runs of 5
(the file unmapped under its readers).

One module lock now guards every pin count and every copy or drop of a
map ref that another thread may hold: `pinMap`, the pin's destructor, and
`close`, `remapWritable` and the destructor of the file (which read the
count, park the tables and drop the map). The copies a pinned close makes
of the tables are made outside the lock.

Allowed now: several threads each make, use and close their own
prefetchers on one open file. Still not allowed: changing the index,
closing the file, handing it to an editor or destroying it while another
thread reads it or has a prefetcher on it (the last holder of a map must
be on the thread that opened the file: the map's source is an ORC ref,
and releasing it on another thread crashes in ORC's cycle bookkeeping).

Test: tsharedreads (five threads, 200 prefetchers each); clean under gcc
ThreadSanitizer, as is the reviewer's reproducer with bruteStack.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A header with only the line or only the point of a station (a SEG-Y
shotpoint without a line) had the station filled whole from the relation
of its record and channel, without a check: header source point 2345 with
an X record saying 13/99.5 became 13/99.5, "by record number".

The part the header has now checks the relation, as a whole station does:
it must be the same value to 1/100, or the relation's as a whole number
(an integer field holds 99.5 as 100, and the survey's 99.5 then replaces
it); a point index other than 0 must match too. Otherwise the trace is
jmConflict and nothing is filled. The module doc, the guide and api.md
say so.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…lder

The two converters built the trace headers with their own code, a less
faithful copy of what `writeTrace(w, keys, samples)` does:

- a fractional source point (99.5, the guide's example) was also written
  rounded into the energy source point, which readers take first: it read
  back as 100;
- start times went through a clamped int16 without a time scalar:
  -10.5 ms read back as -11, 0.25 as 0 and 40000 as 32767, unreported;
- the fraction of the trace time's second was dropped (no Extension 1
  nanoseconds);
- a Rev 0 target got the Rev 1 sensor codes 11-14, which Rev 0 does not
  define: live traces read back with an unknown status.

`keysTrace` moved to private/segyencode as `keysToHeaders` (with
`timeScalarFor` and `scaledTime`), and `writeTrace(keys)`,
`convertSegdToSegy` and `convertSeg2ToSegy` all call it; the converters
then set the fields that are not keys (trace code from the channel type
or TRACE_TYPE, mapped to Rev 0's codes for a Rev 0 target; trace number
in the record; SEG-2 statics, filters and gains; PSEGD001). SEG-2 statics
share the delay's time scalar, as bytes 95-114 have one. What a field
cannot hold is reported: rounded start times and source points as notes,
start times beyond the field as a warning with the trace count, and a
trace whose interval exceeds 65535 µs is skipped with an error before
Rev 2. The trace sequence number is clamped at int32 instead of raising
a RangeDefect past 2^31-1 traces.

Output changes: a fractional source point leaves bytes 17-20 at 0 (it is
in 197-202); the time scalar is 1, not 0, for whole-ms start times; a
SEG-D trace's time of day comes from its keys (the trace's Timestamp
block when it has one).

tests/tconvert.nim pinned energySourcePoint == 100 for 63.sgd; it now
expects 0 and the key 99.5, and a new block compares the keys before and
after conversion for the cases above.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The readers stop on any failed boundary, but a plain SegFormatError is
logged as an error (badValue), so a walk could end with part of the file
and `isFatal` false, although the guide says isFatal means "the walk
stopped early". The reviewer's case: a tape image with one damaged trailer
word gave 4 of 10 traces and one error entry.

- common/diagnostics: tryRecordFatal, the boundary for a failure that ends
  the read; whatever is raised is recorded as fatal (recordException has a
  `fatal` flag for it).
- The stop sites use it: openSegy and readTrace (SEG-Y); openSegd,
  nextRecord, readTrace and the record trailer (SEG-D); and the
  demultiplexed copy, which ends at a record the writer refuses.
- io/tapeimage: a record class other than 0 and a trailer word that differs
  from its header raise SegUnlocatableError with dcTapeLayout instead of a
  plain SegFormatError: the next record cannot be located.

tests/tstopfatal.nim: a source that fails mid-stream with a plain
SegFormatError or a foreign exception, damaged SEG-Y and SEG-D tape images
(class and trailer), and a refused record in a demultiplexed copy, through
the readers and buildIndex.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The module skipped 4 bytes at 0xFFFEFFFF and called that the erase gap.
SIMH's gap marker is 0xFFFFFFFE (MTR_GAP), which the module refused as a
record of class 0xF; 0xFFFEFFFF (MTR_FHGAP) is what a read sees where a gap
was overwritten off the 4-byte grid: the second half of one gap marker and
the first half of the next. Passing 4 bytes there lost the alignment (the
reviewer's image read as "ends inside a 327679-byte record").

A gap word is now skipped, and a half gap passes 2 bytes so the next read
is aligned again.

tests/tio.nim: the gap test had 0xFFFEFFFF as a 4-byte gap in front of a
record, an image SIMH does not write and would misread; it now holds whole
gaps, a half gap, and gaps up to the end of the image.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A kind of value counted for a trace whenever its fields were rewritten,
unless the whole header came out byte-equal; and every field written
rounded was reported, per field and per group. So a source point 99.5
into an espnum that already held 100 counted as a changed source station
as soon as a coordinate of the same trace changed, "rounded" was reported
for it although sp (with sp_scal) held 99.5 exactly, receiver X/Y were
reported rounded because the source's were, and a file of 6 traces had
values "rounded in 12 traces".

SEG-Y: a kind counts when a field of it holds another value after the
write than before (decoded through the layout, scalar applied). SEG-D:
the kinds written are intersected with what the keys decoded from the new
bytes hold differently. A rounding is noted once per kind and trace, for
a value that was written and that no field of the same part holds
exactly. X and Y are separate parts now.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
winlean's DWORD is int32, and spaceFiles converted the low half of the
64-bit count with DWORD(c and 0xFFFF_FFFF): for count = -1 that is
4294967295, a RangeDefect, so no backward space over file marks worked.
Both halves are now cast from uint32.

Not run (no Windows here): the module type-checks with --os:windows, and
the two expressions, with DWORD = int32, give (-1, -1) for -1 and (-3, -1)
for -3 where the old ones raised.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A SEG-D file opened with `twosComplement8015` and written "like" itself
came back one LSB off on every negative sample (494274 of 1000000 samples
of d21-8015-s12000.sgd): the encoder only knew the spec's one's
complement.

`encodeSamples(f, src, dst, twosComplement8015 = false)` is the inverse of
the decoders' option, and the writers pass the input's convention on:
`SeismicWriter` for SEG-D, `convertSegdRevision` for an 8015 trace it has
to resize, and `convertToSeg2` for SEG-2 format 3 input (the SEG-2
encoder already had the flag; the converter dropped it).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
nim cli, the benchmarks and processSEG compile the library for x86-64-v3,
where the decoders have AVX2-only paths and the C compiler contracts
multiply-adds; nim test and nim testRelease build for the baseline CPU, so
CI never ran the code as shipped.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
An interval kept in ms and handed back as ms × 1000 (as processSEG's
stack and pilot results do) is not always the integer it was: 1472 of
the values 1..65535 µs come back off by an ulp (1001 → 1000.9999999999999).
The keys writer compared `dt == round(dt)` exactly, so Rev 0/1 refused
such a trace ("whole-microsecond intervals, not 1000.9999999999999") and
Rev 2 wrote 0 into the 16-bit interval with the noisy value in the
extended field.

`wholeInterval` takes a value within 1e-9 of a whole number as that
number; the shared header builder and the binary header use it, so the
converters gain it too (SEG-2 gives its interval in seconds:
0.001001 s × 1e6 has the same noise).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
xrfez and others added 27 commits November 13, 2026 00:00
A SEG-D source or receiver station with line 0 and point 0 is a header
left empty, but the keys reported it as station 0/0 (with its index), so a
survey join matched it against whatever the survey has at 0/0, or took the
trace for one that already has its stations. The SEG-Y keys already treat
zero source points and zero coordinate pairs as unset. Now in every SEG-D
revision such a station stays NaN (index 0): Rev 1/2 source headers and
Trace Header Extension 1, Rev 3 Source Description blocks (a zeroed one
is passed over for the next, and for the record's when it was the
trace's) and Extension 1. A line with point 0 is still a station.

Test: tkeyfields `segd_zeroed_stations_are_unknown` (demo Rev 2 and Rev 3
records rewritten with zeroed stations; stored keys and keys on demand).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`traceStatics` returned 0.0 for the receiver and source datum elevations
of a header that never had them (bytes 53-60 zero, as in most files), so
datum statics computed with the header columns were made to elevation 0
instead of the datum asked for: processSEG's `staticsInto(meta, d, statics,
out)` gave -93.044 ms where the keys-only form gave 6.956 ms. A pair of
zeros is "not set" here as for coordinates: both are NaN when both standard
fields are 0; one of them set makes both known, and Extension 1's copies
still override when non-zero. With the fix both forms give 6.956 ms.

Test: tstatics `segy_statics_scaled` (unset pair, one of the pair set) and
`extension_1_gives_a_datum_the_standard_header_lacks`.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Branch fix/readers, 15 commits on eab9219 (plus this one). Worktree clean.
SUITE
Debug, every tests/t*.nim, run one by one (`nim test` stops at the first
failure): 53 pass, 2 fail, the same 2 that fail on the untouched tree:
- tcli: the known `survey-apply --values=stations` assertion at line 315.
  With that assertion left out (scratch copy), the rest of tcli passes.
- theadernif, block failed_journal_syncs_are_reported (line 169): an
  artefact of the long TMPDIR this session must use (the journal's session
  line alone exceeds the test's 100-byte RLIMIT_FSIZE). It passes with a
  short temp path, in debug, release and danger.
Release and danger: 33 test files (the modules touched and their dependents:
readers, detection, index, summary, header fields/lists/NIF, conversions,
keys, prefetch, shared reads) pass in both modes (tconvert's symlink check
needs an absolute temp dir; my runner's relative one failed it, the absolute
one passes). Not run in release/danger: the other 22 files. `nim doc --project` with BrokenLink and RstStyle as
errors passes; tdocs passes (45 examples).

FINDINGS

C1 fixed 540c06b. Test: tests/tdetect.nim (new file, 8 blocks).
  detectKind has a last branch behind the SEG-D GH#1 test: a plausible binary
  header (defined format code in some byte order, sample interval > 0, sample
  count > 0) at 3200, or at 3328 behind a tape label -> skSegy. New public
  func looksLikeBinaryHeader (segy/binheader). The six variants of the
  reproducer open as SEG-Y with all traces, vfSampled and vfFull.
  Beyond the sketch: the SEG-Y reader took a tape label only when a C card
  followed it, so "label + blank text" would have been detected and then
  misread; the reader now keeps the label when a binary header sits at 3328
  and none at 3200 (same predicate).
  The info diagnostic is logged by the SEG-Y reader (I do not own the walk):
  info badValue, field "text", "the textual header is not card text (it does
  not start with C); the file is read by its binary header". readSegy logs it
  too. Not logged for C cards padded with NUL (they start with C).
  Mis-detection: tdetect asserts that the 23 files of bench/data, the
  samples (incl. the 65 MB one) and the demo files keep their kind; an
  ad-hoc run with the reviewer's bases (40 files) agrees, and the new branch
  alone fires on none of the 23 SEG-D/SEG-2 files among them (the commit
  message says 22). On 200000 random heads: SEG-Y 76 (0.04 %, the odds of a
  defined format code); the existing SEG-D branch takes 14.
  OpenOptions.kind: SeismicKind = skUnknown is added and documented but NOT
  honoured yet: see "Lead must do" 1.
C2 fixed b842a2b. Tests: tests/tdiagnostics.nim nif_damaged_documents_raise,
  nif_every_truncation_and_byte_change, nif_message_bytes_round_trip,
  nif_reads_documents_of_earlier_versions, nif_layout.
  NifIn/next/skipTree/expectClose/expectInt moved from model/headernif to
  common/niftext (file- or string-backed: openNifIn / initNifIn);
  parseDiagnostics uses it, toNif writes with nifString. common/diagnostics
  no longer imports $nim/dist/nimony/src/lib (nothing in src/ does now).
  Layout of the written log unchanged; strings escape only quote, backslash,
  control and non-ASCII bytes; logs written by 0.9.0 still read.
  Reviewer's fuzzer, 60000 logs: 6 exits + 7 RangeDefects -> 0 (4732 parse,
  55268 SegFormatError).
C3 fixed 7699430. Tests: tests/tseg2.nim malformed_date_and_time_strings,
  integers_of_values_that_are_no_integers. Date/clock parts are 1-4 digits;
  seconds computed after the range check; integer() checks |x| < 9.2e18
  first. ":30" (empty hour) and signed parts no longer parse.
C4 fixed 398a163. Tests: tests/tsegdreader.nim
  a_long_run_of_table_of_contents_files (50000 TOC files),
  table_of_contents_files_between_records.
C5 fixed 7a1db38. Test: tests/tsegdmux.nim
  bytes_per_scan_below_the_scan_header (also a record with 0 scan types and
  whole scans: reads as a record without traces).
C7 fixed c6fd223. Tests: tests/tsegyreader.nim
  zero_sample_count_in_any_revision (Rev 0, 1.0, 2.0, 2.1);
  tests/tsegdreader.nim extension_sample_count_of_zero.
  SEG-D: THE#1 count 0 -> channel set window, warning (inconsistent, field
  ext1.samplesPerTrace); a window that gives no length leaves 0 samples.
C8 fixed a435cb0. Tests: tests/tstopfatal.nim (new file: a source failing
  mid-stream with a plain SegFormatError or a foreign exception, damaged
  SEG-Y and SEG-D tape images, through readSegy/readSegd and buildIndex; a
  refused record in a demultiplexed copy); tests/tdiagnostics.nim
  stop_boundary_always_records_fatal; tests/tio.nim tape_image_corruption.
  New public template tryRecordFatal (never below sevFatal); used in openSegy,
  SEG-Y readTrace, openSegd, nextRecord (marks, TOC, headers), SEG-D
  readTrace and the record trailer, and in demultiplexSegd where a refused
  record ends the copy (my addition: it had the same hole). tapeimage raises
  SegUnlocatableError(dcTapeLayout) for a record class and a trailer mismatch.
C9 fixed d98b700. Test: tests/tio.nim tape_image_gap_and_end_of_medium.
  The old test block encoded the wrong behaviour (0xFFFEFFFF as a 4-byte gap
  before a record) and was rewritten; said so in the commit.
C10 fixed ef5ebaf. Not run (no Windows). `nim check --os:windows` passes; the
  two expressions with DWORD = int32 give (-1, -1) for -1 where the old ones
  raised RangeDefect.
C12 fixed 5310a87. Tests: tests/tsegyreader.nim
  nan_extended_interval_does_not_override; tests/tsummary.nim
  record_length_of_an_invalid_bcd_field.
  Helper for the owner of private/segykeys.nim: usableExtInterval(x: float64):
  bool, in segy/binheader (already visible in segykeys through segy/reader).
  Line 276 `if x != 0 and not x.isNaN: return x` becomes
  `if usableExtInterval(x): return x`; same semantics, nothing changes there.
  The invalid-BCD record length is fixed in the accessors recordLengthMs
  (segd/headers) and recordLengthUs (segd/rev3): NaN; summary follows.
P1 + P2 fixed d474693 (one commit: same function). Test: tests/tsummary.nim
  channel_sets_count_traces_in_any_order (against a trace-by-trace count,
  shuffled order, traces naming no set/record).
  d21-8058-10m-s500000.sgd (500000 traces, 500 records), -d:danger
  --opt:speed --passC:-march=x86-64-v3, minimum of 9 runs, before -> after:
    channelSets   10.87 ms -> 0.40 ms (27x), allocations 2006 -> 3
    summary        1.99 ms -> 0.67 ms (3x),  allocations 1500 -> 0
      (also asks the interval once per run of traces, not per trace)
    recordHeaders, all 500 records: 2.10 ms -> 2.12 ms (no change),
      allocations 21500 -> 21000
  Results identical on 19 files (bench/data + samples): checksums of all
  rows and the whole summary.
A5 fixed 5b54933. Test: tests/tsegdreader.nim
  channel_set_values_without_a_trace. Additive.
A6 fixed 90eebac. Test: tests/tsegdreader.nim
  general_header_time_reads_the_two_digit_year_like_the_library.
  Two overloads: generalHeaderUtc(g1) (pivot 1950-2049) and
  generalHeaderUtc(g1, century). Callers passing a century are unchanged.
A10 fixed d77e024. Test: tests/theaderfields.nim field_names_are_one_table.
  func fieldNames(kind): lent seq[string] from a const table. Same flags,
  200000 calls, minimum of 7 runs: 140 ns and 1 allocation per call -> 1.7 ns
  and 0; names of every kind unchanged (checksum). `lent` chosen because it
  compiles every caller unchanged (headernif, tests, guides) and removes the
  last allocation, which a by-value return from the const keeps.

Deferred (as briefed): C6, C11, A1, A2, A3, A4, A8, A9.
A7 is done for diagnostics only: vendor/maps.parseNif and edit.parseEditLine
are still their own readers (not my files); common/niftext is ready for them.

PUBLIC API CHANGES
Additive:
- segy/binheader: looksLikeBinaryHeader(b), usableExtInterval(x).
- common/diagnostics: tryRecordFatal(log, loc): body;
  recordException(log, loc, e, fatal = false).
- model/indextypes: OpenOptions.kind.
- segd/reader: startTimeMs(rec, scanType, channelSet),
  descaleFactor(rec, scanType, channelSet).
- segd/rev3: generalHeaderUtc(g1) overload.
- common/niftext (internal, not re-exported): NifIn, NifTok, NifTokKind,
  initNifIn, openNifIn, close, line, docError, next, skipTree, expectClose,
  expectInt, expectStr, MaxNifDepth, MaxNifString, NifIdentStart,
  NifIdentChars.
Breaking or behaviour-changing:
- BREAKING generalHeaderUtc: `century` has no default any more;
  generalHeaderUtc(g1) dates years 50-99 as 19xx (was 20xx).
- fieldNames returns `lent seq[string]` (was seq[string]) and is a func.
  Source-compatible for calls; a proc-typed variable holding it would break.
- detectKind returns skSegy for SEG-Y without card text (was skUnknown).
- The SEG-Y reader logs one info entry for a textual header that does not
  start with C: such a file had 0 diagnostics before.
- Stop boundaries log fatal where a plain SegFormatError gave error
  (badValue) and a foreign exception error (internal). Tape image class and
  trailer errors are SegUnlocatableError/dcTapeLayout (still a
  SegFormatError). demultiplexSegd ends on a refused record with a fatal
  entry (was error).
- Tape images: 0xFFFFFFFE is a gap (was refused as class 0xF); 0xFFFEFFFF
  passes 2 bytes (was 4).
- SEG-Y Rev >= 1, fixed-length flag 0, trace sample count 0: binary header's
  count with a warning (was a 0-sample trace). SEG-D Rev 0-2 THE#1 count 0:
  the channel set window with a warning.
- effectiveSampleInterval/effectiveSampleIntervalOrig and the reader ignore
  a NaN extended interval (was NaN).
- recordLengthMs/recordLengthUs: NaN for an invalid BCD field (was -204.8 ms).
- SEG-2 acquisitionTime: empty hour and signed parts no longer parse.
- toNif string escaping (see C2); parseDiagnostics error texts changed
  ("diagnostics NIF line N: ...").
- A multiplexed record with GH#1 bytes per scan 1-7 is always fatal badValue
  (with descriptors it was fatal inconsistent, with another message).

LEAD MUST DO WHEN MERGING
1. OpenOptions.kind needs these lines in src/parseSEG/model/traceindex.nim
   (validated in a scratch copy: a refused file opens as forced SEG-Y with the
   reader's diagnostics, a wrong forced standard gives diagnostics and no
   exception, an empty forced file is fatal):
   a. openMapped signature, last line:
        demuxDir: string, progress: Progress = nil, cache = "", verify = vfSampled,
        forced = skUnknown): SeismicFile =
   b. replace `let kind = if n > 0: detectKind(...) else: skUnknown` by
        let kind = if forced != skUnknown: forced
                   elif n > 0: detectKind(f.map.src.memory.toOpenArray(0, n - 1)) else: skUnknown
   c. openSeismic's call ends `opts.verify, opts.kind)`.
   Not done by these lines: a valid index cache of another kind is still used
   when a kind is forced (loadIndex runs first); suggest skipping the cache
   when `forced != skUnknown and f.index.kind != forced`.
   Test to add with it (e.g. in tests/tdetect.nim, block
   a_known_standard_can_be_forced, which now only checks buildIndex):
        var f = openSeismic(p, opts = OpenOptions(kind: skSegy))
        doAssert f.kind == skSegy and f.log.has("format code 13 is undefined", sevFatal)
   docs/guide/getting-started.md and docs/api.md already describe the option.
2. private/segykeys.nim:276: call usableExtInterval (see C12).
3. segd/writer.nim declaredSamples still says "THE#1 count as is": a strict
   rewrite of a record whose THE#1 count is 0 is refused ("the headers give 0
   samples, the trace has N"). It should mirror the reader: count > 0, else
   the channel set window, else 0. (The SEG-Y writer refuses the analogous
   trace in strict mode as it always did for Rev 0.)
4. model/keys.nim:115 and convert/tosegd.nim:344 pass
   `fullYear(year) - year` to generalHeaderUtc; they still work, and can
   call generalHeaderUtc(g1) now.
5. Stale after C2 (not my files): docs/design/vendor_maps.md lines 77-80 (NIF
   I/O through the toolchain's nifbuilder/nifreader and a --path in
   config.nims); tests/tdocs.nim nimPaths adds the dist/nimony path, no
   longer needed.
6. Two new test files, tests/tdetect.nim and tests/tstopfatal.nim (found by
   tests/tester.nim). The `nim asan` task list is unchanged.
7. Other branches' tests that assert `log.len == 0` on a SEG-Y file whose
   textual header does not start with C, or sevError where a read stops,
   will need the new expectation.

OBSERVATIONS (outside the brief, not changed)
- SEG-D Rev 3 THE#1 numSamples 0 with a non-zero descriptor count has the
  failure mode C7 fixes for Rev 0-2 (0-sample trace, then garbage).
- headerlist.recordHeaders scans every index entry to find the last trace of
  a record that has a trailer (O(traces) per call).
- readSegd's decode loop `for i, tr in d.traces` copies every trace, samples
  included.
- SEG-2 `CHANNEL_NUMBER inf` gives TraceKeys.channel = Inf (model/keys.nim).
- detectKind's card-text branch accepts a label whose first byte is not a
  digit or blank; the reader does not take that as a label.
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Interim report of the `index` engineer (parseSEG, branch fix/index): the
correctness and key-fidelity items are done; the performance items P3, P5,
P4, P6 follow and get a second report commit.

FIXED
- pA C1 (high), keys freed under a prefetcher after openEditor(f): f42674c.
  remapWritable parks the keys with entries and codecs. Test tprefetchrows
  `keys_outlive_a_file_handed_to_an_editor` (fails in a plain build too);
  clean under ASan. Chunks show the keys as of the hand-over (documented).
- pA C2, index cache starting with NUL: ae43aaf. "it has no header", file
  indexed. Also fsync of the temporary cache before the rename (POSIX):
  +10 to 12 ms per saveIndex for 12 MB / 100 MB caches. Test tindexcache
  `damaged_caches`.
- pA C3, argument overflows: c132b6c. All five raise ValueError, plus the
  same pattern in readTraces(range), traceStatics(range) and newPrefetcher
  (cols / chunk bytes). Test tviewerreads
  `arguments_near_the_ends_of_int_raise_value_error` (hung before in
  -d:release). Not covered: an absurd `depth` or `workers` count (a
  resource request, fails like an allocation of that size).
- xF C2, prefetchers made and closed on different threads: 070b362. One
  module lock around pinMap, the pin's destructor and the file's close,
  remapWritable and destructor. Test tsharedreads (5 threads x 200
  prefetchers; crashed 4 runs of 5 before); gcc ThreadSanitizer clean, as
  is xF/rrace.nim with bruteStack. NOT made safe (documented): closing,
  destroying or editing a file while another thread has a prefetcher on it.
  Found on the way: releasing the last map reference on a thread other than
  the one that opened the file crashes in ORC (the map's MemFileSource is
  an ORC ref); the documented rule covers it.
- pA P1, seek/close waited for the stale chunk: 7210b5a. Atomic generation
  copy and stop flag read every 8 traces; relaxed reads that only cause an
  early return, no wait predicate changed, so no tlanif model. bench_seek
  (-d:danger v3, p50, 1024-trace IBM chunks, 1 worker): seek while busy
  1340-1360 us -> 690-710 (idle 740-760), close while busy 586 -> 11 us.
  TSan/ASan stress clean. Test tprefetch
  `a_stale_chunk_is_given_up_and_never_handed_out`. Behaviour: a chunk
  dropped by a seek is no longer transformed.
- pA P2, quickFirst cloned the first chunk: bbb9e41. The slot buffer is
  handed over, `samples` is a view of its first rows; allocation-free with
  nextInto/recycle (the buffer is recognised by the identity of its
  storage). Consumer thread only: no model. bench_seek quickFirst, y-ieee
  1024: idle 154-187 us -> 90-102, busy 290-384 -> 74-78; y-ibm idle
  276-290 -> 200-217. Test tprefetch
  `short_chunks_hand_the_slot_buffer_over` (104 allocations before, 0 now).
- pA C4 + C5 + doc item 5: 2a8e198 (docs, one test pinning the behaviour).
  C4 is documented, not coded: a refresh on seek is neither simple nor safe
  (the prefetcher has no reference to its movable file; after openEditor
  the file's tables are not the ones the pin keeps alive). Views and
  prefetchers made before ensureKeys must be made again: said on
  ensureKeys, keysRevision, newPrefetcher, tracerows, viewer.md,
  processing.md, api.md. C5: ChunkTransform says a Defect in the hook ends
  the process.
- pB C6 + pD C4 + pC API 2 (one commit, one function): ab8743e.
  channel = the layout's `chan`; trace code, delay (+ scalar) and date go
  through the layout when a stanza moved them (direct loads when standard,
  so no cost for other files); intervalUs stays at bytes 117-118 / Ext1
  (structural; comment). Source point: the scaled `sp` when it is espnum
  with its fraction (63.sgd converted: 99.5, was 100). Stations: the six
  layout names sht_line, sht_point, sht_index, rec_line, rec_point,
  rec_index (line/point when not NaN and not both 0, index when not 0).
  Index cache version 2 -> 3 (cached keys of the old rules are refused).
  Tests tkeyfields (4 new blocks), tsegykeysraw
  `fields_a_layout_stanza_moved`; the reference implementation in
  tsegykeysraw encoded the old rules and was rewritten.
- pD C7, zeroed SEG-D station (0/0) is unknown: c937e7e. Test tkeyfields
  `segd_zeroed_stations_are_unknown`.
- xF C1, unset SEG-Y datum elevations are NaN: a776ec3. rstat1.nim: both
  staticsInto forms give 6.956 ms (was -93.044 with headers). Test tstatics.

PENDING (second report): P3, P5, P4, P6, doc item 7 (benchmarks.md).
DEFERRED as briefed: P7, P8, API 1-4 and 6.

PUBLIC API / BEHAVIOUR CHANGES (none breaks a signature)
- TraceKeys of SEG-Y files: channel NaN when a Layout stanza reuses bytes
  13-16; srcPoint keeps a fraction; srcLine/rcvLine/rcvPoint/indexes set
  for converted SEG-D files; trace code / t0Ms / time follow a stanza.
- TraceKeys of SEG-D files: a 0/0 station is NaN.
- traceStatics: srcDatum/rcvDatum NaN when both standard fields are 0.
- readTraces / readTracesInto / traceStatics / newPrefetcher: ValueError
  where they overflowed or hung.
- PrefetchChunk.samples of a short chunk is a view of a whole chunk buffer.
- A transform is not run on a chunk a seek dropped.
- Index caches of format version 2 are re-indexed.

FOR THE LEAD AT MERGE
- tests/tcli.nim, block survey_apply (assertions my key changes flip):
  line 305: "by record number 1" -> "by stations 1";
  line 310: "\t500000\t6000400\t-\t-\t500100\t6000000\t412" ->
            "\t500000\t6000400\t13\t104\t500100\t6000000\t412";
  line 315 (the known failure): "src station: 1 traces" -> "src station: 0
  traces" (the copy reports "0 changed", nothing differs from the header).
  The comment at 297-299 describes the old behaviour.
- tests/tgeomwrite.nim, block segy_rev2_station_header_via_layout (not
  mine; it asserts the old behaviour "its keys have no stations"):
  line 157 stats.matches[jmRecord] == 1 -> jmStations;
  line 161 r.changes.receiverStations == 1 -> 0;
  lines 163-164 rec_line/rec_point 14/105 -> stay 13/104 (header stations
  are not replaced).
- src/parseSEG/model/edit.nim:509-510 says a prefetcher "sees the edits":
  it shows the keys as of openEditor (my docs say so); wording is the
  edit engineer's.
- docs/guide/conversion.md `stationHeader` row could say that traceKeys
  reads the stations back (not my file).
- model/summary.nim:160, 211-212 still read bytes 9-12 / 89-90 raw (pB C6
  note; not my file).
- Not done, noted: `sensor_type` and `component` of the PSEGD001 header are
  not read into the keys (only the six station names were asked for).
- The MemFileSource ORC hazard above could be removed by making the map
  source an acyclic type (io/memsource.nim, not mine).

SUITE
Debug: every tests/t*.nim run once on ab8743e (before the last two
commits): all pass except tcli and tgeomwrite (the assertions listed
above) and theadernif `failed_journal_syncs_are_reported`, which fails in
this worktree for a reason unrelated to the branch: it limits files to 100
bytes and the journal's session line holds the absolute path, which is
longer than that under the mandated TMPDIR. After c937e7e and a776ec3 the
31 test files that touch keys, statics, prefetch, reads, conversion,
survey and docs were rerun: same result. Release and danger runs of the
touched modules' tests: with the final report.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
SEG-Y format 1 and SEG-D 8048 to float32 went one sample at a time through
a 256-entry table of float64 scales. `ibmToFloat32Fast` makes the same
value in single precision without a table or a branch: the 24-bit fraction
times 2^k (k = 4 * exponent - 280) as two multiplies by powers of two, the
first exact, the second rounding once and only for subnormal or infinite
results, so gcc vectorizes the decode loop 8 wide.

The decoders use it only when the C compiler targets AVX2 (`decodeIbm32`,
gated on the same C constant as `copyHostOrder`): in a baseline SSE2 build
the kernel is three times slower than the table, which stays there.

readTracesInto on bench/data/y-ibm-s12000.sgy, 1000-trace chunks, best of
5, -d:danger --opt:speed, two runs each:
  -march=x86-64-v3: 6.34, 6.35 GB/s -> 13.18, 13.51 GB/s
  baseline:         5.31, 5.28      ->  5.32,  5.24 (same loop as before)

Bit-identical: tfastcodecs `ibm_float32_kernel_is_bit_identical` checks all
256 sign/exponent bytes x 4086 fractions and 1 M random words against
`ibmToFloat32`, the scalar function and the decoders in every byte order
and from odd offsets and lengths; it passes in debug, release and danger
builds with and without -march=x86-64-v3. 200 M random words through
`decodeSamples` in a v3 danger build: 0 mismatches.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Interim report of the survey/edit engineer (review pD), state of commit
c447ed7. P5 (SPS parse speed) and P6 (record edits in a batch) follow in
later commits and a final report.

FINDINGS

Section 0, tcli "src station: 1 traces": FIXED 067ed51. The block expects
  "src station: 0 traces" and that the copy equals the input; since b2d190a
  also that no "rounded" warning is printed.
  Expectations of tests/tcli.nim `survey_apply` that change once C4 lands
  (SEG-Y keys read sp with its fraction and the layout's station fields):
    "by record number 1" in dry            -> "by stations 1"
    "\t500000\t6000400\t-\t-\t500100\t6000000\t412" (keys of 63cli.sgy after
      --in-place)                          -> "...\t13\t104\t500100..."
    the block's two comments ("its keys have no stations ...", "# stations:
    layout") are then wrong.
  Also tests/tgeomwrite.nim `segy_rev2_station_header_via_layout`: with C4
  the header has both stations, so the X file's 14/105 no longer replaces
  13/104: stats.matches[jmRecord] == 1 -> jmStations, receiverStations
  1 -> 0, rec_line/rec_point 14/105 -> 13/104.

C1 journal replay: FIXED adaeb4d. Test tedit
  `interrupted_edit_over_or_beside_an_earlier_one` (twice / adjacent /
  overlapping chain, file at every state, rollbackJournal and applyJournal,
  path and open-editor forms, a conflict). Real kill under gdb
  (crash_edit.nim): RESTORED in all three scenarios. Component chain walked
  with a differing-byte count (linear, not the prototype's compare per
  state).
P1 + P2 (hex table), same commit, 200 000 traces, -d:release:
  rollbackJournal 21.9 s / 5257 MiB -> 2.4 s / 113 MiB; readJournal 3.0 s
  -> 1.3 s; writeGeometry 5.4 s -> 2.6 s.

C3 JoinGroup: FIXED 5bd8c24. Tests teditkeys
  `a_partial_join_leaves_the_rest_to_the_headers`,
  `a_join_of_both_ends_keeps_what_it_derived`,
  `changed_groups_name_what_differs`. Offset and midpoint the join derived
  are derived again when an end it left to the headers moves; a header
  midpoint/offset the join had kept stays.

C8: FIXED 43f01c6. Test tsurvey `a_join_that_changes_nothing_is_no_change`
  (applySurvey(overwrite) twice: 0 changed, keysRevision unchanged;
  saveIndex after a join that changed nothing).
C12: FIXED a58b644. Test tsurvey
  `a_station_part_in_the_header_checks_the_relation`. A known part agrees
  when equal to 1/100 or when it is the relation's value as a whole number
  (header 100, relation 99.5); a non-zero point index must match.
C10: FIXED b2d190a. Test tgeomwrite
  `counts_and_rounding_describe_what_was_written`. SEG-Y: per update, slot
  value before/after; SEG-D: kinds written intersected with what the keys of
  the new bytes hold differently. Rounding noted once per kind and trace,
  only when no field of the same part holds the value exactly.

C5: FIXED f863fbe. Tests tsurvey `sps10_without_h00_is_read_as_10`,
  `sps_h00_spellings_of_the_revision`. 14 corpora: 0 wrong 50-record windows
  (base: 4 corpora wrong). Undeclared revision is a warning.
C11: FIXED e25cbd8. Tests tsurvey
  `reader_diagnostics_are_capped_per_code_and_field`,
  `blank_columns_past_80_are_not_reported`, `a_built_sps_file_is_written`,
  `survey_numbers_are_finite`, `a_station_given_twice_is_logged`,
  `a_byte_order_mark_is_not_a_record`,
  `a_time_with_a_blank_for_a_leading_zero`,
  `p1_says_why_it_decoded_nothing`, `p1_angle_unit_is_decided_once_per_file`.
  1e6 lines of "R": 3 000 000 diagnostics / 1231 MiB -> 18 / 620 MiB.

P2 (rest): FIXED 4a8150c. Layout names resolved once per planner; keys of
  the current header straight from its bytes; copy/convertWithGeometry
  write through a writable map without a journal. 200 000 traces,
  -d:release, minima of 3; written files byte-identical to the base tree's:
  geometryChanges 1792 -> 328 ms; writeGeometry 5375 -> 1113 ms;
  copyWithGeometry 5801 -> 601 ms; convertWithGeometry 6161 -> 699 ms.
P3: FIXED c447ed7. Tests tsurvey
  `relation_lookups_agree_with_a_scan_in_file_order` (randomized, both
  paths against a plain scan), `relation_runs_walk_a_record_station_by_station`,
  `a_record_of_thousands_of_rows`. Morgantown, -d:release, minima of 3,
  processSEG unchanged: expectedChannels (all records) 688 -> 0.6 ms;
  preplotFold 1194 -> 637 ms (classes 1704 -> 1095); describePatches
  2440 -> 1931 ms; receiverRelations 3019 -> 2209 ms. A fold walking
  relationRuns: 253 ms, bins identical to preplotFold (0 of 243 810).
P5, P6: not yet done (next commits).

API items: FIXED 72b5399. currentTraceField/currentRecordField/
  currentFileField take `ed: HeaderEditor` (by value). HeaderEditor.log
  holds the first 1000 refused edits of a session; close/release add the
  count of the rest. Tests tedit
  `current_fields_read_through_an_editor_that_is_not_var`,
  `refused_edits_do_not_grow_the_log_without_bound`.

DEFERRED (not attempted, as briefed): journal undo-to-a-mark and session
stamp check (API 2); units from H20/H201 (API 4); station-by-line views
(API 5 beyond the run iterator); strictImpliedDecimals (API 12); Rev 2
Ext1 float64 slots in writeGeometry. Also not done: readJournal still
builds lines character by character (P8, not assigned); a UTF-8 BOM is
skipped on reading and not written back.

FOR processSEG

New JoinGroup members (model/indextypes.nim, a set[JoinGroup] is 1 byte):
  jgSourceXy        srcX, srcY
  jgReceiverXy      rcvX, rcvY
  jgSourceZ         srcZ
  jgReceiverZ       rcvZ
  jgDerived         offset, cmpX, cmpY
  jgSourceStation   srcLine, srcPoint, srcIndex
  jgReceiverStation rcvLine, rcvPoint, rcvIndex
  jgComponent       component
  (jgCoordinates and jgStations are gone.)
  func changedGroups*(before, after: TraceKeys): set[JoinGroup]
  proc markJoined*(ix: var TraceIndex, trace: int, groups: set[JoinGroup])
  func joinedGroups*(ix: TraceIndex, trace: int): set[JoinGroup]

Run iterator (survey/join.nim):
  iterator relationRuns*(g: SurveyGeometry, record: int64,
      domain = rdSource): RelationRun
  type RelationRun* = object
    fromChannel*: int64   # channel of the first station
    toChannel*: int64     # last channel of the last station
    channelStep*: int64   # channels from one station to the next
    stations*: int        # stations in the run
    components*: int      # channels per station: 1, or channelStep when
                          # g.multiComponent
    fixed*: StationKey    # X: the record's source; O: the gather's receiver
    line*: float64        # line of the run's stations
    firstPoint*, lastPoint*: float64
    pointStep*: float64   # per station; negative when descending, 0 for one
    index*: int16         # point index of the run's stations
  func station*(run: RelationRun, i: int): StationKey
      # station i (0-based) exactly as `relations` yields it; its first
      # channel is fromChannel + i * channelStep
  func relationsDisjoint*(g: SurveyGeometry, record: int64,
      domain = rdSource): bool
      # true: the record's runs ascend by channel and share none, so the
      # runs give every channel once; false: use `relations` where a shared
      # channel matters (the earlier run has it)
  Walk: for rec in g.records(domain): for run in g.relationRuns(rec, domain).

PUBLIC API CHANGES

Breaking:
- JoinGroup members (above).
- currentTraceField/currentRecordField/currentFileField: `ed: HeaderEditor`
  instead of `var HeaderEditor` (source compatible for callers).
Behaviour:
- applyGeometry(keys)/applySurvey: "changed" means a value differs; a join
  that changes no trace leaves keysRevision and TraceIndex.joined alone.
- joinStations: jmConflict for a contradicted station part.
- GeometryChanges counts and "rounded" warnings (C10).
- copyWithGeometry/convertWithGeometry: no temporary journal file.
- readSps: undeclared revision is sevWarning (was info); structural
  detection; SpsFile.revision defaults to sps21; diagnostics capped at 5
  per code and field plus a count; BOM skipped; inf/nan/1e999 are not
  numbers; " 91530" is a time.
- readP1: auAuto decided once per file (P1File.options.angleUnit holds the
  result, info when grads); a read without data records warns; logical
  file format by majority of its first data records.
- addSps: duplicate stations logged; its notes capped per kind and file.
- HeaderEditor.log: at most 1000 refusals logged.
New: changedGroups, RelationRun, relationRuns, station, relationsDisjoint.

FOR THE LEAD AT MERGE

- Files outside my list that I had to touch: tests/ttracerows.nim (two
  lines: jgCoordinates -> jgReceiverXy / jgSourceXy), tests/tindexcache.nim
  (`save_refusals`: saveIndex after an empty join now succeeds; a join that
  changes a trace is refused).
- model/traceindex.nim hunks: keepJoined (rewritten in place),
  applyGeometry(ix) (changedGroups, joined set only when result > 0).
  model/keys.nim: applyGeometry(k, g) only. model/indextypes.nim: JoinGroup,
  joinedTraces doc, static assert, changedGroups, markJoined doc.
- survey/writeback.nim now imports private/segykeys (RawHeaders, segyKeys,
  as traceindex.nim does); if C4 renames them, adapt currentSegyKeys.
- After C4: update the tcli and tgeomwrite expectations listed under
  Section 0.
- tests/theadernif.nim `failed_journal_syncs_are_reported` (not mine) fails
  under any long temp directory: it allows the journal 100 bytes, less than
  its session line when TMPDIR is the scratchpad path. Same failure on the
  base tree; passes with a short temp path. tgeomwrite's variant uses
  session size + 20.
- C2 documentation agreement (editing.md, edit.nim:509, tracerows.nim,
  viewer.md) is with the index engineer; I did not touch those sentences.

SUITE (debug, commit c447ed7, every tests/t*.nim run on a clean export):
52 of 53 pass; the one failure is theadernif as described (environment).
tedit, teditkeys, tsurvey, tgeomwrite also pass in -d:release and -d:danger.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…freshes its keys

setRecordField synced the journal and wrote the batch on every call, then
scanned every index entry to find the record's traces and refreshed their
keys, bumping keysRevision per edit. 500 record edits in a batch on a
500-record, 500 000-trace SEG-D file took 2.9 s (keysRevision +500).

A record edit in a batch is now journaled in memory like a trace edit.
When the batch is written, the index's copies of the edited record
headers are decoded from the map once, and the records' traces (found by
binary search: entries are in file order, record after record) join the
stale list for one key refresh. The same 500 edits, with the endBatch
that writes them: 113 ms, keysRevision +1, same keys (-d:release, minima
of 3 alternating runs).

Found on the way: a batch is written when it holds 4096 edits, from
inside the call of the edit that fills it, before that call lists its
trace as stale; endBatch then returned early with nothing pending, so
the trace's stored keys were never refreshed (trace 95: key 0, file 21).
flushBatch now refreshes what is stale also when nothing is pending.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…kernel

The public `encodeSamples` of SEG-Y, SEG-D and SEG-2 sat outside the
decoders' `{.push checks off.}` and stored each sample through the
checked `putNum` with a run-time byte order: in the shipped -d:release
build every byte was bounds-checked and nothing was vectorized. IBM float
went through frexp, ldexp and round per sample.

They now have the decoders' shape: lengths checked once, then a
`{.raises: [].}` kernel that stores through unchecked pointers with the
byte order a constant (`private/bytestore`: `storeNum`, `storeU24`,
shared with `private/sampleconv`), `copyMem` when the samples already
are in the file's order, branch-free integer kernels (round half away
via `trunc`, clamp, flag) and `ibmfloat.normalToIbm`, the IBM conversion
of a normal float32 in integer arithmetic. A kernel only flags what it
cannot do (a value out of range, IBM subnormals, NaN, infinities,
doubles beyond float32, and the formats without a kernel: SEG-Y 4, 9,
12, the SEG-D exponent formats, SEG-2 format 3); the trace then goes
through the plain per-sample code, kept as `encodePlain`, which gives
the same error or bytes as before.

Two things kept gcc from vectorizing and are worth knowing:
- after a call of a generic proc the compiler tests its error flag, an
  exit from the loop: the stores are templates;
- gcc keeps scalar code that could raise floating-point exception flags
  in another order: the three sample modules compile their C file with
  -fno-trapping-math (`localPassC`, gcc only; values do not depend on it).

GB/s of encoded output, 2000-sample traces, best of 2-3 runs,
bench_encode.nim (reviewer's):

                         -d:release -march=x86-64-v3   -d:danger --opt:speed v3
  f32 -> SEG-Y 5 BE          1.36 ->  55.7               15.0 ->  60.0
  f32 -> SEG-Y 5 LE (host)   1.35 -> 120.8               13.4 -> 120.6
  f64 -> SEG-Y 5 BE          2.39 ->  31.1               10.0 ->  30.9
  f32 -> SEG-Y 1 IBM         0.22 ->  10.6               0.26 ->  10.5
  f32 -> SEG-Y 2 int32       0.64 ->   3.97              1.06 ->   4.03
  f32 -> SEG-Y 3 int16       0.43 ->   2.20              0.53 ->   2.20
  f32 -> SEG-D 8058          2.38 ->  59.6               15.0 ->  61.8
  f32 -> SEG-D 8036          0.57 ->   2.35              0.70 ->   2.35
  f32 -> SEG-2 4 LE          2.34 -> 120.3               14.8 -> 120.3

The converters' `encodeChecked` uses the same pieces where they keep its
bytes (bench_encode2.nim, release): f64 -> IBM 0.24 -> 0.88 GB/s, int32
1.05 -> 1.38; IEEE and int16 unchanged.

Bit-identical: tests/tfastencode.nim compares every format, byte order,
input type and odd length against the encoders as they were
(tests/utils/refencode.nim), bytes and error messages, on random, edge
and refused values, and the IBM kernel against `float32ToIbm` on 2 M
random float32 bit patterns and every exponent with the mantissas that
round or tie; it passes in debug, -d:release and -d:danger, with and
without -march=x86-64-v3. The release kernel's disassembly has the packed
instructions (vpshufb, vcvttpd2dq, vpsrlvd; 63 loops in 32-byte vectors).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… loop

`expFraction` searched the exponent of 8015, 8022, 8024, 8042 and 8044
with one `pow` (and a division, a multiplication and a `round`) per
candidate: up to 16 library calls per sample. The exponent of base 2^k is
ceil(x / k) for a magnitude below 2^x, which the float's own exponent
gives; rounding the fraction can carry it one exponent up, nothing more.
Scaling by a power of two is exact, so the fraction is the one the search
rounded. The kernels (`expParts`) flag a value beyond the format's range;
the trace then goes through the old code, which raises as before.

ns per sample, f32 traces of 2000 samples (sin x 1000), best of 5,
-d:release -march=x86-64-v3 (the same within 5 % with -d:danger
--opt:speed):

  8015   170   ->  4.5
  8022    93   ->  6.3
  8024    91   ->  7.6
  8042    60   ->  5.1
  8044    63   ->  4.8

Bit-identical: tests/tfastencode.nim gains a block that compares the five
formats (8015 also in two's complement, f32 and f64 input) with the old
encoder on more than 40000 values each, of every binary order from 2^-40
to full scale, among them the values on an exponent step and those that
round up to one; the general block covers the refusals. Passes in debug,
-d:release and -d:danger, with and without -march=x86-64-v3.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…keepRaw

readSps built every line character by character, copied it for the
caller, cut a blank-padded string out of it per field, concatenated a
field label per field and stripped each field into another string before
parsing it.

Lines are now spans of the 1 MiB chunk they were read in (linespans.nim,
internal; memchr finds the line ends; textRecords is built on it and
gives the same records, checked against a byte-at-a-time splitter across
chunk boundaries, cut lines and every terminator). The field readers
take the record as a view and a field as its trimmed columns, and name a
field only when they report it. The record lists are sized once from the
file size.

SpsOptions.keepRaw (default true) says whether R, S, X and O records
keep their text for the byte-exact write-back; addSps(g, path) reads
without it.

Same output: the reader before and after this commit give identical
records, headers, order and diagnostics on the Morgantown R/S/X files,
their Rev 1.0, fixed-80 and CR-only forms, and seven corpora of 30 000
damaged lines each, under four option sets (dump compared byte for
byte). tsurvey also passes under AddressSanitizer.

-d:release, minima of 5, 1e6 records (82 MB):
  R file  1337 ms, 880 MiB peak -> 381 ms, 343 MiB (no raw: 367 ms, 249 MiB)
  X file  1400 ms, 1023 MiB     -> 462 ms, 403 MiB (no raw: 414 ms, 311 MiB)
  addSps of the Morgantown survey (28.7 MB, three files) 555 -> 209 ms

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
`traceKeysInto` and `startTimeMsInto` (so `startTimeMs(f, i)`) built the
file's layout plan on every call: about 100 allocations and 9.5 us before a
single header byte was read (6.4 us before the keys read more names
through the layout). The plan is now made when the index is made and kept
with the `SeismicFile`, read-only from then on, so concurrent readers
share it. The two values it took from the binary header (time basis, the
fixed interval), which an editor can change, are read per call and passed
to `segyKeys`. A call checks that the plan still fits `index.segy` (byte
order, revision, the same layout entries) and resolves its own otherwise,
so code that replaces the layout through the public index is followed.

`startTimeMsInto` reads only the delay of a SEG-Y trace (through the
layout, as the key does), not the whole key; SEG-Y calls set up no
per-call reader.

bench_ondemand, y-ieee-s12000.sgy, -d:danger v3, ns per call (two runs):
  traceKeysInto, 1 trace:   9553, 9546 -> 224, 219
  traceKeysInto, 10 traces: 11576, 11685 -> 1899, 1900
  startTimeMs(f, i):        9518, 9581 -> 11, 11
SEG-D and SEG-2 are unchanged (313 and 1458 ns per one-trace call).

Tests: tkeyfields `segy_keys_on_demand_resolve_the_layout_once` (no
allocation per call, was 109; an edited binary interval and a replaced
layout are followed); tsharedreads reads keys on demand from 8 threads
(TSan clean).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…0 columns

`nim docs` (run by CI with RST style warnings as errors) took the line
"O) is ..." of RelationRun's comment for an enumerated list item. No
change in behaviour.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
The test keeps a list of `src/parseSEG/private/` modules so that a new one
is checked for leaking through the umbrella; `bytestore` (the encoders'
unchecked stores) is new.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Review pC (parseSEG writers, converters, CLI). Branch fix/writers, 22
commits on eab9219 (plus the interim report b0d99a3). Everything in the
brief is done except the two items it told me to defer.

FINDINGS
C1  fixed 792d768. closeChecked (common/outputfile) checks fflush+fclose;
    used by DiskSink.close, Seg2Writer.close and the SEG-Y binary header
    patch. Tests (tests/twritefail.nim, new; RLIMIT_FSIZE, POSIX only):
    a_failed_last_flush_raises_and_installs_nothing (keys writer, like
    SEG-Y/SEG-D/SEG-2), a_failed_last_flush_is_fatal_for_convertFile (5
    source/target pairs). No fsync before the rename (not asked).
C2  fixed 69645af. Both writeTrace forms discard the output on IOError; a
    later writeTrace/close raises ValueError. Test a_failed_write_discards_
    the_output. Reviewer's r_latch: "no output file".
C9  fixed 0303eaf. createTempFor refuses a directory (IOError), the
    converters check before reading the input, finishOutput removes an
    output it cannot move. Tests a_directory_as_destination_is_refused_
    before_any_work, a_failed_rename_leaves_no_temporary_file.
C3  fixed 949e4a9. tests/tsegyrevconvert.nim (new, synthetic). Rev 0 ->
    1/2.0/2.1 clears trace bytes 181-240 and, beyond the report, binary
    3261-3600 (a 2 in 3505-3506 made the output unreadable; reproduced);
    Rev 1 -> 2 clears binary 3261-3300 and 3507-3532. Rev >=1 -> 0 applies
    the time scalar to bytes 95-114 (rounded: info; beyond int16: clamped,
    warning with trace count) and clears 181-240 (info). A Layout stanza
    lost to Rev 0 is a warning. Interval > 65535 before Rev 2: error +
    trace skipped; fixed-length file: fatal. Not done: moving the other
    Rev 2 extended overrides (traces per ensemble, fold, original count/
    interval) into their 16-bit fields.
C4  fixed f682e0f. Tests a_count_held_in_the_extended_field_alone,
    fixed_length_traces_rev1_cannot_hold, variable_length_traces_too_long_
    are_skipped.
C5  fixed c98a66c (writefile + convertSegy; count, and the interval when
    it was left to the binary header). Tests tsegyrevconvert rev0_traces_
    that_leave_their_count_to_the_binary_header, twritekeys like_a_rev0_
    input_whose_traces_leave_their_count_to_the_binary_header.
C6  fixed 647f7a5. keysTrace -> private/segyencode.keysToHeaders (with
    timeScalarFor, scaledTime); writeTrace(keys), convertSegdToSegy and
    convertSeg2ToSegy share it. Test tconvert keys_survive_the_conversion_
    to_segy (SEG-2 and SEG-D input, Rev 2.1/1/0: srcPoint 99.5, t0 -10.5 /
    0.25 / 40000, fractional second, Rev 0 code 12 -> 1, statics share the
    time scalar). tconvert line 46 pinned energySourcePoint == 100: now 0,
    shotpoint 9950/-100, key 99.5. Output changes: timeScalar 1 (was 0) for
    whole-ms starts; SEG-D time of day from TraceKeys.time; Rev 0 targets
    get codes 1-8 only; interval > 65535 before Rev 2 = error + skip.
C7  fixed 9c8d2d3. segd encodeSamples(f, src, dst, twosComplement8015 =
    false); passed on by SeismicWriter, convertSegdRevision (resized 8015
    traces) and convertToSeg2. Reviewer's r_twos8015: 0 of 1000000 samples
    differ (was 494274).
C8  fixed de50444. wholeInterval (1e-9 relative) in the shared builder and
    the binary header. Test twritekeys whole_intervals_with_float_noise
    (all 1472 noisy values of 1..65535, Rev 0/1/2.1).
C10 fixed 1c91c9f. SegFormatError for NaN/Inf/out of range in SEG-Y 1,
    SEG-D 8048, 8015, 8022, 8024, 8042, 8044; a double beyond float32 but
    within IBM range is encoded directly (float64ToIbm); values within
    float32 keep their bytes; encodeChecked falls back to encodeExact; the
    8048 -> format 1 word copy is untouched; tf8015 refused for sample
    counts not multiple of 4. tsegdsamples review_clamps pinned the
    clamping: replaced by values_beyond_a_formats_range_are_refused.
C11 fixed 1c91c9f (same commit, same lines). ValueError for a short
    destination in SEG-Y/SEG-D encodeSamples and for SegdWriter misuse.
C12 fixed 4ab6346 (library), f9f7988 (CLI). Largest count in the binary
    header for mixed counts (twritefile mixed_counts_keep_the_binary_count
    pinned the old header; now ..._write_the_largest_...); int32 sequence
    clamp (in 647f7a5); keys writer Rev 0: |t0Ms| > 32767 raises
    ValueError, midpoint not written into 181-188, documented. CLI:
    `--traces 0 dump f`, convert --rev=3 exits 2 (SEG-D too: tcli line 177
    expected 1, now 2), --force/--out in the help, banner names SEG-2.
    DEFERRED as briefed: > 65535 samples in a fixed-length Rev 2 template
    without Ext1; SEG-2 -> SEG-Y receiver at local (0,0) reads back NaN.
    Not done: a CLI option for twosComplement8015.
P2  fixed f3ed82d. DiskSink collects records in its own 1 MiB buffer
    (SinkBufferSize), flushes when full and on close; a failed sink stays
    failed. bench_write keys writer, 60000 traces x 2000 f32 (0.49 GB),
    -d:danger --opt:speed -march=x86-64-v3, minima of 3: write syscalls
    120001 -> 946; sys 0.955 -> 0.127 s/GB; user unchanged (0.154 -> 0.157).
    Tests twritefail disk_sink_collects_records, a_disk_sink_that_failed_
    stays_failed.
P1  fixed 4e04ea9. Kernels in {.raises: [].} procs: unchecked stores with
    a static byte order (private/bytestore, new), copyMem for host order,
    branch-free integer kernels, ibmfloat.normalToIbm (integer IBM
    kernel); the old per-sample code stays as encodePlain for flagged
    traces and formats without a kernel (SEG-Y 4, 9, 12; SEG-2 format 3).
    GB/s, bench_encode, best of 2-3, release (-d:release v3, as shipped)
    | danger (-d:danger --opt:speed v3):
      f32 -> SEG-Y 5 BE     1.36 ->  55.7 | 15.0 ->  60.0
      f32 -> SEG-Y 5 LE     1.35 -> 120.8 | 13.4 -> 120.6
      f64 -> SEG-Y 5 BE     2.39 ->  31.1 | 10.0 ->  30.9
      f32 -> SEG-Y 1 IBM    0.22 ->  10.6 | 0.26 ->  10.5
      f32 -> SEG-Y 2 int32  0.64 ->   3.97| 1.06 ->   4.03
      f32 -> SEG-Y 3 int16  0.43 ->   2.20| 0.53 ->   2.20
      f32 -> SEG-D 8058     2.38 ->  59.6 | 15.0 ->  61.8
      f32 -> SEG-D 8036     0.57 ->   2.35| 0.70 ->   2.35
      f32 -> SEG-2 4 LE     2.34 -> 120.3 | 14.8 -> 120.3
    encodeChecked (converters, release): f64 -> IBM 0.24 -> 0.88, int32
    1.05 -> 1.38, IEEE/int16 unchanged. End to end (bench_write, danger):
    keys writer IBM user 3.67 -> 0.17 s/GB, int16 1.80 -> 0.63.
    Bit-identical: tests/tfastencode.nim (new) against the old encoders
    kept in tests/utils/refencode.nim, bytes and error messages, every
    format/order/type/odd length; IBM kernel against float32ToIbm on 2 M
    random patterns and every exponent; passes in debug, release, danger,
    with and without -march=x86-64-v3. Disassembly of the release kernel
    has packed instructions (vpshufb, vcvttpd2dq, vpsrlvd).
    Two things the lead should know: the stores are templates (a call of
    a generic proc is followed by an error-flag test that blocks
    vectorization), and the three samples modules compile their C file
    with -fno-trapping-math (localPassC, gcc only; needed for the integer
    kernels, values do not depend on it, decoder speed unchanged).
P3  fixed 3919e75. expParts takes the exponent from the float's bits. ns
    per sample, release v3, best of 5 (danger within 5 %): 8015 170 -> 4.5,
    8022 93 -> 6.3, 8024 91 -> 7.6, 8042 60 -> 5.1, 8044 63 -> 4.8.
    Bit-identical on > 40000 values per format over the whole range
    (tfastencode segd_exponent_formats_over_their_whole_range).
API 3 fixed b2642cf. SeismicWriter.close raises IOError for a failed
    rename; the returned log holds warnings only.
API 5 fixed b2642cf. Tensor writeTrace: `samples` -> `count`.
API 7 fixed 1306e1b. Seg2Writer.abandon(); privateAccess gone.
Docs  4d5bcb2: guide/processing.md (writing), guide/conversion.md,
    docs/api.md; tdocs passes (44 examples).
Not attempted (not mine): API 1, 2, 4, 6, 8, 9; P4.

PUBLIC API CHANGES
- BREAKING writeTrace(w, src, trace, x: Tensor, row, count = -1, ...):
  parameter renamed from `samples`.
- BREAKING (behaviour) SeismicWriter.close raises IOError when the output
  cannot be moved into place (was a fatal log entry); writeTrace discards
  the output and closes the writer on IOError.
- BREAKING (behaviour) encodeSamples: SEG-Y 1 and SEG-D 8048/8015/8022/
  8024/8042/8044 raise SegFormatError for NaN/Inf/out of range (were 0 or
  clamped); a short destination raises ValueError (was AssertionDefect);
  SegdWriter misuse raises ValueError (was AssertionDefect).
- BREAKING (behaviour) DiskSink: the file is complete only after close
  (records are buffered, 1 MiB); close and Seg2Writer.close raise IOError
  when bytes did not reach the file.
- openSeismicWriter: IOError at once for a directory path; ValueError for
  tf8015 with counts not multiple of 4. writeTrace(keys) on Rev 0:
  ValueError for |t0Ms| > 32767. convertFile: fatal for a directory.
- New: segd encodeSamples(..., twosComplement8015 = false);
  Seg2Writer.abandon(); io/diskio.SinkBufferSize; codecs (no stability
  promise): ibmfloat.float64ToIbm, normalToIbm.
- Internal: outputfile.closeChecked, isDirectory; private/segyencode
  keysToHeaders, timeScalarFor, scaledTime, wholeInterval,
  declaredSamples, setSampleCount; private/bytestore (new module).
- CLI: an undefined --rev exits 2.

processSEG: no caller has to change. It calls writeTrace(keys, samples,
overrides) and close. writeStack's log from close can no longer be fatal
(its doc mentions isFatal; a failed rename raises IOError, which the doc
lists). Intervals handed back as dtMs * 1000 are accepted (C8). A stack
with NaN/Inf samples written as format 1 (IBM) now raises SegFormatError
(st5, the default, holds NaN).

FOR THE LEAD WHEN MERGING
- tests/tcli.nim survey_apply (edit/survey engineer): since 647f7a5
  `parseSEG convert 63.sgd x.sgy` writes source point 99.5, so `survey-
  apply --values=stations` has nothing to change ("src station: 0
  traces"); the block's "src station: 1 traces" expectation must be
  adapted. The rest of tcli passes (run with that block removed).
- tests/tumbrella.nim (not on my list): one line, "bytestore" added to
  privateMods (ef57963), which the test demands for a new private module.
- tests/theadernif.nim failed_journal_syncs_are_reported (not mine)
  allows the journal 100 bytes "for its session line", which holds the
  file path: it fails under a long TMPDIR such as the worktree temp dirs
  (151-character path) and passes with a short one. Not a regression.
- segd/demux.nim (not mine) closes a DiskSink in a `finally`; that close
  now raises IOError on a failed flush (its doc allows IOError).
- Seen, not mine: TraceKeys.intervalUs of a Rev 0 trace whose header
  interval is 0 is 0 while the streaming reader uses the binary header's
  (reader/index); SeismicWriter cannot repeat the same SEG-D record
  twice in a row ("trace 5 comes next, not 0").
- New files: src/parseSEG/private/bytestore.nim, tests/twritefail.nim,
  tests/tsegyrevconvert.nim, tests/tfastencode.nim,
  tests/utils/refencode.nim.

SUITE (after the last commit, ef57963)
All 56 test files, each in its own temp dir (`nim test` stops at tcli's
known failure, so every file was run by a script): debug, -d:release and
-d:danger each 54 pass, 2 fail:
- tcli: only the survey_apply block (see above); with that block removed
  the file passes (debug).
- theadernif: only under the long temp path; passes with a short TMPDIR
  (debug).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Final report of the survey/edit engineer (review pD). Branch fix/survey,
head 0047159, 14 commits on eab9219 plus two report commits. Every item
of the brief is done; nothing was rejected.

FINDINGS

Section 0, tcli "src station: 1 traces": FIXED 067ed51. The block expects
  "src station: 0 traces", that the copy equals the input, and (since
  b2d190a) no "rounded" warning.
  Expectations that change once C4 lands (SEG-Y keys read sp with its
  fraction and the layout's station fields):
  tests/tcli.nim `survey_apply`:
    "by record number 1" in dry            -> "by stations 1"
    "\t500000\t6000400\t-\t-\t500100\t6000000\t412" (keys of 63cli.sgy after
      --in-place)                          -> "...\t13\t104\t500100..."
    its two comments ("its keys have no stations ...", "# stations: layout")
    are then wrong.
  tests/tgeomwrite.nim `segy_rev2_station_header_via_layout`: the header
    then has both stations, so the X file's 14/105 no longer replaces
    13/104: stats.matches[jmRecord] == 1 -> jmStations, receiverStations
    1 -> 0, rec_line/rec_point 14/105 -> 13/104.

C1 journal replay: FIXED adaeb4d. Test tedit
  `interrupted_edit_over_or_beside_an_earlier_one` (twice / adjacent /
  overlapping chain; file at every state of the chain; rollbackJournal and
  applyJournal; path form with an open map; a conflict). Real kill under
  gdb (crash_edit.nim): RESTORED in all three scenarios; journal fuzz 4000
  journals, no Defect. The chain is walked with a count of differing bytes
  (linear), not the prototype's compare per state.
P1 and the hex table of P2, same commit, 200 000 traces, -d:release:
  rollbackJournal 21.9 s / 5257 MiB -> 2.4 s / 113 MiB; readJournal 3.0 s
  -> 1.3 s; writeGeometry 5.4 s -> 2.6 s.

C3 JoinGroup: FIXED 5bd8c24. Tests teditkeys
  `a_partial_join_leaves_the_rest_to_the_headers`,
  `a_join_of_both_ends_keeps_what_it_derived`,
  `changed_groups_name_what_differs`. Differs from the reviewer's sketch in
  one point: offset and midpoint the join derived are derived again only
  when an end it left to the headers has moved, and a header offset or
  midpoint the join had kept (non-overwrite) stays; otherwise the stored
  values are restored exactly.

C8: FIXED 43f01c6. Test tsurvey `a_join_that_changes_nothing_is_no_change`.
C12: FIXED a58b644. Test tsurvey
  `a_station_part_in_the_header_checks_the_relation`. A known part agrees
  when equal to 1/100 or when it is the relation's value as a whole number
  (header 100, relation 99.5); a non-zero point index must match too.
C10: FIXED b2d190a. Test tgeomwrite
  `counts_and_rounding_describe_what_was_written`. SEG-Y: per update, the
  slot value before and after; SEG-D: the kinds written intersected with
  what the keys of the new bytes hold differently. Rounding is noted once
  per kind and trace, only when no field of the same part holds the value
  exactly. X and Y are separate parts now (a layout with sht_x but no
  sht_y would report the missing one).

C5: FIXED f863fbe. Tests tsurvey `sps10_without_h00_is_read_as_10`,
  `sps_h00_spellings_of_the_revision`. 14 corpora: 0 wrong 50-record
  windows (base: 4 corpora wrong). Undeclared revision is a warning.
C11: FIXED e25cbd8. Tests tsurvey
  `reader_diagnostics_are_capped_per_code_and_field`,
  `blank_columns_past_80_are_not_reported`, `a_built_sps_file_is_written`,
  `survey_numbers_are_finite`, `a_station_given_twice_is_logged`,
  `a_byte_order_mark_is_not_a_record`,
  `a_time_with_a_blank_for_a_leading_zero`,
  `p1_says_why_it_decoded_nothing`, `p1_angle_unit_is_decided_once_per_file`.
  1e6 lines of "R": 3 000 000 diagnostics / 1231 MiB -> 18 / 620 MiB.
  Beyond the letter of the finding: a P1 logical file's format is now the
  one most of its first data records match (was: the first match), and P3
  data needs a number in columns 37-64.

P2 (rest): FIXED 4a8150c. Layout names resolved once per planner; the keys
  of the current header come straight from its bytes; copyWithGeometry and
  convertWithGeometry write through a writable map without a journal.
  200 000 traces, -d:release, minima of 3; the files written are
  byte-identical to the base tree's:
  geometryChanges 1792 -> 328 ms; writeGeometry 5375 -> 1113 ms;
  copyWithGeometry 5801 -> 601 ms; convertWithGeometry 6161 -> 699 ms.
P3: FIXED c447ed7. Tests tsurvey
  `relation_lookups_agree_with_a_scan_in_file_order` (randomized, both
  paths against a plain scan), `relation_runs_walk_a_record_station_by_station`,
  `a_record_of_thousands_of_rows`. Morgantown, -d:release, minima of 3,
  processSEG unchanged: expectedChannels (all records) 688 -> 0.6 ms;
  preplotFold 1194 -> 637 ms (classes 1704 -> 1095); describePatches
  2440 -> 1931 ms; receiverRelations 3019 -> 2209 ms. A fold that walks
  relationRuns: 253 ms, bins identical to preplotFold (0 of 243 810).
P5: FIXED 899d4f3. Tests tsurvey
  `lines_are_cut_alike_at_any_chunk_boundary`,
  `sps_records_without_their_text`. Output identical to the reader before
  the change (records, headers, order and diagnostics dumped and compared
  byte for byte) on the Morgantown R/S/X files, their Rev 1.0, fixed-80 and
  CR-only forms and seven corpora of 30 000 damaged lines, under four
  option sets; tsurvey passes under AddressSanitizer; fuzz 170 000
  iterations, no Defect. -d:release, minima of 5, 1e6 records (82 MB):
  R file 1337 ms / 880 MiB -> 381 ms / 343 MiB (keepRaw false: 367 ms /
  249 MiB); X file 1400 ms / 1023 MiB -> 462 ms / 403 MiB (414 ms /
  311 MiB); addSps of the Morgantown survey 555 -> 209 ms.
P6: FIXED 8e92b15. Test teditkeys `record_edits_wait_for_the_batch`.
  500 setRecordField in a batch with its endBatch, 500 records / 500 000
  traces, -d:release, minima of 3: 2928 ms (keysRevision +500) -> 113 ms
  (+1), same keys.
  Found on the way and fixed in the same commit (not in the review): the
  edit that fills a batch (the 4096th) is written from inside its own
  call, before its trace is listed as stale, and endBatch returned early
  with nothing pending, so that trace's stored keys were never refreshed.
  Reproduced on the base tree (key 0, file 21). Test teditkeys
  `the_edit_that_fills_a_batch_refreshes_its_keys`.

API items: FIXED 72b5399. currentTraceField/currentRecordField/
  currentFileField take `ed: HeaderEditor`. HeaderEditor.log holds the
  first 1000 refused edits of a session; close/release add the count of
  the rest. Tests tedit
  `current_fields_read_through_an_editor_that_is_not_var`,
  `refused_edits_do_not_grow_the_log_without_bound`.

0047159: a doc comment `nim docs` rejected (RST), four lines wrapped.

DEFERRED (not attempted, as briefed): journal undo-to-a-mark and session
stamp check (API 2); units from H20/H201 (API 4); station-by-line views
(API 5 beyond the run iterator); strictImpliedDecimals (API 12); Rev 2
Ext1 float64 slots in writeGeometry. Also left: readJournal still builds
lines character by character (P8, not assigned); a UTF-8 BOM is skipped on
reading and not written back; setFileField still writes the batch so far.

FOR processSEG

New JoinGroup members (model/indextypes.nim; a set[JoinGroup] is 1 byte):
  jgSourceXy        srcX, srcY
  jgReceiverXy      rcvX, rcvY
  jgSourceZ         srcZ
  jgReceiverZ       rcvZ
  jgDerived         offset, cmpX, cmpY
  jgSourceStation   srcLine, srcPoint, srcIndex
  jgReceiverStation rcvLine, rcvPoint, rcvIndex
  jgComponent       component
  (jgCoordinates and jgStations are gone.)
  func changedGroups*(before, after: TraceKeys): set[JoinGroup]
  proc markJoined*(ix: var TraceIndex, trace: int, groups: set[JoinGroup])
  func joinedGroups*(ix: TraceIndex, trace: int): set[JoinGroup]

Run iterator (survey/join.nim):
  iterator relationRuns*(g: SurveyGeometry, record: int64,
      domain = rdSource): RelationRun
  type RelationRun* = object
    fromChannel*: int64   # channel of the first station
    toChannel*: int64     # last channel of the last station
    channelStep*: int64   # channels from one station to the next
    stations*: int        # stations in the run
    components*: int      # channels per station: 1, or channelStep when
                          # g.multiComponent
    fixed*: StationKey    # X: the record's source; O: the gather's receiver
    line*: float64        # line of the run's stations
    firstPoint*, lastPoint*: float64
    pointStep*: float64   # per station; negative when descending, 0 for one
    index*: int16         # point index of the run's stations
  func station*(run: RelationRun, i: int): StationKey
      # station i (0-based) exactly as `relations` yields it; its first
      # channel is fromChannel + i * channelStep
  func relationsDisjoint*(g: SurveyGeometry, record: int64,
      domain = rdSource): bool
      # true: the record's runs ascend by channel and share none, so the
      # runs give every channel once; false: use `relations` where a shared
      # channel matters (the earlier run has it)
  Walk: for rec in g.records(domain): for run in g.relationRuns(rec, domain).
  processSEG's current source compiles against this branch unchanged.

PUBLIC API CHANGES

Breaking:
- JoinGroup members (above).
- currentTraceField/currentRecordField/currentFileField: `ed: HeaderEditor`
  instead of `var HeaderEditor` (callers compile unchanged).
Behaviour:
- applyGeometry(keys)/applySurvey: "changed" means a value differs; a join
  that changes no trace leaves keysRevision and TraceIndex.joined alone.
- joinStations: jmConflict for a contradicted station part.
- GeometryChanges counts and "rounded" warnings.
- copyWithGeometry/convertWithGeometry: no temporary journal file.
- readSps: undeclared revision is sevWarning (was info); structural
  detection; SpsFile.revision defaults to sps21; diagnostics capped at 5
  per code and field plus a count; BOM skipped; inf/nan/1e999 are not
  numbers; " 91530" is a time.
- readP1: auAuto decided once per file (P1File.options.angleUnit holds the
  result, info when grads); a read without data records warns; logical
  file format by majority of its first data records.
- addSps: duplicate stations logged; its notes capped per kind and file;
  addSps(g, path) reads without record text.
- HeaderEditor: at most 1000 refusals logged; in a batch setRecordField
  refreshes the index's record headers and keys at endBatch, not per call.
New: changedGroups, RelationRun, relationRuns, station, relationsDisjoint,
SpsOptions.keepRaw.

FOR THE LEAD AT MERGE

- New source file: src/parseSEG/survey/linespans.nim (internal, imported
  by textrec.nim and sps.nim; the umbrella does not export it).
- Files outside my list that I had to touch: tests/ttracerows.nim (two
  lines: jgCoordinates -> jgReceiverXy / jgSourceXy), tests/tindexcache.nim
  (`save_refusals`: saveIndex after an empty join now succeeds; a join that
  changes a trace is refused).
- model/traceindex.nim hunks: keepJoined (rewritten in place),
  applyGeometry(ix) (changedGroups, joined set only when result > 0).
  model/keys.nim: applyGeometry(k, g) only. model/indextypes.nim: JoinGroup,
  joinedTraces doc, static assert, changedGroups, markJoined doc.
- survey/writeback.nim imports private/segykeys (RawHeaders, segyKeys, as
  traceindex.nim does); if C4 renames them, adapt currentSegyKeys.
- After C4: update the tcli and tgeomwrite expectations listed under
  Section 0.
- tests/theadernif.nim `failed_journal_syncs_are_reported` (not mine) fails
  under any long temp directory: it allows the journal 100 bytes, less than
  its session line when TMPDIR is the scratchpad path. Same failure on the
  base tree; it passes on this branch with a short temp path. tgeomwrite's
  variant of the test uses the session size + 20.
- C2 documentation agreement (editing.md, edit.nim's openEditor(f) doc,
  tracerows.nim, viewer.md) is with the index engineer; untouched.

SUITE
Debug, every tests/t*.nim on a clean export of 899d4f3 (the commit before
the comment-only 0047159): 52 of 53 pass. The one failure is theadernif,
from the temp path length as described above; it passes on 0047159 with a
short temp path. On 0047159: tedit, teditkeys, tsurvey, tgeomwrite,
ttracerows and tindexcache pass in debug, -d:release and -d:danger; tdocs,
tcli and tconvert pass in debug; `nim doc --project` with RST warnings as
errors is clean; tsurvey passes under AddressSanitizer (at 899d4f3).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Every geometry name of every trace went through the layout's entry list
(resolve the header, dispatch on the entry type, look up the scalar): 355
ns of key reading per trace, for values that in a file with the standard
layout sit at fixed bytes. When the plan is made, the layout is now
compared with the standard one: each geometry name with its one plain
standard-header entry at its standard place, the three scalars at theirs,
the Rev 1 names (bytes 181-202) all there or all absent (Rev 0), and at
most Extension 1 entries beside them. Such a plan reads a trace that has
no Extension 1 with direct loads (`valuesDirect`), each scalar once; a
trace with Extension 1, and any plan with a Layout stanza or vendor
layout that moves a name, goes through the entries as before. The rest of
the key (trace code, delay, date, interval) is the code it was.
`geometry` (`geometryValues`) takes the same path.

-d:danger v3, y-ieee-10m-s500000.sgy (500 000 traces), two runs each:
  ensureKeys:                    314, 311 -> 123, 121 ns/trace
  walk + keys (vfFull):          398, 402 -> 257, 259 ns/trace
  traceKeysInto, 1000 at a time: 332, 333 -> 124, 124 ns/trace
  traceKeysInto, one trace (y-ieee-s12000): 224 -> 82 ns/call
(what is left in ensureKeys is mostly the first touch of the mapped
header pages.)

Identical keys: tsegykeysraw `direct_loads_equal_the_layout_walk` compares
the direct loads with the entry walk (`withoutDirect`) bit for bit on
random headers in every byte order and revision and on every trace of the
demo, sample and bench SEG-Y files (seven bench files, 610 000 traces, all
read directly; Sweep0 directly; Inglewood and the demo survey have a
stanza and are not), and checks that a moved name, a moved or reused
scalar, an if-non-zero or a proprietary entry turn the direct loads off.
The reference test (`random_headers_through_random_layouts`) covers the
standard layouts against the independent reference as before.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… then reads the keys from the map

Asking for keys made `openSeismic` walk every trace of a fixed-length
SEG-Y file with the reader (412 ns per trace), although the default open
indexes such a file from its size and `ensureKeys` then makes the same
keys from the mapped headers. `keys = true` (and `buildIndex(keys = true,
verify = vfSampled)`) now does just that: the sampled index, then one pass
over the mapped trace headers with the keys' plan.

What is the same: the entries and the keys, bit for bit (tsampledindex
compares the whole index, keys included, with the `vfFull` walk for every
fixture: sample and demo files, Rev 0/1/2.0/2.1, little-endian and
pair-swapped, IBM and int16). Progress is reported during the key pass and
a cancel raises `SegCancelledError`.

What differs, exactly as for a sampled open without keys: the log has the
`dcIndexSampled` info, and the walk's per-trace warnings for headers that
were not sampled (a sample count or interval that disagrees with the
binary header) are not made; `verify = vfFull` walks and reports them. A
cache saved from such an open is a sampled one (not used with `vfFull`).

-d:danger v3, openSeismic(keys = true), best of 6, two runs:
  y-ieee-10m-s500000.sgy (500 000 traces): 206, 206 ms -> 65, 64 ms
    (412 -> 128 ns/trace; vfFull: 208, 204 -> 130, 128 ms)
  y-ieee-1m-s50000.sgy: 21.9 -> 7.7 ms; y-ibm-s12000.sgy: 7.0 -> 3.3 ms

Tests changed because they asserted the old behaviour: tsampledindex
`keys_vfFull_and_buildIndex_walk` (keys = true was "not sampled"; now
`vfFull_and_buildIndex_walk`, with progress, cancel and an unsampled
mismatch) and tindexcache (a cache saved with keys was compared with the
`vfFull` walk's log; it is the default open's).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…d, not hashed

`classify` built two hash sets of 40-byte station ids for every gather. A
post-stack volume has one trace per gather: 500 000 gathers took 233 ms,
467 ns per trace. A gather of one trace now takes its kind and counts
straight from the trace, and the counter of distinct sources and receivers
keeps the first eight stations in an array and compares them, making the
hash set only for a ninth (a shot gather has one source, so it never makes
the source set). The scan for a seismic trace stops at the first.

bench_gathers, y-ieee-10m-s500000.sgy, -d:danger v3, two runs each:
  post-stack (one CDP per trace): 233, 235 ms -> 30.3, 30.0 ms
    (467 -> 60 ns/trace)
  shot gathers of 1000 traces (500 gathers): 29.0, 28.7 -> 23.5, 23.6 ms

Same kinds and counts: tgathers `classify_counts_as_the_reference_does`
compares with the two-set rule on 18 000 random ranges (single traces, few
and many distinct stations, unknown parts, signed zeros, auxiliary
traces).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
- docs/benchmarks.md gets a section with the before/after numbers of this
  pass (seek and close latency, IBM float decode in v3 and baseline
  builds, keys at open, on demand and per call, gathers), and says that the
  older tables show 144 to 147 B per trace for keys because `TraceKeys` was
  144 bytes then: it is 184, and the index takes 24.2 B per trace without
  keys and 208.7 B with them (measured: y-ieee-10m, 500 000 traces).
- docs/guide/viewer.md: `keys = true` no longer makes a fixed-length SEG-Y
  file walk; the cost of keys at open; IBM float decode speed by build;
  `quickFirst` latencies and what a short chunk's `samples` is; a seek
  does not wait for the chunk being decoded.
- docs/api.md: the `openSeismic`, `saveIndex` and `buildIndex` rows.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…r a Rev 2 interval

The option was declared and documented on the readers branch; the open
itself lives in traceindex. A cache holding the index of another standard
than the one named is not used.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A SEG-Y file converted from SEG-D keeps its stations in the fields its
Layout stanza names, and the keys now read them. A survey join therefore
matches such a file by stations instead of by record number, and never
replaces a station the header already holds. The CLI test and the
geometry write test expected the old record-number match; the second now
also covers the case the old one meant to test, a header without its
receiver station, which gets the one the X record gives.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@xrfez
xrfez merged commit bc93e7b into main Nov 13, 2026
3 checks passed
@xrfez
xrfez deleted the final-pass branch November 13, 2026 03:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant