Swamp extension: ROTE issue classifier (@keeb/rote-classifier)
  • TypeScript 100%
Find a file
keeb 31cefa4635 Add @keeb/rote-classifier (2026.11.05.1)
Decide whether an issue is ROTE (safe to plan, implement, PR and
auto-merge unseen) or needs a person. Jev answers rubric v6 through an
@swamp/typesafe-ai definition (runModel, declared dependency) and the
v6 docs-branch gate decides, naming every blocker. Issues come in as
plain {id, title, body, type} records from any source; verdict-<id>
and a summary are stored per run.

Verified live: the 58 labelled open swamp-club issues reproduce the
calibration log exactly (P 7/8, R 7/15, false ROTE 1248, same misses).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:48:54 -08:00
extensions/models Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
rubric Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
.gitignore Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
.swamp.yaml Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
CLAUDE.md Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
LICENSE.txt Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
manifest.yaml Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00
README.md Add @keeb/rote-classifier (2026.11.05.1) 2026-11-05 07:48:54 -08:00

@keeb/rote-classifier

Decide whether an issue is ROTE: safe to run plan → implement → PR → auto-merge with no human review. Anything else is HUMAN.

The classifier is a rubric and a gate:

  • Rubric. Jev (TypeSafe System One) answers 15 yes/no questions about the issue through an @swamp/typesafe-ai definition. Each answer is a probability: is it docs-only, does it need a product decision, does it touch auth or billing code, a schema, infrastructure…
  • Gate. A fixed rule turns the probabilities into a verdict and names every condition that failed. It fails closed: an unanswered question blocks.

Where the issues come from does not matter. classify takes plain {id, title, body, type?} records, so issues from the swamp-club Lab, GitHub or Linear all go in the same way.

Calibration

The bundled rubric is v6 with the v6 docs-branch gate (rubric/). They were calibrated on 244 hand-labelled swamp-club Lab issues against jev-latest (jev-1.13.0):

Set Precision Recall
Tune (130) 1.00 0.74
Holdout (56, run once) 0.97 0.97
Open (58, human prior) 0.88 0.47

A false ROTE costs far more than a missed one, so the gate is tuned for precision. Every false ROTE seen was a borderline issue; no clearly-not-rote issue was ever flagged. By design only documentation issues can pass the gate, so code fixes always go to a person.

The issue source is pluggable, but the rubric is not neutral: several risk questions name swamp-club's own stack (ClickHouse, MongoDB, XP and the ledger, the telemetry service, the discord bot). For another codebase, re-word those questions for its stack and re-check the gate with the questions and gate overrides before trusting a ROTE.

Each verdict records the Jev checkpoint that answered. The gate was fitted on jev-1.13.0; a re-run of the 58 open issues on jev-1.14.0 gave the same precision and recall and the same single false ROTE, but check a new checkpoint the same way before relying on it.

Setup

swamp extension pull @keeb/rote-classifier   # pulls @swamp/typesafe-ai too

# Jev, with the key in a vault
swamp vault create local_encryption typesafe
swamp vault put typesafe TYPESAFE_API_KEY
swamp model create @swamp/typesafe-ai jev \
  --global-arg 'apiKey=${{ vault.get("typesafe", "TYPESAFE_API_KEY") }}'

# The classifier, pointed at that definition
swamp model create @keeb/rote-classifier rote --global-arg jev=jev

Classify

swamp model method run rote classify --input-file issues.json
{
  "issues": [
    {
      "id": 2036,
      "type": "bug",
      "title": "Docs: the env example is wrong",
      "body": "…"
    },
    { "id": "owner/repo#12", "title": "Add a cache for scores", "body": "…" }
  ]
}

Up to 100 issues per call; classify asks Jev once per issue through runModel, and swamp allows 100 such calls per execution. Larger sets go in batches.

In a workflow, wire the issues in with CEL from wherever another model stored them:

- name: classify
  task:
    type: model_method
    modelIdOrName: rote
    methodName: classify
    inputs:
      issues: ${{ data.latest("tracker", "open").attributes.issues.map(i, {"id": i.number, "title": i.title, "body": i.body}) }}

Output

Data What it holds
verdict-<id> verdict (ROTE, HUMAN, UNCLASSIFIED), blockers (e.g. needs_decision=0.71>0.3), every answers.<question>, the Jev checkpoint, rubric and gate hashes
summary counts and the rote, human and unclassified id lists for the run (name it with --input name=…)
# Only auto-merge what the gate passed
${{ data.latest("rote", "verdict-2036").attributes.verdict == "ROTE" }}

An issue Jev could not be asked about is UNCLASSIFIED with the error, and the rest of the batch still runs. A run where nothing could be asked fails. Jev's raw answers stay on the jev definition as evaluation-rote-<id>.

Calibration overrides

questions and gate replace the bundled rubric and gate for one call, for trying a new question or threshold. The stored verdicts record rubric.label custom and both hashes, so runs stay comparable:

swamp model method run rote classify --input-file issues.json \
  --input 'gate={"type":"rule","version":"strict","min":{"docs_only":0.9},"max":{"needs_decision":0.2}}'

A gate is either {"type": "rule", "max": {…}, "min": {…}, "anyMin": {…}} or {"type": "logistic", "intercept": …, "weights": {…}, "threshold": …}.