At a glance · three principles
A rubric in Scriptorium is a single JSON object — but it carries a strong contract behind that JSON. Every rubric the system grades against, however it was authored, must satisfy three principles. The rest of this page is the contract behind those three lines.
- Two tiers Every rubric is trait (main criterion) → sub-criterion. Three or more tiers explode combinatorially; one is too coarse to grade.
- Cumulative levels L1 is the floor. Every level above L1 must explicitly contain everything the previous level required, plus one observable increment. A paper that earns L4 has, by definition, already satisfied L3, L2, and L1.
- JSON, distributive A rubric is a single JSON object. Its traits are independent — any trait can be scored on a paper without reference to any other trait's score. This is what makes parallel AI grading possible.
A "rubric" here is the artifact instructors and reviewers author and apply to a submitted paper, essay, code artifact, or peer-review response. It is not the AES scoring model, the calibration round, or the cohort report — those are downstream consumers.
The schema
The shape has three layers: the persistence envelope (id, ownership, status, validation report), the gradable core (traits, sub-criteria, levels, descriptors), and the optional metadata (framework, course, references, anchor samples). The smallest object the grader will operate on is below — everything else adds audit, governance, and validation context, but does not change the gradable core.
The minimum gradable shape
{ "rid": "r_a1b2c3d4e5f6", "title": "Argumentative Essay — Disciplinary Reasoning", "totalPoints": 100, "weight": 100, "levels": { "count": 5, "labels": ["Insufficient", "Developing", "Adequate", "Proficient", "Distinctive"] }, "traits": [ { "id": "thesis", "name": "Thesis & Position", "description": "Clarity, specificity, and arguability of the central claim.", "weight": 20, "subCriteria": [ { "question": "Is the central claim explicit, specific, and arguable?", "weight": 0.45, "description": "An evaluator looks for one sentence the reader could disagree with…", "descriptorPriors": { "L1": "No identifiable thesis…", "L2": "A claim is present but generic…", "L3": "In addition to L2's claim, the claim is locatable in one sentence, names a specific aspect, and is arguable.", "L4": "In addition to L3's specific claim, the claim distinguishes itself from rivals.", "L5": "In addition to L4's distinction, the claim identifies a non-obvious tension whose resolution drives the essay." } } ] } ], "descriptorPriors": { "thesis": { "L1": "…", "L2": "…", "L3": "…", "L4": "…", "L5": "…" } } }
Sub-criterion question is always a yes-question — never a noun phrase, never a statement. This forces the grader (or AI) to commit to an answer. description names the test the evaluator applies. descriptorPriors is the per-level anchor; the cumulative rule lives here.
Two layers of descriptors
Both traits and sub-criteria carry their own per-level descriptors. They serve different scopes.
The holistic anchor
Read from rubric.descriptorPriors[traitId]. Answers: what does L3 of "evidence quality" look like as a whole?
This is the canonical client-visible location. The grader UI falls back to trait.descriptorPriors and curated trait.descriptors only when the top-level map is missing.
The diagnostic anchor
Read from trait.subCriteria[i].descriptorPriors. Scoped to one specific judgment: what does L3 of "are the sources credible?" look like?
When a grader scores at the sub-criterion level (the default in GradePanel), the trait-level descriptor is the reference the rationale should triangulate toward.
Distributive evaluation
The pipeline fires one LLM call per trait, in parallel. The holistic score is computed after per-trait scores by weighted average — it does not feed back. So a trait descriptor may reference the paper, the genre, the assignment context, or what other writers in this form do; it may not reference another trait's score. This is a hard requirement, not a preference.
A rubric is complete enough to grade with when it has ≥ 3 traits each with id / name / description / weight; 2–6 sub-criteria per trait, each with question / weight / descriptors; trait-level descriptors at the canonical location; rubric.levels declared; and sub-criterion weights within each trait summing to 1.0 ± 0.02. Errors block validation; weight-imbalance warnings auto-normalize. The single source of truth for the check is server/lib/rubricCompleteness.js.
Field reference
Top-level fields
| Field | Type | Req | Purpose |
|---|---|---|---|
rid | string | ✓ | Stable id, format r_<12-hex>. Never reused. |
title | string | ✓ | Human-readable name. |
ownerId | string | ✓ | The author's user.uid. |
tenantId | string | ✓ | The tenant the rubric is filed under. |
status | enum | ✓ | draft · validated · production-validated · hs-validated · published · archived |
visibility | enum | ✓ | private · tenant-public · curated. Orthogonal to status. |
schemaVersion | string | ✓ | Pinned (default "1.2"). |
genre | string | opt | Genre id from the 117-genre catalog. |
construct | object | opt | The construct-alignment declaration: name · definition · framework · subConstructs[]. |
traits | array | ✓ | The list of scoring traits. |
levels | object | ✓ | { count, labels[] }. Default is 5 levels with the canonical labels. |
descriptorPriors | object | ✓ | { [traitId]: { L1..Ln } } — canonical client-visible location. |
weight | number | ✓ | Share of the assignment grade (default 100). |
constructTargets | object | opt | { [tag]: weight } — protects against surface-fluency reward. |
defaultAnchors | array | opt | Anchor seed from the genre template. |
anchorSamples | array | opt | Promoted at /validate/save: [{ level, text, score, provenance }]. |
validation | object | opt | Saved validation report. null until validated. |
createdAt · updatedAt | ISO date | ✓ | Server-stamped. |
Trait fields · rubric.traits[i]
| Field | Type | Req | Purpose |
|---|---|---|---|
id | string | ✓ | Kebab-case, stable. Used by traitScores[id] and descriptorPriors[id]. |
name | string | ✓ | Display label — noun phrase ("Evidence & Sources"). |
description | string | ✓ | One-paragraph anchor: what the evaluator looks for at the trait scope. Not the level descriptor. |
weight | number | ✓ | Share of the holistic score (1–100). Auto-normalized at grading time. |
subCriteria | array | ✓ | 2–6 sub-criteria. Not optional — a trait with zero subs is rejected. |
tags · constructTags | string[] | opt | Construct labels for analytics and IRR. |
anchorSensitive | boolean | opt | Whether per-paper anchors materially shift scoring. |
exemplarRefs | object | opt | { L<n>: ["exemplars/<trait>/L<n>/<id>.json"] }. |
Sub-criterion fields · trait.subCriteria[i]
| Field | Type | Req | Purpose |
|---|---|---|---|
question | string | ✓ | A yes-question the grader answers. Not a noun phrase, not a statement. |
weight | number | ✓ | Share of the trait's score, in [0.0, 1.0]. Sub-weights within a trait must sum to 1.0 ± 0.02. |
description | string | rec | 1–2 sentence anchor: the test the grader applies. Warning, not error, if missing. |
descriptorPriors | object | ✓ | { L1..Ln } per-level descriptors scoped to this judgment. |
A sub-criterion with weight: 0 whose question starts with L<n>: is rejected outright as sub_leaked_descriptor — it is a known data-corruption shape from older imports where a level descriptor leaked into the sub-criterion array. Delete the sub; do not try to score it.
Lifecycle & transitions
A rubric moves through a small set of states, each with different visibility and editability. The only path from draft to a gradable status runs through the validation wizard.
State definitions
| State | Visibility | Editable | Gradable | Set by |
|---|---|---|---|---|
draft | owner only | owner | no | createRubric() default |
validated | tenant (per visibility) | owner (limited) | yes | Core gates pass |
production-validated | tenant | owner (limited) | yes | Production gates pass |
hs-validated | tenant | nobody | yes | High-stakes gates pass |
published | tenant / curated | nobody | yes | admin promotion |
archived | role-gated | nobody | no — past scorings remain valid | owner / admin |
READABLE_STATUSES in server/lib/rubrics.js is the allow-list: validated · production-validated · hs-validated · published. A rubric outside this set is owner-private regardless of visibility.
Storage
Per CLAUDE.md, durable rubric state lives in GCS keyed by tenant. Per-tenant rubrics under tenants/{tid}/rubrics/; cross-tenant reference data (genre templates, the curated library) under the _db/ prefix.
gs://{bucket}/
├─ tenants/{tid}/rubrics/
│ ├─ {rid}.json // canonical rubric
│ ├─ {rid}/draft-validation.json // in-flight validation work
│ └─ {rid}/exemplars/{trait}/L{level}/{sub_id}.json // anchored exemplars
│
├─ _db/genre_rubrics/ // 117-genre template library
│ ├─ {id}.json // HEAD
│ └─ {id}/v{templateVersion}.json // immutable snapshots
│
└─ _db/rubric_library/ // curated cross-tenant rubrics
└─ r-{n}.json
Validation tiers
The validation wizard walks a rubric through a tiered set of quality gates and promotes it to one of three validated statuses. Tiering is FDA-analogous — regulatory burden scales with consequence. Forcing every author through high-stakes rigor would deter use; allowing every author to ship at Core would harm students.
Formative
Formative feedback, exploratory authoring, low-stakes practice.
- Gates run
- Structural · Independence · Discrimination · Test-Retest · Sensitivity
- Typical cost
- ~80 LLM calls
- Promotes to
validated
Routine deployment
Routine deployment in courses, dashboards, large-N feedback.
- Gates run
- Core + Perturbation Fidelity + Cross-Rater + Anchor Provenance
- Typical cost
- ~140 LLM calls
- Promotes to
production-validated
Consequential
Grading, admissions, certification, anything consequential.
- Gates run
- Production + Fairness/DIF + human-rated anchors required
- Typical cost
- ~220 calls + ≥ 6 human-rated anchors
- Promotes to
hs-validated
A validated (Core) rubric is not ready for high-stakes use. The wizard's green checkmark does not imply otherwise — the Report Card surfaces tier prominently, and the Library badges show tier alongside status. Tier upgrades require re-running the additional gates; you cannot promote by editing the status field.
The gates
Each gate answers one psychometric question. Pure-function gates run on cached scores (no LLM cost, idempotent); LLM-gates fire calls. Filled dots show which tiers run each gate.
| # | Gate | Question it answers | Core · Prod · HS | LLM cost |
|---|---|---|---|---|
| 1 | Structural Integrityv1: Schema | Is the rubric structurally well-formed? | 0 (pure) | |
| 2 | Trait-Descriptor Disjointnessv1: Independence | Do trait descriptors describe orthogonal axes? | 1 | |
| 3 | Known-Groups Discriminationv1: Discriminability | Do good/ord/bad anchors separate and use the scale's range? | 18 | |
| 4 | Intra-Rater Test-Retest Reliabilityv1: Robustness | Does the same rater produce the same scores on re-runs? | 18 | |
| 5 | Causal Sensitivity to Targeted Editsv1: Sensitivity | Do targeted perturbations move the targeted trait's score — and only that one? | 42 | |
| 5a | Perturbation Fidelity Auditnew in v2 | Do the generated perturbations actually damage the trait they were meant to? | ~12 | |
| 6 | Cross-Rater Agreementnew in v2 | Do two different raters (LLMs or human) agree on the same papers? | ~30 | |
| 7 | Anchor Provenancenew in v2 | Where do the anchor labels come from — and is the source defensible for the stakes? | 0 | |
| 8 | Fairness / DIFnew in v2 | Does the rubric score equivalent-content papers differently based on surface markers? | ~30 | |
| CI | Negative-Control Sanitybuild-time fixture | Does the pipeline fail when given a deliberately broken rubric? | — |
The Anchor Provenance gate requires LLM-generated anchors at Production tier (with cross-model separation to avoid circularity), but requires human-rated anchors with κ ≥ 0.61 at High-stakes. The CI gate is a fixture that runs against tests/fixtures/broken-rubric.json and asserts at least 3 of 5 Core gates fail — it catches gate-leniency regressions over time.
What the headline gates actually do
- Structural Integrity
- The completeness contract from §2 — missing trait id, no levels, sub weights don't sum to 1.0, leaked-descriptor subs. Zero LLM calls.
- Disjointness
- One LLM call returns a pairwise overlap matrix across trait descriptors; pairs above
independenceThresholdare flagged. Measures overlap among the rubric text, not among scored data. - Discrimination
- Score three anchors (good · ord · bad) × N traits, then check both gap (adjacent levels separate) and range coverage (good ≥ 4.0, bad ≤ 2.0 on a 1–5 scale). Reports point estimate ± SE and 95% CI per trait.
- Test-Retest
- Score one sample R times with the same rater; compute per-trait SD; gate on threshold. Reports per-trait ICC(2,1) with 95% CI — informational at Core, gating at High-stakes (
robustnessICCMin = 0.75). - Sensitivity
- For each (sample, trait), generate a perturbation that damages that trait specifically; rescore; verify the targeted drop, that other traits don't drift, and that neutral edits don't move scores.
- Cross-Rater
- Re-score with a second rater (default Anthropic + OpenAI pairing). Compute per-trait ICC and Krippendorff's α with ordinal weights. Default pass thresholds: ICC ≥ 0.60, α ≥ 0.667.
- Fairness / DIF
- Generate matched-content variants differing only in surface markers (L2 phrasing, register shifts, dialectal markers); rescore; check mean drift per (variant family × trait) is below threshold. A lite DIF check — real DIF on cohort data is a separate workstream.
Wizard & architecture
The validation wizard is a tier-dependent flow (7 steps at Core, 9 at Production, 11 at High-stakes) opened from Rubric Studio. The architecture is forced by one binding constraint: codespace and reverse-proxy gateways enforce a 30–60 second per-request timeout, and Claude Opus on a long writing routinely takes 15–45 s per scoring call. Three crosscutting design decisions follow.
One LLM call, one HTTP request
Every step that does serious LLM work is split into per-item calls driven sequentially from the client — per (sample, trait), per (repeat, trait), per perturbation job. Each call lands in < 30 s. Retries fire only failed items; successful items are durable. Non-negotiable — do not reintroduce a bulk endpoint.
Closed tab loses nothing
Per-trait reviews are LLM-expensive. They land at tenants/{tid}/rubrics/{rid}/draft-validation.json as they complete. The fingerprint covers rubric content, scoring model id + version, prompt hash, and tier; any change invalidates the draft on hydration. Catches model bumps, not just rubric edits.
Recompute without re-calling
Gate verdicts (assess*) are pure functions of cached data, served at dedicated */assess endpoints — zero LLM cost, idempotent. The wizard can recompute a gate without re-firing scoring calls. All v2 gates follow the same pattern.
Psychometrics is library code
ICC, Krippendorff's α, t-CIs, Mantel-Haenszel-style DIF — all live in server/lib/psychometrics.js as pure functions, dependency-light, testable. A research mode can run them on archived score data without touching the LLM stack.
Wizard step rail
Core Production High-stakes 1 · Configure ✓ ✓ ✓ // tier, model, thresholds 2 · Independence ✓ ✓ ✓ // pairwise overlap matrix 3 · Anchor Provenance — ✓ ✓ // LLM gen / upload / hybrid 4 · Samples ✓ ✓ ✓ // generate or load anchors 5 · Discrimination ✓ ✓ ✓ // score baseline + range coverage 6 · Test-Retest Reliability ✓ ✓ ✓ // R-repeat scoring + ICC 7 · Cross-Rater Agreement — ✓ ✓ // re-score with alt rater 8 · Causal Sensitivity ✓ ✓ ✓ // perturb + score 9 · Perturbation Fidelity — ✓ ✓ // audit subsample 10 · Fairness / DIF — — ✓ // matched-content variants 11 · Save ✓ ✓ ✓ // persist report; promote status
Past steps are revisitable (rail shows ✓); forward navigation without a gate pass is blocked. Tier downgrade at save is silent (a Production-attempt that only completed Core gates saves at Core); tier upgrade reopens the wizard and runs only the new gates against the previously-saved data.
Reporting & freshness
Every saved validation produces a Report Card surfaced in Rubric Studio and the Library. It is the artifact downstream consumers (Calibration Studio, the Bias & Fairness Audit, Cohort Analytics) actually read. The Report Card always carries:
- The tier badge — Core, Production-validated, or HS-validated.
- The model + version stamp — e.g.
scoring=anthropic:claude-opus-4-7@2026-04-15. - The prompt hash —
sha256:9c3f…— captured at run time. - A gate-by-gate verdict table with ✓ / ✗ / N/A by tier.
- Per-trait statistics: mean, SE, 95% CI, ICC, Krippendorff's α.
- The anchor provenance summary, with rater metadata if human.
- Failure diagnoses + suggested edits (if any gate fails).
- A disclosures block — always shown — listing what this validation does not cover (content validity, criterion validity, real-cohort DIF, calibration to absolute scale, longitudinal model drift).
The freshness indicator
A validated rubric is not fresh forever. Anthropic, OpenAI, and Google all rev models; yesterday's validation does not hold for free. The Library shows a freshness state on every validated rubric:
| State | Trigger | Action |
|---|---|---|
| Fresh | All version fields match current production model + prompt | None |
| Stale-model | Scoring model upgraded since last validation | Soft warning; "Re-validate" CTA |
| Stale-prompt | System prompt changed | Soft warning |
| Stale-rubric | Rubric edited since last validation | Hard warning; status reverts to draft |
The silent-failure mode this catches is the one where a validated rubric quietly becomes invalid because the underlying scoring model shipped a new version overnight. The fingerprint covers scoringModelVersion and promptHash — when either drifts, the rubric is flagged as stale and authors are given a one-click path to re-validate. The gradable core itself is invalidated entirely on edit; that one is a hard revert to draft.