Skip to content

Commit 7879783

Browse files
Copilotmrjf
andauthored
fix: resolve 12 CI test failures in date_range, to_timedelta, interval, explode, and sample
Agent-Logs-Url: https://github.com/githubnext/tsessebe/sessions/29d9c670-7033-40c6-baa5-3f651bf0be32 Co-authored-by: mrjf <180956+mrjf@users.noreply.github.com>
1 parent 3fe55ef commit 7879783

8 files changed

Lines changed: 103 additions & 27 deletions

File tree

src/stats/date_range.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,18 @@ const UNIT_NORM: Readonly<Record<string, string>> = {
119119
/**
120120
* Map a raw unit token (from the regex match) to its canonical form.
121121
* Returns the uppercased input unchanged if no mapping exists.
122+
*
123+
* NOTE: "ms" (lowercase) means milliseconds; "MS" (uppercase) means month-start.
124+
* Check case-sensitive lowercase tokens BEFORE uppercasing.
122125
*/
123126
function normaliseUnit(raw: string): string {
127+
// Case-sensitive lowercase tokens — millisecond aliases must come first
128+
// so they are not confused with "MS" (month-start) after uppercasing.
129+
if (raw === "ms" || raw === "L") return "ms";
130+
if (raw === "us") return "us";
131+
if (raw === "ns") return "ns";
124132
const u = raw.toUpperCase();
125-
// "MS" as a unit token means month-start, not milliseconds
133+
// Tokens that are passed through unchanged (already canonical)
126134
if (u === "MS" || u === "QS" || u === "D" || u === "B") {
127135
return u;
128136
}
@@ -427,10 +435,58 @@ function snapToAnchor(d: Date, pf: ParsedFreq): Date {
427435
return d;
428436
}
429437

438+
/**
439+
* For calendar boundary frequencies (ME, QE, YE), if the start date does not
440+
* fall exactly on a boundary, snap forward to the first boundary on or after `d`.
441+
*
442+
* For boundary-start frequencies (MS, QS, YS), a start that already lies on a
443+
* boundary is included as-is; if it is not on a boundary we snap to the next one.
444+
*/
445+
function snapToCalendarBoundary(d: Date, unit: string): Date {
446+
const y = d.getUTCFullYear();
447+
const m = d.getUTCMonth();
448+
const day = d.getUTCDate();
449+
switch (unit) {
450+
case "MS":
451+
// If not already month-start, advance to first day of next month.
452+
if (day === 1) return d;
453+
return new Date(Date.UTC(y, m + 1, 1));
454+
case "ME": {
455+
// If not already month-end, snap to end of the current month.
456+
const lastDay = daysInMonth(y, m);
457+
if (day === lastDay) return d;
458+
return new Date(Date.UTC(y, m, lastDay));
459+
}
460+
case "QS": {
461+
// Quarter-starts are Jan/Apr/Jul/Oct 1.
462+
const isQS = (m === 0 || m === 3 || m === 6 || m === 9) && day === 1;
463+
if (isQS) return d;
464+
return nextQStart(d);
465+
}
466+
case "QE": {
467+
// Quarter-ends are Mar 31, Jun 30, Sep 30, Dec 31.
468+
const isQE = (m === 2 || m === 5 || m === 8 || m === 11) && day === daysInMonth(y, m);
469+
if (isQE) return d;
470+
return nextQEnd(d);
471+
}
472+
case "YS":
473+
// Year-start is Jan 1.
474+
if (m === 0 && day === 1) return d;
475+
return new Date(Date.UTC(y + 1, 0, 1));
476+
case "YE":
477+
// Year-end is Dec 31.
478+
if (m === 11 && day === 31) return d;
479+
return new Date(Date.UTC(y, 11, 31));
480+
default:
481+
return d;
482+
}
483+
}
484+
430485
/** Generate `count` dates starting from `start`, advancing by `pf` each step. */
431486
function genFromStart(start: Date, count: number, pf: ParsedFreq): Date[] {
432487
const out: Date[] = [];
433488
let cur = snapToAnchor(start, pf);
489+
cur = snapToCalendarBoundary(cur, pf.unit);
434490
for (let i = 0; i < count; i++) {
435491
out.push(cur);
436492
cur = advanceDate(cur, pf);

src/stats/explode.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export function explodeDataFrame(
175175
// Validate column names
176176
for (const c of colNames) {
177177
if (!df.has(c)) {
178-
throw new Error(`Column "${c}" not found in DataFrame`);
178+
throw new Error(`Column '${c}' not found`);
179179
}
180180
}
181181

@@ -219,23 +219,30 @@ export function explodeDataFrame(
219219
const explodedCols = new Map<string, Scalar[]>();
220220
explodedCols.set(firstCol, firstOut);
221221

222+
// Compute per-row output count from the primary column explosion.
223+
const rowCounts: number[] = new Array<number>(nRows).fill(0);
224+
for (const p of firstPos) {
225+
rowCounts[p] = (rowCounts[p] ?? 0) + 1;
226+
}
227+
222228
for (let ci = 1; ci < colNames.length; ci++) {
223229
const cname = colNames[ci] as string;
224230
const wideColVals: readonly unknown[] = df.col(cname).values;
225231
const out: Scalar[] = [];
226232

227233
for (let row = 0; row < nRows; row++) {
228234
const v = wideColVals[row];
235+
const expectedCount = rowCounts[row] ?? 1;
229236
if (isListLike(v)) {
230-
if (v.length === 0) {
231-
out.push(null);
232-
} else {
233-
for (const item of v) {
234-
out.push(item as Scalar);
235-
}
237+
// Push items, padding with null if the list is shorter than expected.
238+
for (let k = 0; k < expectedCount; k++) {
239+
out.push(k < v.length ? (v[k] as Scalar) : null);
236240
}
237241
} else {
238-
out.push(v as Scalar);
242+
// Scalar: repeat (or pad) to fill the expected slot count.
243+
for (let k = 0; k < expectedCount; k++) {
244+
out.push(v as Scalar);
245+
}
239246
}
240247
}
241248
explodedCols.set(cname, out);

src/stats/sample.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,18 @@ class Rng {
102102
private _state: number;
103103

104104
constructor(seed: number) {
105-
// Ensure non-zero starting state.
106-
this._state = seed >>> 0 || 0xdeadbeef;
105+
// Mix the seed so that small sequential seeds (0, 1, 2, …) produce
106+
// well-distributed starting states. Without mixing, XOR-shift produces
107+
// near-zero outputs for seeds like 1, 2, 3 because the internal state
108+
// stays small for the first few steps.
109+
let s = seed >>> 0 || 0xdeadbeef;
110+
// Wang hash to spread bits before the first step.
111+
s = ((s ^ 61) ^ (s >>> 16)) >>> 0;
112+
s = (s + (s << 3)) >>> 0;
113+
s = (s ^ (s >>> 4)) >>> 0;
114+
s = (Math.imul(s, 0x27d4eb2d)) >>> 0;
115+
s = (s ^ (s >>> 15)) >>> 0;
116+
this._state = s || 0xdeadbeef;
107117
}
108118

109119
/** Returns a float in [0, 1). */

src/stats/to_timedelta.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const RE_ISO =
3030

3131
/** Human unit tokens for scanAll: "1h", "30 minutes", "2.5s" etc. */
3232
const RE_HUMAN_UNIT =
33-
/(\d+(?:\.\d+)?)\s*(weeks?|w|days?|d|hours?|h|minutes?|mins?|m|seconds?|secs?|s|milliseconds?|millis?|ms|microseconds?|micros?|us|nanoseconds?|nanos?|ns)/gi;
33+
/(\d+(?:\.\d+)?)\s*(weeks?|w|days?|d|hours?|h|milliseconds?|millis?|ms|minutes?|mins?|m|seconds?|secs?|s|microseconds?|micros?|us|nanoseconds?|nanos?|ns)/gi;
3434

3535
/** Pure integer string (no decimal). */
3636
const RE_INT = /^-?\d+$/;

tests/core/sample.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("sampleSeries", () => {
3131
test("replace=false: no repeated items (small pool)", () => {
3232
const s = new Series({ data: [10, 20, 30] });
3333
const r = sampleSeries(s, { n: 3, replace: false, randomState: 42 });
34-
const vals = r.values as number[];
34+
const vals = [...r.values] as number[];
3535
expect(new Set(vals).size).toBe(3);
3636
expect(vals.sort((a, b) => a - b)).toEqual([10, 20, 30]);
3737
});

tests/stats/date_range.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ describe("dateRange — QS", () => {
326326
const r = dateRange({ start: "2024-01-01", periods: 4, freq: "QS" });
327327
expect(r).toHaveLength(4);
328328
const months = r.map((d) => d.getUTCMonth() + 1);
329-
expect(months).toStrictEqual([4, 7, 10, 1]); // Apr Jul Oct Jan
329+
expect(months).toStrictEqual([1, 4, 7, 10]); // Jan Apr Jul Oct
330330
});
331331

332332
it("QE: generates 4 quarter-ends", () => {
@@ -345,7 +345,7 @@ describe("dateRange — YS/YE", () => {
345345
const r = dateRange({ start: "2024-01-01", periods: 3, freq: "YS" });
346346
expect(r).toHaveLength(3);
347347
const years = r.map((d) => d.getUTCFullYear());
348-
expect(years).toStrictEqual([2025, 2026, 2027]);
348+
expect(years).toStrictEqual([2024, 2025, 2026]);
349349
});
350350

351351
it("YE generates Dec 31 for each year", () => {

tests/stats/explode.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ describe("explodeDataFrame", () => {
211211

212212
test("throws on unknown column", () => {
213213
const df = DataFrame.fromColumns({ a: [[1, 2]] as unknown as Scalar[] });
214-
expect(() => explodeDataFrame(df, "z")).toThrow(/Column "z" not found/);
214+
expect(() => explodeDataFrame(df, "z")).toThrow(/Column 'z' not found/);
215215
});
216216

217217
test("empty DataFrame returns empty DataFrame", () => {

tests/stats/interval.test.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,8 @@ describe("Interval", () => {
142142
test("touching endpoints — one open side", () => {
143143
const a = new Interval(0, 2, "right");
144144
const b = new Interval(2, 4, "left");
145-
expect(a.overlaps(b)).toBe(false); // a's right is closed, b's left is closed — but they touch at 2
146-
// Actually both touch: a closes at right (2], b opens at left [2 — same point
147-
// corrected: both include 2 → they do overlap
145+
// a = (0, 2] closes on 2; b = [2, 4) opens on 2 — both include 2, so they overlap.
146+
expect(a.overlaps(b)).toBe(true);
148147
});
149148

150149
test("completely disjoint", () => {
@@ -421,14 +420,18 @@ describe("intervalRange", () => {
421420
describe("Interval properties (fast-check)", () => {
422421
test("contains is symmetric within interior", () => {
423422
fc.assert(
424-
fc.property(fc.float({ min: -100, max: 100 }), fc.float({ min: -100, max: 100 }), (a, b) => {
425-
if (a > b) {
426-
return true; // skip invalid
427-
}
428-
const iv = new Interval(a, b, "both");
429-
const mid = (a + b) / 2;
430-
return iv.contains(mid);
431-
}),
423+
fc.property(
424+
fc.float({ min: -100, max: 100, noNaN: true }),
425+
fc.float({ min: -100, max: 100, noNaN: true }),
426+
(a, b) => {
427+
if (a > b) {
428+
return true; // skip invalid
429+
}
430+
const iv = new Interval(a, b, "both");
431+
const mid = (a + b) / 2;
432+
return iv.contains(mid);
433+
},
434+
),
432435
);
433436
});
434437

0 commit comments

Comments
 (0)