Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions specs/policy/pr_budget.t27
Original file line number Diff line number Diff line change
@@ -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;
}
Loading