Conduct already consumes judgements everywhere — shadow scores, the per-model
rollup (eval.md), the planned DPO/SFT export (#16).
But by default those scores come from a human (the review UI, an eval-token
link) or an external client. The judge task type lets Conduct produce
them itself: a job whose work is to evaluate another job's output, routed
through Conduct's own dispatch.
It's pleasingly recursive — Conduct judging its own outputs through its own engine — and most of the hard parts already existed (routing, deterministic sampling, the score lane, fan-out, prompt versioning), so the judge mostly wires primitives together. Design + research notes live in #17.
Conduct's stance is primitives, clients orchestrate, no lifecycle hooks.
A judge is therefore an ordinary task you submit — it does not fire
automatically when jobs complete. It produces a verdict; writing that verdict
back to the judged job happens only on an explicit apply_to_target opt-in.
This keeps determinism guarantees and write-backs under the caller's control,
the same way media chaining works (you pass job-ids; Conduct resolves them).
A judge job is just a normal text job whose inputs name a target to evaluate:
Carrying a target_job_id is what marks it a judge (it then runs the judge
executor instead of a plain completion, and is forced onto the async worker).
| mode | compares | emits | feeds |
|---|---|---|---|
| pointwise (default) | one target vs a rubric | {score 1-5, rationale} |
monitoring, SFT labels |
| pairwise | two targets, head-to-head | {chosen, rejected, tie} |
DPO (chosen, rejected) pairs |
| panel | one target, jury of models | {score (median), n, disagreement} |
bias-reduced scores |
All three run under the judge rule's deterministic sampling profile — temp 0
- a seed derived from the input — so the same target gets the same ruling every time (reproducible, comparable over time).
One model scores one target against the rubric.
curl -sX POST $CONDUCT/jobs -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{
"task_type": "judge",
"prompt": "(judge)",
"inputs": { "mode": "pointwise", "target_job_id": "<uuid>", "apply_to_target": true }
}'Verdict (on the judge job's response + metadata.judge):
{ "mode": "pointwise", "score": 5, "rationale": "Relevant, correct, complete and clear.",
"target_kind": "job", "applied_to_target": true }With apply_to_target, the score is appended to the target's
quality_scores (via="judge", reviewer="<model>") — the same lane humans
use, so the rollup and the DPO export pick it
up with no extra work.
Pass inputs.dimensions to score distinct qualities separately instead of one
lumped number (#18) — the
dimension names are yours (Conduct doesn't interpret them):
{ "mode": "pointwise", "target_job_id": "<uuid>", "apply_to_target": true,
"dimensions": ["correctness", "format", "craft"] }The judge then returns {"scores": {"correctness": 5, "format": 3, "craft": 4}, "rationale": "…"};
the overall is their mean. Both the map and the overall land in quality_scores
(scores + score), so the rollup shows per-dimension per-model averages and
the export can train on a single clean dimension via ?label_dim=correctness.
The panel medians each dimension across the jury. (Pairwise stays winner-based.)
Compares two targets — e.g. a primary vs one of its shadows. The headline feature is position-bias defence: each comparison runs twice with A/B swapped, and a confident result requires both calls to name the same job as winner. If they disagree, the judge is position-biased on that pair, so it records a tie (no preference) rather than a coin-flip.
curl -sX POST $CONDUCT/jobs ... -d '{
"task_type": "judge", "prompt": "(judge)",
"inputs": { "mode": "pairwise", "target_job_id": "<A>", "against_job_id": "<B>", "apply_to_target": true }
}'Verdict:
{ "mode": "pairwise", "chosen_job_id": "<A>", "rejected_job_id": "<B>",
"tie": false, "position_consistent": true,
"rationale_ab": "...", "rationale_ba": "...", "applied_to_target": true }Pairwise stays out of the 1-5 pointwise lane (a relative win/loss isn't an absolute score). On
apply_to_targetit records the preference on both participants under a separatepairwise_verdictsmetadata list, so it never skews per-model pointwise averages. That list is the (chosen, rejected) signal the DPO export reads.
A jury of diverse models each pointwise-scores the target; the result is the median (robust to one odd juror). This is the most effective bias mitigation in the literature, and Conduct gets two of its defences almost for free:
- Self-preference exclusion. Every job records
model_used, so the jury drops any juror in the same family as the target's producer — "never let gemma judge gemma." Families are coarse vendor keys (gemma / llama / qwen / claude / mistral / …). - Sensitivity-aware jury. A juror reads the target's content, so cloud
jurors are dropped when the target is
confidential(orinternalwithout the client's cloud opt-in) — the routing engine's gate, applied per juror.
The jury is the judge rule's preferred_model + its eval_shadow_models
(repurposed as the panel, since judges don't fan out shadows), or an explicit
inputs.panel:
curl -sX POST $CONDUCT/jobs ... -d '{
"task_type": "judge", "prompt": "(judge)",
"inputs": { "mode": "panel", "target_job_id": "<uuid>", "apply_to_target": true,
"panel": ["gemma4:e4b","llama3.2:3b","qwen3.5:4b"] }
}'Verdict:
{ "mode": "panel", "score": 3, "n": 2, "spread": 2, "disagreement": true,
"panelists": [ {"model":"llama3.2:3b","score":4,"rationale":"…"},
{"model":"qwen3.5:4b","score":2,"rationale":"…"} ],
"failures": [], "excluded": {"gemma4:e4b": "self-preference (same family as producer: gemma)"},
"applied_to_target": true }Resilient: a juror that errors or returns an unparseable verdict is recorded
in failures and skipped — the median is over the survivors. If every juror
fails, or the jury is empty after exclusions, the judge job fails loudly. A
spread >= 2 sets disagreement: true — a contested verdict worth a human's
eyes. With apply_to_target, the median is written via via="judge-panel".
Quorum floor (min_panel_n, #21). By default a single surviving juror
still produces a verdict (resilience). If you'd rather a thinned-out jury be
rejected than write a low-confidence one-juror "median", set a floor — per
request via inputs.min_panel_n, or as a standing default on the judge rule
(min_panel_n in config/seed.routing.yaml /
PUT /routing; the request value wins). When fewer than min_panel_n jurors
score, the judge job fails loudly (naming the shortfall + failures) and
nothing is written back — the caller re-runs (a flaky juror usually parses on
retry). Unset, or <= 1, is a no-op (only a zero-survivor jury fails). The
verdict echoes min_panel_n so a quorum failure is self-describing.
| mode | write-back (on apply_to_target) |
lane |
|---|---|---|
| pointwise | target.quality_scores |
via="judge" |
| panel | target.quality_scores (the median) |
via="judge-panel" |
| pairwise | both participants' pairwise_verdicts |
win / loss records |
The quality_scores entries flow straight into the existing per-model rollup,
and all of it (scores + pairwise verdicts) is what the dataset export
turns into training data — removing the human-labelling bottleneck:
# DPO pairs from the pairwise judge's verdicts (or method=score for differentials)
curl -s "$CONDUCT/datasets/preferences?task_type=judge&method=pairwise" -H "Authorization: Bearer $ADMIN"
# SFT examples from high-scored responses (judge-sourced only)
curl -s "$CONDUCT/datasets/sft?min_score=4&via=judge&include_shadows=true" -H "Authorization: Bearer $ADMIN"Both stream JSONL ({prompt, system, chosen, rejected, meta} /
{prompt, system, completion, meta}) ready for TRL/unsloth. Admin-only — full
reference in datasets.md.
- Determinism — judge rules use the
deterministicsampling profile, so rulings are reproducible. (See the sampling profile.) - Sensitivity inheritance — a judge that resolved looser than its target fails before any model call, so a public judge can never send a confidential job's content to a cloud model. The panel applies this per juror.
- Position-bias defence — pairwise always order-swaps and requires agreement.
- Self-preference defence — the panel excludes the producer's family.
- No recursion — judges don't fan out eval shadows; don't give a judge rule cloud-shadow members you wouldn't want judged.
- Robust parsing — the verdict parser tolerates ```json fences + surrounding prose (a real failure mode on some local models) and rejects out-of-range or missing scores by failing the job, never recording a bogus number.
A judge rule ships in config/seed.routing.yaml:
- task_type: judge
preferred_model: gemma4:e4b # the single-model judge (pointwise/pairwise)
fallback_model: gemma4:e4b
sensitivity: internal # floor — keeps internal/confidential content off cloud
max_tokens: 400
sampling: deterministic
eval_shadow_models: # repurposed as the PANEL jury
- { model: llama3.2:3b, rate: 1.0 }
- { model: qwen3.5:4b, rate: 1.0 }Tips:
- Edit it live with
PUT /routing/judge(hot-reloads) or persist viamake seed. - For the panel, pick diverse families and ideally
RESIDENT_MODELS— a non-resident Ollama juror triggers a model swap per run. - The rubric is the judge task's prompt (
prompts/shared/judge.md); override per request withsystem_prompt, and it's versioned like any prompt so scores stay comparable across rubric changes.
- Judge jobs always run async (on the worker), never the sync API path.
- You can judge a Job or a JobShadow — shadows take their prompt + sensitivity from their parent.
- Build status: pointwise, pairwise, and panel all shipped (#17); the
/datasets/sft+/datasets/preferencesJSONL export is built (#16); and named multi-dimensional scores are supported end to end (#18).
{ "task_type": "judge", "prompt": "(judge)", "inputs": { "mode": "...", "target_job_id": "<uuid>", ... } }