Skip to content
Merged
40 changes: 20 additions & 20 deletions .github/workflows/autoloop.lock.yml

Large diffs are not rendered by default.

63 changes: 56 additions & 7 deletions .github/workflows/autoloop.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ safe-outputs:
title-prefix: "[Autoloop] "
labels: [automation, autoloop]
protected-files: fallback-to-issue
preserve-branch-name: true
max: 1
push-to-pull-request-branch:
target: "*"
Expand Down Expand Up @@ -434,10 +435,26 @@ steps:
# Look up existing PR for the selected program's canonical branch
existing_pr = None
head_branch = None

def verify_pr_is_open(pr_number):
"""Check if a PR is still open via the GitHub API. Returns True if open."""
try:
verify_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
verify_req = urllib.request.Request(verify_url, headers={
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github.v3+json",
})
with urllib.request.urlopen(verify_req, timeout=30) as verify_resp:
pr_data = json.loads(verify_resp.read().decode())
return pr_data.get("state") == "open"
except Exception:
return True # If we can't verify, assume it's open (best effort)

if selected:
head_branch = f"autoloop/{selected}"
owner = repo.split("/")[0] if "/" in repo else ""
if owner:
# Strategy 1: exact branch match (works when branch has no framework suffix)
try:
pr_api_url = (
f"https://api.github.com/repos/{repo}/pulls"
Expand All @@ -451,22 +468,54 @@ steps:
open_prs = json.loads(pr_resp.read().decode())
if open_prs:
existing_pr = open_prs[0]["number"]
print(f" Found existing PR #{existing_pr} for branch {head_branch}")
else:
print(f" No existing PR found for branch {head_branch}")
print(f" Found existing PR #{existing_pr} for exact branch {head_branch}")
except Exception as e:
print(f" Warning: could not check for existing PRs: {e}")
print(f" Warning: could not check for existing PRs by exact branch: {e}")

# Strategy 2: search by title and branch prefix (catches framework-generated
# hash suffixes like autoloop/name-a1b2c3d4e5f6g7h8 created by create-pull-request)
if existing_pr is None:
try:
title_marker = f"[Autoloop: {selected}]"
branch_prefix = head_branch # e.g. autoloop/perf-comparison
list_url = (
f"https://api.github.com/repos/{repo}/pulls"
f"?state=open&per_page=100&sort=created&direction=desc"
)
list_req = urllib.request.Request(list_url, headers={
"Authorization": f"token {github_token}",
"Accept": "application/vnd.github.v3+json",
})
with urllib.request.urlopen(list_req, timeout=30) as list_resp:
all_open_prs = json.loads(list_resp.read().decode())
# Match branch names: exact canonical name or canonical + framework hash suffix
branch_pattern = re.compile(r'^' + re.escape(branch_prefix) + r'(-[0-9a-f]{16})?$')
for pr in all_open_prs:
pr_title = pr.get("title", "")
pr_head_ref = pr.get("head", {}).get("ref", "")
if title_marker in pr_title or branch_pattern.match(pr_head_ref):
existing_pr = pr["number"]
print(f" Found existing PR #{existing_pr} by title/branch-prefix (branch: {pr_head_ref})")
break
if existing_pr is None:
print(f" No existing PR found for program {selected}")
except Exception as e:
print(f" Warning: could not search for existing PRs by title/prefix: {e}")
else:
print(f" Warning: could not parse owner from GITHUB_REPOSITORY='{repo}'")

# Also check the state file for a recorded PR number as fallback
# Strategy 3: check the state file for a recorded PR number as fallback
if existing_pr is None:
state = read_program_state(selected)
pr_field = state.get("pr") or ""
pr_match = re.match(r'^#?(\d+)$', pr_field.strip())
if pr_match:
existing_pr = int(pr_match.group(1))
print(f" Found PR #{existing_pr} from state file for {selected}")
pr_num = int(pr_match.group(1))
if verify_pr_is_open(pr_num):
existing_pr = pr_num
print(f" Found open PR #{existing_pr} from state file for {selected}")
else:
print(f" PR #{pr_num} from state file is no longer open — ignoring")

result = {
"selected": selected,
Expand Down
9 changes: 8 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
},
"files": {
"ignoreUnknown": false,
"ignore": ["dist/**", "node_modules/**", "*.d.ts", "playground/**/*.js", "playground/serve.ts"]
"ignore": [
"dist/**",
"node_modules/**",
"*.d.ts",
"playground/**/*.js",
"playground/serve.ts",
"benchmarks/**"
]
},
"formatter": {
"enabled": true,
Expand Down
4 changes: 3 additions & 1 deletion src/core/attrs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,9 @@ export function setAttr(obj: object, key: string, value: unknown): void {
*/
export function deleteAttr(obj: object, key: string): void {
const existing = registry.get(obj);
if (existing === undefined) return;
if (existing === undefined) {
return;
}
const { [key]: _removed, ...rest } = existing;
if (Object.keys(rest).length === 0) {
registry.delete(obj);
Expand Down
14 changes: 9 additions & 5 deletions src/core/frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,14 @@ export class DataFrame {
* @param columns - Ordered map of column name → Series (all same length and index).
* @param index - Row index (must match each Series' length).
*/
constructor(columns: ReadonlyMap<string, Series<Scalar>>, index: Index<Label>) {
constructor(
columns: ReadonlyMap<string, Series<Scalar>>,
index: Index<Label>,
columnNames?: readonly string[],
) {
this._columns = columns;
this.index = index;
this.columns = new Index<string>([...columns.keys()]);
this.columns = new Index<string>(columnNames ?? [...columns.keys()]);
}

/**
Expand Down Expand Up @@ -208,7 +212,7 @@ export class DataFrame {

/** `[nRows, nCols]` — mirrors `pandas.DataFrame.shape`. */
get shape(): [number, number] {
return [this.index.size, this._columns.size];
return [this.index.size, this.columns.size];
}

/** Always `2`. */
Expand All @@ -218,12 +222,12 @@ export class DataFrame {

/** Total number of cells (`nRows * nCols`). */
get size(): number {
return this.index.size * this._columns.size;
return this.index.size * this.columns.size;
}

/** `true` when the DataFrame has no rows or no columns. */
get empty(): boolean {
return this.index.size === 0 || this._columns.size === 0;
return this.index.size === 0 || this.columns.size === 0;
}

// ─── column access ────────────────────────────────────────────────────────
Expand Down
11 changes: 8 additions & 3 deletions src/core/insert_pop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,28 @@ export function insertColumn(
}

// Rebuild the column map, inserting the new column at position `loc`.
const colNames: string[] = [];
const colMap = new Map<string, Series<Scalar>>();
let idx = 0;

for (const colName of df.columns.values) {
if (idx === loc) {
colMap.set(column, series);
colNames.push(column);
}
colNames.push(colName);
colMap.set(colName, df.col(colName));
idx++;
}

// Handle insertion at the end (loc === nCols).
if (loc === nCols) {
colMap.set(column, series);
colNames.push(column);
}

return new DataFrame(colMap, df.index);
// Always add the new column data to the map (last-wins for duplicate names).
colMap.set(column, series);

return new DataFrame(colMap, df.index, colNames);
}

// ─── popColumn ────────────────────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion src/reshape/wide_to_long.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ function collectSuffixes(
// Sort numerically when both look like integers, otherwise lexicographically.
const na = Number(a);
const nb = Number(b);
if (!Number.isNaN(na) && !Number.isNaN(nb)) {
if (!(Number.isNaN(na) || Number.isNaN(nb))) {
return na - nb;
}
return a < b ? -1 : a > b ? 1 : 0;
Expand Down
54 changes: 41 additions & 13 deletions src/stats/categorical_ops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ export function catFromCodes(
const { ordered = false, name = null } = opts;
const cats = uniqueKeys(categories);
const values: Scalar[] = codes.map((code) => {
if (code === -1) return null;
if (code === -1) {
return null;
}
if (code < -1 || code >= cats.length) {
throw new RangeError(`catFromCodes: code ${code} is out of range [0, ${cats.length - 1}]`);
}
Expand Down Expand Up @@ -210,9 +212,13 @@ export function catDiffCategories(a: CatSeriesLike, b: CatSeriesLike): CatSeries
export function catEqualCategories(a: CatSeriesLike, b: CatSeriesLike): boolean {
const aSet = new Set((a.cat.categories.values as Scalar[]).map(String));
const bSet = new Set((b.cat.categories.values as Scalar[]).map(String));
if (aSet.size !== bSet.size) return false;
if (aSet.size !== bSet.size) {
return false;
}
for (const c of aSet) {
if (!bSet.has(c)) return false;
if (!bSet.has(c)) {
return false;
}
}
return true;
}
Expand Down Expand Up @@ -243,12 +249,16 @@ export function catSortByFreq(
const { ascending = false } = opts;
const cats = series.cat.categories.values as Scalar[];
const freq = new Map<string, number>();
for (const c of cats) freq.set(String(c), 0);
for (const c of cats) {
freq.set(String(c), 0);
}
for (const v of series.values) {
if (!isMissing(v)) {
const k = String(v);
const prev = freq.get(k);
if (prev !== undefined) freq.set(k, prev + 1);
if (prev !== undefined) {
freq.set(k, prev + 1);
}
}
}
const sorted = [...cats].sort((a, b) => {
Expand Down Expand Up @@ -306,7 +316,9 @@ export function catToOrdinal(series: CatSeriesLike, order: readonly Scalar[]): C
export function catFreqTable(series: CatSeriesLike): Record<string, number> {
const cats = series.cat.categories.values as Scalar[];
const freq: Record<string, number> = {};
for (const c of cats) freq[String(c)] = 0;
for (const c of cats) {
freq[String(c)] = 0;
}
for (const v of series.values) {
if (!isMissing(v)) {
const k = String(v);
Expand Down Expand Up @@ -358,7 +370,9 @@ export function catCrossTab(
const counts = new Map<string, Map<string, number>>();
for (const r of rowCats) {
const row = new Map<string, number>();
for (const c of colCats) row.set(String(c), 0);
for (const c of colCats) {
row.set(String(c), 0);
}
counts.set(String(r), row);
}

Expand All @@ -368,18 +382,26 @@ export function catCrossTab(
for (let i = 0; i < n; i++) {
const av = aVals[i];
const bv = bVals[i];
if (isMissing(av) || isMissing(bv)) continue;
if (isMissing(av) || isMissing(bv)) {
continue;
}
const row = counts.get(String(av));
if (row === undefined) continue;
if (row === undefined) {
continue;
}
const prev = row.get(String(bv));
if (prev !== undefined) row.set(String(bv), prev + 1);
if (prev !== undefined) {
row.set(String(bv), prev + 1);
}
}

// Compute total for normalization
let total = 0;
if (normalize) {
for (const row of counts.values()) {
for (const v of row.values()) total += v;
for (const v of row.values()) {
total += v;
}
}
}

Expand All @@ -399,7 +421,11 @@ export function catCrossTab(
const rowTotals: Scalar[] = rowCats.map((r) => {
let sum = 0;
const row = counts.get(String(r));
if (row) for (const v of row.values()) sum += v;
if (row) {
for (const v of row.values()) {
sum += v;
}
}
return normalize && total > 0 ? sum / total : sum;
});
data[marginsName] = rowTotals;
Expand Down Expand Up @@ -430,7 +456,9 @@ export function catCrossTab(
// Ensure all column arrays have the same length
for (const col of allCols) {
const arr = data[col];
if (arr === undefined) data[col] = rowLabels.map(() => 0);
if (arr === undefined) {
data[col] = rowLabels.map(() => 0);
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/stats/window_extended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function applyWindow(
minN: number,
agg: (nums: number[], n: number) => Scalar,
): SeriesLike {
const { values, index, name } = series;
const { values, name } = series;
const n = values.length;
const minPeriods = opts.minPeriods ?? window;
const effectiveMin = Math.max(minN, minPeriods);
Expand Down
8 changes: 6 additions & 2 deletions src/window/rolling_apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ function validNums(slice: readonly Scalar[]): number[] {
/** Convert a raw window slice to `null`-substituted numeric array. */
function rawWindow(slice: readonly Scalar[]): (number | null)[] {
return slice.map((v): number | null => {
if (isMissing(v)) return null;
if (typeof v === "number") return v;
if (isMissing(v)) {
return null;
}
if (typeof v === "number") {
return v;
}
return null;
});
}
Expand Down
4 changes: 3 additions & 1 deletion tests/core/api_types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,9 @@ describe("isMissing", () => {
it("property: no finite number is missing", () => {
fc.assert(
fc.property(fc.float({ noNaN: true }), (n) => {
if (!Number.isFinite(n)) return true;
if (!Number.isFinite(n)) {
return true;
}
return !isMissing(n);
}),
);
Expand Down
2 changes: 2 additions & 0 deletions tests/core/attrs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe("getAttrs", () => {
const obj = freshObj();
setAttrs(obj, { a: 1 });
const copy = getAttrs(obj);
// biome-ignore lint/complexity/useLiteralKeys: TS4111 index signature
copy["a"] = 999;
// original should be unchanged
expect(getAttrs(obj)).toEqual({ a: 1 });
Expand All @@ -112,6 +113,7 @@ describe("setAttrs", () => {
const obj = freshObj();
const input: Record<string, unknown> = { x: 10 };
setAttrs(obj, input);
// biome-ignore lint/complexity/useLiteralKeys: TS4111 index signature
input["x"] = 999;
expect(getAttrs(obj)).toEqual({ x: 10 });
});
Expand Down
5 changes: 3 additions & 2 deletions tests/core/insert_pop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,9 @@ describe("insertColumn", () => {
test("allows duplicate column when allowDuplicates=true", () => {
const df = makeDF();
const df2 = insertColumn(df, 1, "a", [99, 99, 99], true);
// Map-based column store overwrites the duplicate key; shape stays at 3
expect(df2.shape[1]).toBe(3);
// columnNames array preserves duplicates; shape grows to 4
expect(df2.shape[1]).toBe(4);
// col("a") returns the last-set value in the Map (the new column)
expect(df2.col("a").values).toEqual([99, 99, 99]);
});

Expand Down
Loading
Loading