/** * Decimal text to exact 100 ns ticks. * * Layer 1, not 3 — this file sits in `tal/` but does not belong to it. It imports `constants.ts` * and nothing else, and `header/parse.ts` and `header/lookup.ts` at layer 2 both call it, so * labelling it 3 with the rest of the directory made the only two upward runtime imports in the * package (corrected in 0.4.256). A module's layer is its dependencies, not its folder. * * Sole owner of the string -> tick conversion, which is the whole reason event times in edfcore * compare exactly. `parseFloat`, `Number(text)` and float arithmetic appear nowhere on that path: * an onset written `+0.1` and one written `+0.3` are integers here, so equality, ordering and * subtraction are decided by the digits on disk rather than by float64 rounding. */ import { TICKS_PER_SECOND } from '../constants.js'; /** * One parsed onset or duration field. * * `ticks` is the authoritative value; `seconds` and any float derived from it are conveniences. */ export interface TickParse { /** * The text matched the EDF+ grammar for its field. When false, `ticks` is 0n except in the * one documented case in `parseSignedTicks` (a valid magnitude with the sign missing). */ readonly ok: boolean; readonly ticks: bigint; /** The input verbatim, so a caller never has to reconstruct the digits it came from. */ readonly raw: string; /** Lossy by construction. See `ticksToSeconds`. */ readonly seconds: number; } const ASCII_ZERO = 0x30; const ASCII_NINE = 0x39; const ASCII_PLUS = 0x2b; const ASCII_MINUS = 0x2d; const ASCII_DOT = 0x2e; /** `TICKS_PER_SECOND` is 10^7, so exactly seven fractional decimal digits are representable. */ const TICK_FRACTION_DIGITS = 7; /** Exact: 10^7 is far below 2^53, so this conversion loses nothing. */ const TICKS_PER_SECOND_FLOAT = Number(TICKS_PER_SECOND); interface MagnitudeParse { readonly ok: boolean; /** Non-negative. The sign is applied by the caller, to the whole value. */ readonly ticks: bigint; } const MAGNITUDE_FAILED: MagnitudeParse = { ok: false, ticks: 0n }; function isAsciiDigit(code: number): boolean { return code >= ASCII_ZERO && code <= ASCII_NINE; } /** * `1*DIGIT [ "." 1*DIGIT ]` starting at `start`, accumulated digit by digit into a bigint. * * Fractional digits beyond the seventh are below tick resolution and are TRUNCATED, never * rounded: rounding would move an event to a time that is in no file, and for a stimulus marker * that is the difference between a pre- and a post-stimulus sample. The extra digits are still * required to be digits, so `+1.00000000x` is rejected rather than silently truncated. */ function parseMagnitudeTicks(text: string, start: number): MagnitudeParse { const end = text.length; let i = start; let whole = 0n; while (i < end) { const code = text.charCodeAt(i); if (!isAsciiDigit(code)) break; whole = whole * 10n + BigInt(code - ASCII_ZERO); i += 1; } if (i === start) return MAGNITUDE_FAILED; let ticks = whole * TICKS_PER_SECOND; if (i === end) return { ok: true, ticks }; // Anything other than a decimal point here is a space, a second sign, a stray structural // byte or trailing padding, and none of those are part of the grammar. if (text.charCodeAt(i) !== ASCII_DOT) return MAGNITUDE_FAILED; i += 1; let fractionDigits = 0; let scale = TICKS_PER_SECOND; while (i < end) { const code = text.charCodeAt(i); if (!isAsciiDigit(code)) return MAGNITUDE_FAILED; fractionDigits += 1; if (fractionDigits <= TICK_FRACTION_DIGITS) { scale = scale / 10n; ticks += BigInt(code - ASCII_ZERO) * scale; } i += 1; } if (fractionDigits === 0) return MAGNITUDE_FAILED; return { ok: true, ticks }; } /** * An EDF+ onset: `("+" / "-") 1*DIGIT [ "." 1*DIGIT ]`. * * The sign is mandatory, so an unsigned onset is a spec violation and `ok` is false — the caller * emits `TAL_MALFORMED`. The magnitude is parsed anyway and returned in `ticks`, so a lenient * caller can still use the value instead of discarding an otherwise readable annotation. That is * the only case where `ok: false` comes with a meaningful `ticks`. * * `-` applies to the WHOLE value, fraction included: `-0.5` is -5000000 ticks, not -0 plus * 5000000. Splitting the sign off the fraction flips pre-stimulus event times about zero. */ export function parseSignedTicks(text: string): TickParse { const first = text.length > 0 ? text.charCodeAt(0) : -1; const negative = first === ASCII_MINUS; const signed = negative || first === ASCII_PLUS; const magnitude = parseMagnitudeTicks(text, signed ? 1 : 0); const ticks = negative ? -magnitude.ticks : magnitude.ticks; return { ok: magnitude.ok && signed, ticks, raw: text, seconds: ticksToSeconds(ticks) }; } /** * An EDF+ duration: `1*DIGIT [ "." 1*DIGIT ]`, never signed. * * A leading `+` or `-` fails the grammar; it is not tolerated and not stripped, because a signed * duration means the writer's field layout is not the one we are reading. */ export function parseUnsignedTicks(text: string): TickParse { const magnitude = parseMagnitudeTicks(text, 0); return { ok: magnitude.ok, ticks: magnitude.ticks, raw: text, seconds: ticksToSeconds(magnitude.ticks), }; } /** * Ticks as float64 seconds, for ergonomics and display. * * Lossy by construction — most tick values are not representable in binary floating point, and * beyond 2^53 ticks (~28.5 years) even the integer part rounds. The exact value always stays * available as ticks, and that is what comparisons must use. * * Split into whole seconds plus remainder so that only the remainder is ever divided; bigint * `/` truncates toward zero and `%` keeps the dividend's sign, so both parts share a sign and * the sum is correct for negative onsets. */ export function ticksToSeconds(ticks: bigint): number { const wholeSeconds = ticks / TICKS_PER_SECOND; const remainder = ticks % TICKS_PER_SECOND; return Number(wholeSeconds) + Number(remainder) / TICKS_PER_SECOND_FLOAT; } /** * Integer division that rounds toward -Infinity and +Infinity respectively, `b` positive. * * Bigint `/` truncates toward zero, so both need a correction on the negative side, and the * negative side is reached by ordinary input: a window that starts before record 0 is how a * pre-stimulus epoch is spelled. They live here, beside the tick conversions, because every * caller is dividing a tick count by another tick count and three modules had grown their own * copies of the same four lines. */ export function floorDiv(a: bigint, b: bigint): bigint { const quotient = a / b; return a % b === 0n || a > 0n ? quotient : quotient - 1n; } /** * Division rounding toward positive infinity, the counterpart to `floorDiv` above. BigInt `/` * truncates toward zero, so neither direction is what the operator gives for a negative operand — * and a time before the recording's start is exactly where that matters. */ export function ceilDiv(a: bigint, b: bigint): bigint { const quotient = a / b; return a % b === 0n || a < 0n ? quotient : quotient + 1n; } const INT64_MIN: bigint = -(2n ** 63n); const INT64_MAX: bigint = 2n ** 63n - 1n; /** * Clamps a tick count to what a `BigInt64Array` element can hold. * * Assignment to a `BigInt64Array` wraps modulo 2^64 rather than throwing, and every onset array * in edfcore is one. Wrapping turns a monotonically increasing series into one that jumps * backwards, which downstream code reads as a genuine discontinuity: a file whose declared * record duration overflows the range then indexes as one segment per record, with negative * gaps between them and no diagnostic anywhere. * * Saturating keeps the array non-decreasing, so an absurd geometry stays visibly absurd instead * of becoming plausibly wrong. Reaching either bound needs a declared geometry that is already * impossible — over 29,000 years of records — but `recordDuration` is a free-form ASCII field * that accepts exponent notation, so three bytes are enough to ask for it. */ export function saturateToInt64(ticks: bigint): bigint { if (ticks > INT64_MAX) return INT64_MAX; if (ticks < INT64_MIN) return INT64_MIN; return ticks; } /** * Seconds to ticks, rounded to the NEAREST tick (ties away from zero is not required; ties go * toward +Infinity, as `Math.round` does). * * Only for user-supplied bounds such as a time window, never for a value read from disk: a * caller's `30.0` must resolve to 300000000 ticks and not to 299999999 because `30 * 1e7` landed * one ULP low. On-disk values reach ticks through the digit parsers above, which never round. * * Throws `RangeError` for a non-finite argument: there is no tick count for NaN or Infinity, and * inventing 0 would silently move a window to the file start. * * `name` is the caller's own word for the value, and it is required rather than defaulted. The * message used to end "check the window bound you passed in", which is right for five of the * fifteen call sites and wrong for the rest: nine of them convert an INSTANT — a cursor position, * a record onset, a sample start — and one converts `secondsPerBucket`, so a caller who passed a * `NaN` bucket width was sent to look at their window (fixed in 0.6.30). * * It is the pair `options.ts` documents from the other side: "One bad option, two different wrong * diagnoses. Resolving it in one place means the message names the argument that is actually * wrong." `resolveSignals` reached the same place by the opposite route and carries NO caller * prefix, "because a hard-coded name would be wrong for all but one of them" — which is the choice * available when the callers cannot say. These can. */ /** * A value that is not a number at all, said in a way that does not read as a valid one. * * The template below interpolated it bare, so the message named a rule the printed value satisfies. * A BigInt is the sharp case: this package hands out `onsetTicksFromFirstRecord` and every other * instant as ticks, and passing one where seconds belong earned "startSeconds must be a finite * number of seconds, but was 10000000" — which is a finite number of seconds (fixed in 0.6.92). */ function describeSeconds(seconds: unknown): string { if (typeof seconds === 'bigint') return `the BigInt ${seconds}n`; if (typeof seconds === 'string') return `the string ${JSON.stringify(seconds)}`; if (seconds === null) return 'null'; /* * A PENDING PROMISE, which "an object" is true of and says nothing about. * * `describeValue` names it everywhere else and gives the reason: a forgotten `await` is told * "nothing about the one keyword that fixes it". This module cannot reach that helper — it imports * `constants.ts` and nothing else, which is the property `AGENTS.md` cites as the point of the * layer rule — so it is said here in words, the way `summarizeDiagnostics` said its own in 0.6.254. * * It is reachable on every time-bounded call in the package. `startSeconds` and `durationSeconds` * come from a seek position, a playhead or a stored viewport, and `locate(seconds)` takes one * directly — so `readWindow(recording, { signalIndices, startSeconds: seekPosition(), ... })` with * an async `seekPosition` is one keyword short, and it was told its bound was "an object", which is * true of the window object a reader might have passed instead. * * A property read, never a call: nothing here awaits, settles or subscribes to anything. */ if (typeof (seconds as { then?: unknown } | undefined)?.then === 'function') { return 'a pending Promise'; } return typeof seconds === 'object' ? 'an object' : `a ${typeof seconds}`; } export function secondsToTicks(seconds: number, name: string): bigint { // Separated from the finiteness check below so that neither message can describe the other's // value: "not finite" is about a number, and `NaN`, `Infinity` and `undefined` all read correctly // as themselves. A string or a BigInt does not. if (typeof seconds !== 'number' && seconds !== undefined) { throw new RangeError( `${name} must be a number of seconds, and was given ${describeSeconds(seconds)}. Next: ` + 'convert it first — Number(text) for a string, and for a tick count the matching ' + '*Seconds field beside it, which every instant edfcore hands out carries.', ); } if (!Number.isFinite(seconds)) { /* * `undefined` gets its own advice, because none of the advice below can apply to it. * * The subject of the sentence is unchanged — 0.6.92 decided that `NaN`, `Infinity` and * `undefined` "all read correctly as themselves" and that is still true. The `Next:` clause is * the part that was wrong: it sends the reader to audit the expression that produced the * value, and names `Number()` on an absent key and a division by zero, both of which produce a * NUMBER. Nothing computes `undefined`; a field is simply not there. * * 0.6.87 made this exact argument — "every clause of that is about a value the caller never * computed: it sends them to audit a config key, when what they wrote is an argument of the * wrong shape" — and closed one route to it, `{ records }` handed to a call that takes a * window. Every other route to an absent bound still got the config-key advice: a selection * built one field at a time, a spread that dropped a key, a window object whose two fields are * spelled something else. */ throw new RangeError( `${name} must be a finite number of seconds, but was ${seconds}. Next: ` + (seconds === undefined ? 'pass it — nothing computes undefined, so this is a field that is not there: a ' + 'selection built one field at a time, a spread that dropped a key, or an object ' + 'whose bounds are spelled something else.' : 'check the expression that produced it — Number() on an absent environment ' + 'variable, query parameter or config key yields NaN, and a division by zero yields ' + 'Infinity.'), ); } /* * The PRODUCT, which the finiteness check above cannot see. * * A tick is 100 ns, so this multiplies by ten million — and above about 1.8e301 seconds the * result is `Infinity` for an argument that was finite. `Math.round(Infinity)` is `Infinity`, and * `BigInt(Infinity)` is V8's "The number Infinity cannot be converted to a BigInt because it is * not an integer": no `Next:` clause, no mention of edfcore, naming an internal conversion and * saying "is not an integer" about a value the caller never produced. It escaped on eleven public * calls — `readWindow` on either bound, `readEnvelope`, `readEnvelopeAtResolution`, * `streamRecords`, `resolveTimeWindow`, `trimToWindow`, `segmentAt`, `filterAnnotationsByTime`, * `annotationsAt` and `gridSampleIndexAt`. * * `nan-seconds-reaches.test.ts` is the sweep this belongs to and states the premise: "The * guarantee is that a `NaN` cannot get PAST it." A finite bound that overflows inside the * conversion is the fourth value, and the one the guard above lets through. * * `Number.MAX_VALUE` is how it arrives, and the check above is what sends a caller to it. Asking * for "as much as there is" is what a sentinel is for, and this package accepts one for a row * limit — so `Infinity` is the first thing tried on a time bound, it is refused here with advice * to go and audit the expression that produced it, and `Number.MAX_VALUE` is the second. That one * broke the contract instead of refusing. * * No caller-specific advice: the docblock above records what that costs — the old wording was * "right for five of the fifteen call sites and wrong for the rest" — so this names the mistake * itself, which is the same for a window, an instant and a bucket width (0.6.287). */ const ticks = Math.round(seconds * TICKS_PER_SECOND_FLOAT); if (!Number.isFinite(ticks)) { throw new RangeError( `${name} is ${seconds} seconds, which is finite but has no tick count — a tick is 100 ns, ` + 'so the conversion overflows float64 above about 1.8e301 seconds. Next: pass the value ' + 'you mean rather than a sentinel for as much as possible — a bound past the end of the ' + 'recording is clamped to it, so none of them has to be large.', ); } return BigInt(ticks); }