edfcore 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -256,8 +256,11 @@ export async function buildTimeline(
256
256
 
257
257
  const probeOptions = (
258
258
  readOptions: ReadOptions | undefined,
259
- ): DecodeAnnotationsOptions & ReadOptions =>
260
- readOptions === undefined ? { strict } : { ...readOptions, strict };
259
+ originTicks?: bigint,
260
+ ): DecodeAnnotationsOptions & ReadOptions => {
261
+ const base = readOptions === undefined ? { strict } : { ...readOptions, strict };
262
+ return originTicks === undefined ? base : { ...base, originTicks };
263
+ };
261
264
 
262
265
  const memo = new Map<number, bigint>();
263
266
  const probes: RecordOnsetProbe[] = [];
@@ -271,7 +274,11 @@ export async function buildTimeline(
271
274
  probes.push({ recordIndex, onsetTicks: ticks });
272
275
  continue;
273
276
  }
274
- const probe = await probeOnset(source, header, recordIndex, probeOptions(options));
277
+ // Record 0 is probed first and defines the origin, so by the time the last record is probed
278
+ // its true onset is in `memo`. Without handing it over, a last record with no timekeeping TAL
279
+ // derived its onset from zero and appeared to sit `startOffset` seconds early — enough to
280
+ // fake a discontinuity in a conforming file and make readWindow refuse every window in it.
281
+ const probe = await probeOnset(source, header, recordIndex, probeOptions(options, memo.get(0)));
275
282
  memo.set(recordIndex, probe.ticks);
276
283
  probeDiagnostics.push(...probe.diagnostics);
277
284
  probes.push({ recordIndex, onsetTicks: probe.ticks });
@@ -287,7 +294,12 @@ export async function buildTimeline(
287
294
  memo.set(recordIndex, ticks);
288
295
  return ticks;
289
296
  }
290
- const probe = await probeOnset(source, header, recordIndex, probeOptions(readOptions));
297
+ const probe = await probeOnset(
298
+ source,
299
+ header,
300
+ recordIndex,
301
+ probeOptions(readOptions, timeline.startOffsetTicks),
302
+ );
291
303
  memo.set(recordIndex, probe.ticks);
292
304
  return probe.ticks;
293
305
  }
@@ -339,7 +351,14 @@ async function scanOnsets(
339
351
  count: Math.min(chunkRecords, recordCount - scanned),
340
352
  };
341
353
  const bytes = await readRecordBytes(source, header, records, options);
342
- const decoded = decodeAnnotations(header, bytes, records, options);
354
+ // The origin comes from the recording, not from whatever this chunk happens to contain.
355
+ // Chunking is a memory-bounding detail and must not change the answer: without this, a chunk
356
+ // holding no observed onset derived from zero, so the onsets, the segments, the gaps and
357
+ // even a fatal TIMELINE_NOT_MONOTONIC varied with maxMaterializeBytes.
358
+ const decoded = decodeAnnotations(header, bytes, records, {
359
+ ...options,
360
+ originTicks: recording.timeline.startOffsetTicks,
361
+ });
343
362
  onsets.set(decoded.recordOnsetTicks, scanned);
344
363
  scanned += records.count;
345
364
  options?.onProgress?.(scanned, recordCount);
package/src/recording.ts CHANGED
@@ -135,7 +135,14 @@ async function readChunk(
135
135
  // Never strict, and not because the flag was lost: a read that threw on an impolite TAL in a
136
136
  // record the caller asked for would return no samples at all over a defect in a different
137
137
  // channel. The defects land on `chunk.diagnostics`, next to the data they were found beside.
138
- const annotations = decodeAnnotations(header, bytes, records);
138
+ // `originTicks` is the recording's own start, not this range's. It is already known here — the
139
+ // next few lines rebase against it — and without passing it down, a first record whose
140
+ // timekeeping TAL is missing derived its onset from zero. The same record then reported one
141
+ // start time when read alone and another when read beside a neighbour that did carry a TAL,
142
+ // and that start is the grid origin trimToWindow measures from.
143
+ const annotations = decodeAnnotations(header, bytes, records, {
144
+ originTicks: timeline.startOffsetTicks,
145
+ });
139
146
  const onsets = annotations.recordOnsetTicks;
140
147
  assertMonotonicOnsetArray(onsets, records.start);
141
148
 
@@ -445,9 +445,15 @@ export function decodeAnnotations(
445
445
 
446
446
  // Record 0's onset, observed when it was decoded and derived from the first record that was
447
447
  // otherwise. For a continuous file the derivation is exact; see the rebasing note below.
448
+ //
449
+ // With no observed onset anywhere in this range there is nothing local to derive from, and the
450
+ // origin has to come from the caller. Falling back to zero instead made the result depend on
451
+ // the range: the same record got one onset when read alone and another when read alongside a
452
+ // neighbour that did carry a timekeeping TAL, which in turn made chunk boundaries, segment
453
+ // boundaries and even a fatal TIMELINE_NOT_MONOTONIC a function of the scan chunk size.
448
454
  const baseTicks =
449
455
  firstObserved === undefined
450
- ? 0n
456
+ ? (options?.originTicks ?? 0n)
451
457
  : firstObserved.ticks - BigInt(firstObserved.recordIndex) * durationTicks;
452
458
 
453
459
  const recordOnsetTicks = new BigInt64Array(records.count);
package/src/types.ts CHANGED
@@ -449,6 +449,21 @@ export interface WindowSelection {
449
449
  export interface DecodeAnnotationsOptions extends ParseOptions {
450
450
  /** Defaults to every annotation signal. Only the first carries timekeeping. */
451
451
  readonly signalIndices?: readonly number[];
452
+ /**
453
+ * The recording's own time origin, for deriving the onset of a record whose timekeeping TAL
454
+ * is missing.
455
+ *
456
+ * A missing timekeeping TAL is a warning, not a fatal error, and such a record is documented
457
+ * to get `start + recordIndex * recordDuration`. That `start` can only be known by a caller
458
+ * who has already seen record 0. Without it the derivation falls back to an origin of zero,
459
+ * which is right only for a file whose first record starts at zero — and makes the answer
460
+ * depend on which records happened to share the call, because a range containing no observed
461
+ * onset at all gets a different origin from one that contains one.
462
+ *
463
+ * Pass `timeline.startOffsetTicks` whenever it is known. Decoding a range in isolation, as
464
+ * `openEdf` does before any timeline exists, correctly omits it.
465
+ */
466
+ readonly originTicks?: bigint;
452
467
  }
453
468
 
454
469
  /** Header-only triage. Reads at most 128 KiB and never throws on malformed content. */
package/src/validate.ts CHANGED
@@ -19,9 +19,10 @@
19
19
  */
20
20
 
21
21
  import { trimEdfField } from './bytes/latin1.js';
22
- import { EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
22
+ import { DEFAULT_MAX_MATERIALIZE_BYTES, EDF_RECOMMENDED_MAX_RECORD_BYTES } from './constants.js';
23
23
  import { decodeDigitalCounted } from './decode/digital.js';
24
24
  import { createDiagnostic } from './diagnostics/collector.js';
25
+ import { EdfBudgetError } from './errors.js';
25
26
  import { calendarDatesEqual, formatCalendarDate, isValidCalendarDate } from './header/dates.js';
26
27
  import { signalFieldOffset } from './header/signals.js';
27
28
  import { readRecordBytes } from './io/read.js';
@@ -55,6 +56,9 @@ export type { ObservedSignalStats, ValidateOptions, ValidationReport } from './t
55
56
  const LABEL_SPEC = 'EDF+ additional specification 9 (standard texts and labels)';
56
57
  const TIMEKEEPING_SPEC = 'EDF+ specification 2.2.1 (time keeping of data records)';
57
58
 
59
+ /** The sample-scan scratch buffer is an `Int32Array`, so four bytes per sample. */
60
+ const BYTES_PER_SCRATCH_SAMPLE = 4;
61
+
58
62
  /**
59
63
  * The signal types EDF+ additional specification 9 names.
60
64
  *
@@ -479,8 +483,17 @@ async function traverse(
479
483
  : [];
480
484
 
481
485
  const chunkRecords = scanChunkRecords(header, options?.maxMaterializeBytes);
482
- // One scratch array for every signal and every chunk. Bounded by chunkRecords *
483
- // max(samplesPerRecord) * 4 bytes, which is at most twice the chunk's own byte size.
486
+ /*
487
+ * One scratch array for every signal and every chunk.
488
+ *
489
+ * This is normally bounded by the chunk's own byte size, because `scanChunkRecords` fits the
490
+ * chunk into a scan block. That bound fails for a single record larger than the whole block:
491
+ * the record count floors to 1 and the scratch size becomes `samplesPerRecord` unclamped, up
492
+ * to the 99,999,999 an 8-byte EDF field can hold — a 400 MB allocation reachable from one
493
+ * corrupted digit in a 512-byte file, made before any read, so no downstream check can catch
494
+ * it. Validation exists to be pointed at untrusted files, so it is exactly the caller who
495
+ * needs the budget honoured rather than a documented cap that only the read path respects.
496
+ */
484
497
  let scratch: Int32Array | undefined;
485
498
  if (scanSamples) {
486
499
  let maxSamplesPerRecord = 0;
@@ -490,6 +503,18 @@ async function traverse(
490
503
  maxSamplesPerRecord = Math.max(maxSamplesPerRecord, signal.samplesPerRecord);
491
504
  }
492
505
  }
506
+ const scratchBytes = chunkRecords * maxSamplesPerRecord * BYTES_PER_SCRATCH_SAMPLE;
507
+ const budgetBytes = options?.maxMaterializeBytes ?? DEFAULT_MAX_MATERIALIZE_BYTES;
508
+ if (scratchBytes > budgetBytes) {
509
+ throw new EdfBudgetError(
510
+ `Scanning samples needs a ${scratchBytes}-byte scratch buffer for ${chunkRecords} ` +
511
+ `record(s) of up to ${maxSamplesPerRecord} samples, above the ${budgetBytes}-byte ` +
512
+ 'maxMaterializeBytes budget, so the scan was refused before anything was allocated. ' +
513
+ 'Next: raise options.maxMaterializeBytes, or drop scanSamples and validate the ' +
514
+ 'header alone.',
515
+ { requiredBytes: scratchBytes, budgetBytes },
516
+ );
517
+ }
493
518
  scratch = new Int32Array(chunkRecords * maxSamplesPerRecord);
494
519
  }
495
520
 
@@ -504,7 +529,10 @@ async function traverse(
504
529
  bytesRead += bytes.length;
505
530
 
506
531
  // Never strict: a sweep whose job is to list every defect must not stop at the first one.
507
- const decoded = decodeAnnotations(header, bytes, records);
532
+ // The origin is the recording's, so the sweep's verdict does not depend on its chunk size.
533
+ const decoded = decodeAnnotations(header, bytes, records, {
534
+ originTicks: recording.timeline.startOffsetTicks,
535
+ });
508
536
  onsets.set(decoded.recordOnsetTicks, records.start);
509
537
  diagnostics.push(...decoded.diagnostics);
510
538