Repository navigation
Final pass: correctness and speed across readers, writers, index, editing and join (0.10.0) - #3
Merged
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Or in your
.nimblefile:requires "https://github.com/xrfez/parseSEG >= 0.10.0".New
OpenOptions.kindforces the standardopenSeismicreads a file as. An index cache written for another standard is refused and rebuilt.Layoutstanza are read intoTraceKeys. A SEG-Y file converted from SEG-D keeps its stations, so a survey join matches it by stations.relationRunsiterates a record's relation as runs of channels (RelationRun), andrelationsDisjointsays whether records share channels: lookups no longer scan per channel.changedGroups: a join marks only the values it changed (JoinGroupis finer, see below), and the rest keep following header edits.Seg2Writer.abandoncloses a writer whose output is discarded.SpsOptions.keepRawkeeps the raw record text only when asked.looksLikeBinaryHeaderandusableExtIntervalexpose the rules the readers use for detection and for a Rev 2 extended sample interval.nim testV3runs 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.DiskSinkwrites a MiB at a time. Gather classification compares a few stations instead of hashing them. Geometry copies are written without a journal.Fixed
convertSegyrewrites 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.ACQUISITION_DATE/ACQUISITION_TIMEgive no time instead of a crash. Arguments near the ends ofintraiseValueError. An invalid record length is NaN.descaleFactorandstartTimeMsare per scan type and channel set.0xFFFFFFFE, and0xFFFEFFFFis half a gap.SegFormatErrorinstead of ending the process. Survey readers cap their diagnostics and give a reason for an empty P1 read.Breaking changes
JoinGroupis finer:jgSourceXy,jgReceiverXy,jgSourceZ,jgReceiverZ,jgDerived,jgSourceStation,jgReceiverStation,jgComponent.writeTracetakes its trace count ascount(default -1: all).SeismicWriter.closeraisesIOErrorwhen the final rename fails.encodeSamplesraises for NaN, infinity or out-of-range values in the IBM and SEG-D exponent formats.generalHeaderUtcreads a two-digit year as 1950-2049 by default.fieldNamesreturnslent seq[string].Verification
nim teston the merged tree with the sample files: 58 of 58 test files pass.🤖 Generated with Claude Code