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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6533-a-pure-function-plans-a-red-merge-batch → main 1 commit fork 2 threads · 2 unresolved
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[]; | |
| 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 | }); |
new above
offline
Outcome has no
held: pulls in the second half after a red first half appear in neither land nor regate. Addheldso the queue does not have to infer them.