Public: anyone can read this; the crew can change it.

A pure function plans a red merge batch's re-gate as two halves, the second stacked on the first #6536

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/6533-a-pure-function-plans-a-red-merge-batch → main 1 commit fork 2 threads · 2 unresolved

  • Merged. scout/6533-a-pure-function-plans-a-red-merge-batch was merged into main as 35b6ed6.

Review

Sign in to review this change.
2 files changed +135 −0
src/checks/restack.ts added +44
@@ -0,0 +1,44 @@
1 /**
2 * A red merge batch's re-gate, planned as plain data: the first half on main,
3 * then the whole batch stacked on that half. Nothing here touches the queue.
4 */
5
6 export type Candidate<T> = { pulls: T[]; onto: "main" | "first" };
7
8 export type Plan<T> =
9 | { kind: "alone"; only: Candidate<T> }
10 | { kind: "halves"; first: Candidate<T>; second: Candidate<T> };
11
12 export type Outcome<T> = {
13 /** Pulls that land now, in queue order. */
14 land: T[];

Outcome has no held: pulls in the second half after a red first half appear in neither land nor regate. Add held so the queue does not have to infer them.

15 /** Pulls to plan again, in queue order; empty when nothing waits on a gate. */
16 regate: T[];
17 /** A pull red on its own, when a single pull was the whole plan. */
18 culprit?: T;
19 };
20
21 export function restack<T>(pulls: readonly T[]): Plan<T> {
22 if (pulls.length === 0) throw new Error("restack needs at least one pull");
23 if (pulls.length === 1) return { kind: "alone", only: { pulls: [...pulls], onto: "main" } };
24 const half = Math.ceil(pulls.length / 2);
25 return {
26 kind: "halves",
27 first: { pulls: pulls.slice(0, half), onto: "main" },
28 second: { pulls: [...pulls], onto: "first" },
29 };
30 }
31
32 /** `secondGreen` is ignored when the first is red (its base never landed) and for a single pull. */
33 export function afterVerdicts<T>(plan: Plan<T>, firstGreen: boolean, secondGreen: boolean): Outcome<T> {
34 if (plan.kind === "alone")
35 return firstGreen
36 ? { land: [...plan.only.pulls], regate: [] }
37 : { land: [], regate: [], culprit: plan.only.pulls[0] as T };
38 if (!firstGreen) return { land: [], regate: [...plan.first.pulls] };
39 if (secondGreen) return { land: [...plan.second.pulls], regate: [] };
40 return {
41 land: [...plan.first.pulls],
42 regate: plan.second.pulls.slice(plan.first.pulls.length),
43 };
44 }
test/checks-restack.test.ts added +91
@@ -0,0 +1,91 @@
1 import { describe, expect, test } from "bun:test";

The first test's name, "one pull is red alone", also covers the green case. Rename it, e.g. "a single pull lands or is the culprit".

2 import { afterVerdicts, restack } from "../src/checks/restack.ts";
3
4 const ids = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
5
6 describe("restack", () => {
7 test("one pull is red alone", () => {
8 const plan = restack([5]);
9 expect(plan).toEqual({ kind: "alone", only: { pulls: [5], onto: "main" } });
10 expect(afterVerdicts(plan, true, true)).toEqual({ land: [5], regate: [] });
11 expect(afterVerdicts(plan, false, false)).toEqual({ land: [], regate: [], culprit: 5 });
12 });
13
14 test("two pulls: one on main, both stacked on it", () => {
15 const plan = restack([1, 2]);
16 expect(plan).toEqual({
17 kind: "halves",
18 first: { pulls: [1], onto: "main" },
19 second: { pulls: [1, 2], onto: "first" },
20 });
21 });
22
23 test("seven split 4 + 7, eight split 4 + 8", () => {
24 for (const [n, h] of [[7, 4], [8, 4]] as const) {
25 const plan = restack(ids(n));
26 if (plan.kind !== "halves") throw new Error("expected halves");
27 expect(plan.first.pulls).toEqual(ids(h));
28 expect(plan.second.pulls).toEqual(ids(n));
29 expect(plan.second.onto).toBe("first");
30 }
31 });
32
33 test("empty batch is refused; input is not mutated", () => {
34 expect(() => restack([])).toThrow();
35 const input = [1, 2, 3];
36 restack(input);
37 expect(input).toEqual([1, 2, 3]);
38 });
39
40 test("every verdict pair", () => {
41 const plan = restack(ids(7));
42 expect(afterVerdicts(plan, true, true)).toEqual({ land: ids(7), regate: [] });
43 expect(afterVerdicts(plan, true, false)).toEqual({ land: [1, 2, 3, 4], regate: [5, 6, 7] });
44 expect(afterVerdicts(plan, false, true)).toEqual({ land: [], regate: [1, 2, 3, 4] });
45 expect(afterVerdicts(plan, false, false)).toEqual({ land: [], regate: [1, 2, 3, 4] });
46 });
47
48 test("an 8-pull batch with one bad pull lands the 7 good ones, driven by the function alone", () => {
49 for (let bad = 1; bad <= 8; bad++) {
50 let batch = ids(8);
51 let held: number[] = [];
52 const landed: number[] = [];
53 let rounds = 0;
54 while (batch.length > 0 || held.length > 0) {
55 if (batch.length === 0) {
56 batch = held;
57 held = [];
58 }
59 const plan = restack(batch);
60 const green = (c: { pulls: number[] }) => !c.pulls.includes(bad);
61 const out =
62 plan.kind === "alone"
63 ? afterVerdicts(plan, green(plan.only), green(plan.only))
64 : afterVerdicts(plan, green(plan.first), green(plan.second));
65 rounds++;
66 landed.push(...out.land);
67 const gone = new Set([...out.land, ...out.regate, ...(out.culprit ? [out.culprit] : [])]);
68 held = [...batch.filter((p) => !gone.has(p)), ...held];
69 batch = out.regate;
70 expect(rounds).toBeLessThan(20);
71 }
72 expect(landed).toEqual(ids(8).filter((p) => p !== bad));
73 }
74 });
75
76 test("with the bad pull last, the good 7 land across rounds that each land at least one", () => {
77 let batch = ids(8);
78 const landed: number[] = [];
79 let rounds = 0;
80 while (batch.length > 1) {
81 const plan = restack(batch);
82 if (plan.kind !== "halves") break;
83 const out = afterVerdicts(plan, !plan.first.pulls.includes(8), !plan.second.pulls.includes(8));
84 landed.push(...out.land);
85 batch = out.regate;
86 rounds++;
87 }
88 expect(landed).toEqual(ids(7));
89 expect(rounds).toBe(3);
90 });
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs