Watch
0
0
Fork
You've already forked hyperhive
0

review classifier: the most classifications a run carries is a setting

The limit was a constant of ten in swarm-wire, so a repo with eleven rules
could not be reviewed and nothing short of a rebuild changed it. It is now
`max_classifications` in the classifier settings, beside
`max_concurrent_reviews`: 1 to 1000, ten until set, and a document stored
before the field decodes with the default. The classifier settings form in
the swarm UI gets a "rules per review" field, and the try-out's add button
stops at the saved limit instead of a hard-coded ten.

The controller checks a try-out, and a requested review's rules file, against
the setting where the run starts. The rules file itself may hold up to the
1000 the setting allows, so raising the limit needs no change to a repo.
A review whose rules exceed the limit posts a no-verdict comment saying so.

A run's reply deadline was a constant that covered ten classifications, a
minute plus three each. It is now computed from the run's own size, so a
bigger run waits as long as it needs and a default-sized one still waits 31
minutes. The classifier's answer window in the queue permissions covers the
biggest run the setting allows.
This commit is contained in:
iris 2026-11-07 10:37:52 +01:00 • committed by mara
commit cffe8895df
6 changed files with 400 additions and 101 deletions

View file

@ -1,5 +1,6 @@
// <ClassifierSettings> — the review classifier's runtime, model, effort and
// how many requested reviews run at once, shown and saved here
// how many requested reviews run at once and how many rules a run takes, shown
// and saved here
// (`GET`/`PUT /api/review-classifier/settings`). A PUT changes only the
// fields it carries: the form sends the runtime, model, effort and limit, and
// the ACP provider only while the runtime is ACP, so the stored provider
@ -19,7 +20,9 @@ import { TextField } from "../../ui/text-field/TextField.js";
import {
EFFORTS,
MAX_REVIEWS,
MAX_RULES,
MIN_REVIEWS,
MIN_RULES,
changed,
draftOf,
settingsProblem,
@ -177,6 +180,13 @@ export function ClassifierSettings() {
pattern="[0-9]+"
onInput={(max) => edit({ ...draft, max })}
/>
<TextField
id="classifier-max-rules"
label={`rules per review (${MIN_RULES} to ${MAX_RULES})`}
value={draft.maxRules}
pattern="[0-9]+"
onInput={(maxRules) => edit({ ...draft, maxRules })}
/>
</FieldGrid>
{draft.runtime === "acp" ? (
<fieldset class="ui-classifier-settings-group">

View file

@ -33,6 +33,8 @@ export interface ClassifierSettingsBody {
* `--effort`, or opencode's. */
effort: Effort | null;
max_concurrent_reviews: number;
/** The most classifications (rules) one run carries. */
max_classifications: number;
runtime: Runtime;
/** The provider record. It stays stored while the runtime is `api-key`. */
acp: AcpSettings | null;
@ -44,6 +46,8 @@ export interface SettingsDraft {
/** An effort, or `""` for the runtime's own default. */
effort: string;
max: string;
/** The most classifications one run carries. */
maxRules: string;
runtime: Runtime;
acpUrl: string;
acpModel: string;
@ -56,6 +60,8 @@ export interface SettingsDraft {
export const MIN_REVIEWS = 1;
export const MAX_REVIEWS = 8;
export const MIN_RULES = 1;
export const MAX_RULES = 1000;
export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
const acp = s.acp;
@ -63,6 +69,7 @@ export function draftOf(s: ClassifierSettingsBody): SettingsDraft {
model: s.model,
effort: s.effort ?? "",
max: String(s.max_concurrent_reviews),
maxRules: String(s.max_classifications),
runtime: s.runtime,
acpUrl: acp?.base_url ?? "",
acpModel: acp?.model ?? "",
@ -86,6 +93,15 @@ export function maxValue(text: string): number | null {
return n >= MIN_REVIEWS && n <= MAX_REVIEWS ? n : null;
}
/** The rules limit as a number, or null when it is not a whole number in
* range. */
export function rulesValue(text: string): number | null {
const t = text.trim();
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= MIN_RULES && n <= MAX_RULES ? n : null;
}
/** A price as a number, or null when it is not a number of 0 or more. */
export function priceValue(text: string): number | null {
const t = text.trim();
@ -124,6 +140,9 @@ export function settingsProblem(d: SettingsDraft): string | null {
if (maxValue(d.max) === null) {
return `concurrent reviews is a whole number from ${MIN_REVIEWS} to ${MAX_REVIEWS}`;
}
if (rulesValue(d.maxRules) === null) {
return `rules per review is a whole number from ${MIN_RULES} to ${MAX_RULES}`;
}
return d.runtime === "acp" ? acpProblem(d) : null;
}
@ -153,6 +172,7 @@ export function toSettings(
model: d.model.trim(),
effort: d.effort === "" ? null : (d.effort as Effort),
max_concurrent_reviews: maxValue(d.max) ?? MIN_REVIEWS,
max_classifications: rulesValue(d.maxRules) ?? MIN_RULES,
runtime: d.runtime,
};
return d.runtime === "acp" ? { ...body, acp: toAcp(d) } : body;
@ -169,6 +189,7 @@ export function changed(
d.model.trim() !== now.model ||
d.effort !== now.effort ||
d.max.trim() !== now.max ||
d.maxRules.trim() !== now.maxRules ||
d.runtime !== now.runtime;
if (base) return true;
// The provider fields only save on the ACP runtime.
@ -185,12 +206,16 @@ export function changed(
/** The settings in force as one short line, for a folded view of them: the
* runtime and the model it runs, the effort when one is set, and how many
* reviews run at once. The model is the provider's under ACP. */
* reviews run at once and how many rules a run takes. The model is the
* provider's under ACP. */
export function settingsSummary(s: ClassifierSettingsBody): string {
const model = s.runtime === "acp" ? (s.acp?.model ?? "") : s.model;
const parts = [s.runtime, model, s.effort ? `${s.effort} effort` : ""].filter(
(p) => p !== "",
);
parts.push(`${s.max_concurrent_reviews} at once`);
parts.push(
`${s.max_concurrent_reviews} at once`,
`up to ${s.max_classifications} rules`,
);
return parts.join(" · ");
}

View file

@ -55,6 +55,7 @@ import "./ReviewTryOut.css";
const TRY_OUT = "/api/review-classifier/try-out";
const RULES = "/api/review-classifier/rules";
const OPEN_PR = "/api/review-classifier/open-pr";
const SETTINGS = "/api/review-classifier/settings";
const PULL_REPOS = "/api/pulls/repos";
const PULLS = "/api/pulls";
// How long the repo field rests before its PRs are fetched, so typing a repo
@ -111,6 +112,9 @@ export function ReviewTryOut() {
useState<ProblemDetails | null>(null);
const [pulls, setPulls] = useState<OpenPull[]>([]);
const [pullsError, setPullsError] = useState<ProblemDetails | null>(null);
const [maxClassifications, setMaxClassifications] = useState<number | null>(
null,
);
const running = starting || runId !== null;
@ -139,6 +143,27 @@ export function ReviewTryOut() {
};
}, []);
// How many rows a run takes: the classifier settings', read once. Until it
// is known, or when it cannot be read, the run endpoint is the only cap.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const res = await fetch(SETTINGS);
if (cancelled || !res.ok) return;
const body = (await res.json()) as { max_classifications?: number };
if (!cancelled && typeof body.max_classifications === "number") {
setMaxClassifications(body.max_classifications);
}
} catch {
// The endpoint still refuses a run that is too long.
}
})();
return () => {
cancelled = true;
};
}, []);
// The open PRs of the repo once it is typed or picked and has stopped
// changing. A repo that is not valid yet has no list.
useEffect(() => {
@ -224,7 +249,7 @@ export function ReviewTryOut() {
const openPrProblem = rowsProblem(repo, drafts);
const problem = runProblem(repo, pr, drafts);
const capProblem = addProblem(drafts.length);
const capProblem = addProblem(drafts.length, maxClassifications);
async function run(e: Event) {
e.preventDefault();

View file

@ -150,14 +150,13 @@ export function duplicateName(drafts: ClassificationDraft[]): string | null {
return null;
}
/** More classifications than this are not offered a row. It is the try-out
* endpoint's own cap, which answers a longer list with a 400. */
export const MAX_CLASSIFICATIONS = 10;
/** Why another row cannot be added, or null while there is room. */
export function addProblem(count: number): string | null {
return count >= MAX_CLASSIFICATIONS
? `a run takes at most ${MAX_CLASSIFICATIONS} classifications`
/** Why another row cannot be added, or null while there is room. `max` is the
* classifier settings' `max_classifications`, the cap the try-out endpoint
* answers a longer list with a 400 for; null while the settings have not
* loaded, when the endpoint stays the only gate. */
export function addProblem(count: number, max: number | null): string | null {
return max !== null && count >= max
? `a run takes at most ${max} classifications, as the classifier's settings say`
: null;
}

View file

@ -8,6 +8,7 @@ import {
changed,
draftOf,
maxValue,
rulesValue,
priceValue,
settingsProblem,
settingsSummary,
@ -18,6 +19,7 @@ const apiKey = {
model: "haiku",
effort: null,
max_concurrent_reviews: 2,
max_classifications: 10,
runtime: "api-key",
acp: null,
};
@ -57,6 +59,7 @@ test("the API key request leaves the provider out, so the stored one stays", ()
model: "haiku",
effort: null,
max_concurrent_reviews: 2,
max_classifications: 10,
runtime: "api-key",
});
assert.equal("acp" in body, false);
@ -119,14 +122,33 @@ test("the reviews limit and a price parse as the form needs", () => {
assert.equal(priceValue("1e3"), null);
});
test("the summary names the runtime, model and how many run at once", () => {
assert.equal(settingsSummary(apiKey), "api-key · haiku · 2 at once");
test("the summary names the runtime, model, how many run at once and the rules a run takes", () => {
assert.equal(
settingsSummary(apiKey),
"api-key · haiku · 2 at once · up to 10 rules",
);
});
test("the rules limit parses and checks as the form needs", () => {
assert.equal(rulesValue("1"), 1);
assert.equal(rulesValue(" 1000 "), 1000);
assert.equal(rulesValue("0"), null);
assert.equal(rulesValue("1001"), null);
assert.equal(rulesValue("2.5"), null);
assert.equal(rulesValue(""), null);
const d = { ...draftOf(apiKey), maxRules: "5000" };
assert.match(settingsProblem(d), /rules per review.*1 to 1000/);
assert.equal(
toSettings({ ...draftOf(apiKey), maxRules: "40" }).max_classifications,
40,
);
assert.equal(changed({ ...draftOf(apiKey), maxRules: "11" }, apiKey), true);
});
test("the summary names an effort when one is set", () => {
assert.equal(
settingsSummary({ ...apiKey, effort: "high" }),
"api-key · haiku · high effort · 2 at once",
"api-key · haiku · high effort · 2 at once · up to 10 rules",
);
});
@ -141,6 +163,6 @@ test("under ACP the summary names the provider's model, not the claude one", ()
prices: { input: 1, output: 2 },
},
}),
"acp · glm · 2 at once",
"acp · glm · 2 at once · up to 10 rules",
);
});

View file

@ -5,7 +5,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
MAX_CLASSIFICATIONS,
addProblem,
draftFromRule,
draftProblem,
@ -19,10 +18,16 @@ import {
toBody,
} from "../src/pages/review-tryout/TryOutTypes.ts";
test("a row is offered until the endpoint's cap is reached", () => {
assert.equal(addProblem(1), null);
assert.equal(addProblem(MAX_CLASSIFICATIONS - 1), null);
assert.match(addProblem(MAX_CLASSIFICATIONS), /at most 10 classifications/);
test("a row is offered until the settings' cap is reached", () => {
assert.equal(addProblem(1, 10), null);
assert.equal(addProblem(9, 10), null);
assert.match(addProblem(10, 10), /at most 10 classifications/);
assert.match(addProblem(25, 25), /at most 25 classifications/);
});
test("with the cap unknown, every row is offered and the endpoint stays the gate", () => {
assert.equal(addProblem(10, null), null);
assert.equal(addProblem(500, null), null);
});
// A row ready to send, with `over` replacing any of its fields.