diff --git a/specs/policy/pr_budget.t27 b/specs/policy/pr_budget.t27 new file mode 100644 index 0000000000..975d137ff5 --- /dev/null +++ b/specs/policy/pr_budget.t27 @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/policy/pr_budget.t27 -- how many PRs a port wave may open, and how many one author may hold open. +; Closes #10067. Measured on 2026-10-17: open PRs were dmitrii-f-t27 128 and gHashTag 162 (131 of +; them queen-*); dmitrii-f-t27 opened 6 to 9 PRs an hour around the clock; of the latest 1000 Actions +; runs (17:34-17:59Z) 732 were master push runs, about 19 workflows per merge, and 76 were +; dmitrii-f-t27 pull_request runs; at 18:00Z, 29 of 51 queued runs were dmitrii-f-t27's. +; The ports are real work (Roadmap Stage 6). What filled both queues is their shape: one PR per file. +; -- +; Rule 1, batching. A port wave PR is one whose title starts `spec(port)` or whose branch starts +; `zcode-port-` or `port/cr-chat-`. A port wave lands one PR per ring or crate, never one per file. +; Credit is not lost: each ported spec earns TRI_PER_SPEC in the Queen earnings ledger whatever the +; number of PRs that carried it, so batching keeps every author's credit and drops only Actions runs. +; Rule of thumb: a 20-file wave as 20 PRs is 20 x ~29 checks + 20 merges x ~19 push runs, about 960 +; runs; as one batch it is about 48. +; -- +; Rule 2, budget. Each author may hold at most OPEN_CAP open non-draft PRs. Every author counts the +; same, the owner's account and the Queen's bees included: the cap is on the queue, not on a person. +; When the cap is full, the excess is held as a draft, and drafts are promoted in order as slots free. +; Nothing is closed and no work is refused; a held PR only waits. +; -- +; This card is the rule only. Wiring it into a gate that drafts the excess is a follow-up; until then +; nothing reads this file but its own tests. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module PolicyPrBudget; + +/// Open non-draft PRs one author may hold. The starting value; tune it here, nowhere else. +pub const OPEN_CAP : u32 = 20; + +/// TRI the Queen earnings ledger pays per ported spec, independent of how many PRs carried it. +pub const TRI_PER_SPEC : u32 = 27; + +/// Checks one PR runs on pull_request, and workflows one merge starts on master push (2026-10-17). +pub const CHECKS_PER_PR : u32 = 29; +pub const PUSH_RUNS_PER_MERGE : u32 = 19; + +/// Verdicts of admit(). +pub const READY : u8 = 0; +pub const HOLD_DRAFT : u8 = 1; + +/// The three prefixes that mark a port wave PR, with their lengths. +pub const PORT_TITLE : str = "spec(port)"; +pub const PORT_TITLE_LEN : usize = 10; +pub const PORT_BRANCH_ZCODE : str = "zcode-port-"; +pub const PORT_BRANCH_ZCODE_LEN : usize = 11; +pub const PORT_BRANCH_CR_CHAT : str = "port/cr-chat-"; +pub const PORT_BRANCH_CR_CHAT_LEN : usize = 13; + +/// Measured 2026-10-17 on gHashTag/t27. Test data, not tuning knobs. +pub const M_OPEN_FEDOROV : u32 = 128; +pub const M_OPEN_GHASHTAG : u32 = 162; +pub const M_OPEN_GHASHTAG_QUEEN : u32 = 131; +pub const M_RATE_LOW_PER_HOUR : u32 = 6; +pub const M_RATE_HIGH_PER_HOUR : u32 = 9; +pub const M_RUNS_SAMPLE : u32 = 1000; +pub const M_RUNS_MASTER_PUSH : u32 = 732; +pub const M_RUNS_FEDOROV_PR : u32 = 76; +pub const M_SAMPLE_MINUTES : u32 = 25; +pub const M_QUEUED : u32 = 51; +pub const M_QUEUED_FEDOROV : u32 = 29; + +/// Does buf[0..n] start with p[0..pn]? +pub fn has_prefix(buf: []const u8, n: usize, p: []const u8, pn: usize) -> bool { + if (pn > n) { return false; } + var i : usize = 0; + while (i < pn) { + if (buf[i] != p[i]) { return false; } + i += 1; + } + return true; +} + +/// Is this PR part of a port wave? Title `spec(port)...`, or branch `zcode-port-...` / `port/cr-chat-...`. +pub fn is_port_pr(title: []const u8, tn: usize, branch: []const u8, bn: usize) -> bool { + if (has_prefix(title, tn, PORT_TITLE, PORT_TITLE_LEN)) { return true; } + if (has_prefix(branch, bn, PORT_BRANCH_ZCODE, PORT_BRANCH_ZCODE_LEN)) { return true; } + return has_prefix(branch, bn, PORT_BRANCH_CR_CHAT, PORT_BRANCH_CR_CHAT_LEN); +} + +/// PRs a port wave of `files` specs across `groups` rings or crates opens: one per group when +/// batched, one per file when not. +pub fn wave_prs(files: u32, groups: u32, batched: bool) -> u32 { + if (batched) { return groups; } + return files; +} + +/// Actions runs a wave of `prs` PRs costs: its pull_request checks plus the push runs of its merges. +pub fn wave_runs(prs: u32) -> u32 { + return prs * CHECKS_PER_PR + prs * PUSH_RUNS_PER_MERGE; +} + +/// TRI a wave of `files` ported specs earns its author. The PR count is not an input, on purpose. +pub fn wave_credit(files: u32) -> u32 { + return files * TRI_PER_SPEC; +} + +/// Can an author with `open_ready` open non-draft PRs open one more as ready, or is it held? +pub fn admit(open_ready: u32, cap: u32) -> u8 { + if (open_ready < cap) { return READY; } + return HOLD_DRAFT; +} + +/// Of `opened` new PRs from an author already holding `open_ready`, how many are held as drafts? +pub fn held_as_draft(open_ready: u32, opened: u32, cap: u32) -> u32 { + if (open_ready >= cap) { return opened; } + const free : u32 = cap - open_ready; + if (opened <= free) { return 0; } + return opened - free; +} + +/// When an author holds `open_ready` ready PRs and `drafts` held ones, how many drafts are promoted? +pub fn promoted(open_ready: u32, drafts: u32, cap: u32) -> u32 { + if (open_ready >= cap) { return 0; } + const free : u32 = cap - open_ready; + if (drafts <= free) { return drafts; } + return free; +} + +/// Whole hours until an author opening `rate` PRs an hour fills an empty cap. +pub fn hours_to_fill(cap: u32, rate: u32) -> u32 { + return (cap + rate - 1) / rate; +} + +/// part / whole in thousandths, rounded down. +pub fn permille(part: u32, whole: u32) -> u32 { + return part * 1000 / whole; +} + +test port_prs_are_named_by_title_or_branch { + assert is_port_pr("spec(port): port specs/port/trios-railway/crates/trios-igla-race/src/race.t27", 77, "x", 1) == true; + assert is_port_pr("chore: x", 8, "zcode-port-9776", 15) == true; + assert is_port_pr("chore: x", 8, "port/cr-chat-12", 15) == true; +} + +test other_prs_are_not_port_prs { + assert is_port_pr("spec(t27f): spec_graph_view", 27, "claude/frosty-knuth-6faee5", 26) == false; + assert is_port_pr("fix(t27b): fold a constant tie-off", 34, "queen-10046", 11) == false; + // a near miss on each prefix, and a prefix longer than the text + assert is_port_pr("spec(ports)", 11, "zcode-port", 10) == false; + assert is_port_pr("Spec(port): x", 13, "port/cr-chat", 12) == false; + assert is_port_pr("spec(", 5, "port/", 5) == false; +} + +test a_twenty_file_wave_costs_960_runs_unbatched_and_48_batched { + const prs_one_per_file : u32 = wave_prs(20, 1, false); + const prs_batched : u32 = wave_prs(20, 1, true); + assert prs_one_per_file == 20; + assert prs_batched == 1; + // 20 x 29 checks + 20 merges x 19 push runs + assert wave_runs(prs_one_per_file) == 20 * 29 + 20 * 19; + assert wave_runs(prs_one_per_file) == 960; + assert wave_runs(prs_batched) == 48; + // the saving is the file count per group + assert wave_runs(prs_one_per_file) / wave_runs(prs_batched) == 20; +} + +test batching_keeps_the_credit { + // the same 20 specs earn 540 TRI whether they land as 20 PRs or 1 + assert wave_credit(20) == 540; + assert wave_credit(20) == 20 * TRI_PER_SPEC; +} + +test a_wave_over_three_crates_is_three_prs { + assert wave_prs(20, 3, true) == 3; + assert wave_runs(wave_prs(20, 3, true)) == 144; +} + +test the_cap_admits_up_to_20_and_holds_the_21st { + assert admit(0, OPEN_CAP) == READY; + assert admit(19, OPEN_CAP) == READY; + assert admit(20, OPEN_CAP) == HOLD_DRAFT; + assert admit(21, OPEN_CAP) == HOLD_DRAFT; +} + +test the_excess_is_held_as_drafts { + assert held_as_draft(0, 20, OPEN_CAP) == 0; + assert held_as_draft(15, 10, OPEN_CAP) == 5; + assert held_as_draft(20, 3, OPEN_CAP) == 3; + assert held_as_draft(25, 1, OPEN_CAP) == 1; +} + +test drafts_are_promoted_as_slots_free { + assert promoted(20, 108, OPEN_CAP) == 0; + assert promoted(17, 108, OPEN_CAP) == 3; + assert promoted(10, 4, OPEN_CAP) == 4; + assert promoted(0, 0, OPEN_CAP) == 0; +} + +test measured_2026_10_17_open_prs_against_the_cap { + // read as if every open PR were ready: the share of each queue the cap would hold back + assert held_as_draft(0, M_OPEN_FEDOROV, OPEN_CAP) == 108; + assert held_as_draft(0, M_OPEN_GHASHTAG, OPEN_CAP) == 142; + // the owner's account is not exempt, and most of its queue is the Queen's bees + assert M_OPEN_GHASHTAG_QUEEN * 2 > M_OPEN_GHASHTAG; + assert M_OPEN_GHASHTAG - M_OPEN_GHASHTAG_QUEEN == 31; +} + +test measured_2026_10_17_rate_fills_the_cap_in_hours { + // 6 to 9 PRs an hour, around the clock: 144 to 216 a day + assert M_RATE_LOW_PER_HOUR * 24 == 144; + assert M_RATE_HIGH_PER_HOUR * 24 == 216; + // an empty cap of 20 is full within 3 to 4 hours + assert hours_to_fill(OPEN_CAP, M_RATE_HIGH_PER_HOUR) == 3; + assert hours_to_fill(OPEN_CAP, M_RATE_LOW_PER_HOUR) == 4; +} + +test measured_2026_10_17_actions_runs { + // 732 of 1000 runs were master push runs: at about 19 a merge, about 38 merges in 25 minutes + assert permille(M_RUNS_MASTER_PUSH, M_RUNS_SAMPLE) == 732; + assert M_RUNS_MASTER_PUSH / PUSH_RUNS_PER_MERGE == 38; + assert permille(M_RUNS_FEDOROV_PR, M_RUNS_SAMPLE) == 76; + // at 18:00Z, one author held more than half of the queue + assert permille(M_QUEUED_FEDOROV, M_QUEUED) == 568; + assert M_QUEUED_FEDOROV * 2 > M_QUEUED; +} + +invariant credit_does_not_depend_on_pr_count { + // wave_credit takes no PR count, so any batching of the same files earns the same + assert wave_credit(1) == TRI_PER_SPEC; + assert wave_credit(20) == 20 * wave_credit(1); + assert wave_prs(20, 1, true) < wave_prs(20, 1, false); +} + +invariant batching_never_costs_more_runs { + // a wave never has more groups than files, so the batch is never dearer + assert wave_runs(wave_prs(20, 20, true)) == wave_runs(wave_prs(20, 20, false)); + assert wave_runs(wave_prs(20, 1, true)) <= wave_runs(wave_prs(20, 1, false)); + assert wave_runs(2) == 2 * wave_runs(1); + assert wave_runs(0) == 0; +} + +invariant held_and_ready_add_up_and_never_exceed_the_cap { + // whatever is opened, ready PRs stay at or under the cap and nothing is lost + assert 15 + (10 - held_as_draft(15, 10, OPEN_CAP)) == OPEN_CAP; + assert 0 + (20 - held_as_draft(0, 20, OPEN_CAP)) == OPEN_CAP; + assert 17 + promoted(17, 108, OPEN_CAP) == OPEN_CAP; + assert READY != HOLD_DRAFT; +}