AXLR / Catalog / education

rubric_grade

Score one student or a class against a weighted analytic rubric (criteria with weights; levels with points and descriptors), in exact fractions: per-criterion points and share, raw total, weighted percentage, an optional 0-to-N scale, a grade from bands given in the input, feedback lines built from the level descriptors, and a class summary. Scores may be level labels or points between levels.

Install

/tools install rubric_grade
/tools install rubric_grade@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · rubric_grade 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool rubric_grade; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
rubricobjectyes
studentsarray of objectnoitems ≥ 1; items ≤ 2000
scoresobject of ['string', 'number']noOne student's scores: criterion -> level label or points
bandsarray of objectno
scalenumber | stringnoAlso report on 0..scale, e.g. 10
decimalsintegerno2≥ 0; ≤ 10
Input schema (JSON)
{
  "type": "object",
  "required": [
    "rubric"
  ],
  "additionalProperties": false,
  "properties": {
    "rubric": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "criteria"
      ],
      "properties": {
        "title": {
          "type": "string"
        },
        "criteria": {
          "type": "array",
          "minItems": 1,
          "items": {
            "type": "object",
            "additionalProperties": false,
            "required": [
              "name",
              "levels"
            ],
            "properties": {
              "name": {
                "type": "string"
              },
              "weight": {
                "type": [
                  "number",
                  "string"
                ],
                "default": 1
              },
              "levels": {
                "type": "array",
                "minItems": 1,
                "items": {
                  "type": "object",
                  "additionalProperties": false,
                  "required": [
                    "points"
                  ],
                  "properties": {
                    "label": {
                      "type": "string"
                    },
                    "points": {
                      "type": [
                        "number",
                        "string"
                      ]
                    },
                    "descriptor": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "students": {
      "type": "array",
      "minItems": 1,
      "maxItems": 2000,
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "scores"
        ],
        "properties": {
          "student": {
            "type": "string"
          },
          "scores": {
            "type": "object",
            "additionalProperties": {
              "type": [
                "string",
                "number"
              ]
            }
          },
          "comment": {
            "type": "string"
          }
        }
      }
    },
    "scores": {
      "type": "object",
      "additionalProperties": {
        "type": [
          "string",
          "number"
        ]
      },
      "description": "One student's scores: criterion -> level label or points"
    },
    "bands": {
      "type": "array",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "min_percent",
          "grade"
        ],
        "properties": {
          "min_percent": {
            "type": [
              "number",
              "string"
            ]
          },
          "grade": {
            "type": "string"
          }
        }
      }
    },
    "scale": {
      "type": [
        "number",
        "string"
      ],
      "description": "Also report on 0..scale, e.g. 10"
    },
    "decimals": {
      "type": "integer",
      "minimum": 0,
      "maximum": 10,
      "default": 2
    }
  }
}

Example

Input

{
  "rubric": {
    "criteria": [
      {
        "name": "Content",
        "weight": 2,
        "levels": [
          {
            "label": "Good",
            "points": 3,
            "descriptor": "Minor gaps"
          },
          {
            "label": "Excellent",
            "points": 4
          }
        ]
      },
      {
        "name": "Language",
        "levels": [
          {
            "label": "Adequate",
            "points": 2,
            "descriptor": "Some slips"
          },
          {
            "label": "Strong",
            "points": 3
          }
        ]
      }
    ]
  },
  "scores": {
    "Content": "Good",
    "Language": "Adequate"
  }
}

Output (stdout)

{
  "results": [
    {
      "weighted_score": "13/6",
      "percentage": "72.22",
      "feedback": [
        "Content: Good (3/4) - Minor gaps",
        "Language: Adequate (2/3) - Some slips"
      ]
    }
  ]
}

Files

The command the install card registers is python3 rubric_grade.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

rubric_grade.py · sha256 73963eec2b9378d42f32927c1d85e36b68f431c5707eb1c8a352eda0a08fccf1
#!/usr/bin/env python3
"""Score students against a weighted analytic rubric, exactly, with grade bands and feedback
built from the level descriptors.

Input (JSON on stdin):
  rubric    {"criteria": [{"name": "Content", "weight": 2,
                           "levels": [{"label": "Excellent", "points": 4, "descriptor": "..."}, ...]}, ...]}
  students  [{"student": "Ana", "scores": {"Content": "Good", "Language": 2}, "comment": "..."}]
            (a score is a level label or a number of points from 0 to the criterion's maximum)
  scores    instead of students: one student's scores
  bands     optional [{"min_percent": 90, "grade": "A"}, ...]
  scale     optional number: also give the result on 0..scale (e.g. 10)
  decimals  optional, default 2

Criterion share = weight x points / max points of the criterion; percentage = sum of shares /
sum of weights x 100.
"""
import json
import re
import sys
from decimal import ROUND_HALF_UP, Decimal, localcontext
from fractions import Fraction
DECIMAL = re.compile(r"\s*[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d{1,3})?\s*")


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def number(value, field, allow_zero=True):
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number or a decimal string")
    if isinstance(value, str) and not DECIMAL.fullmatch(value):
        fail(f"{field} must be a number or a decimal string")
    try:
        result = Fraction(str(value).strip())
    except (ValueError, ZeroDivisionError):
        fail(f"{field} must be a number or a decimal string")
    if abs(result) > 10 ** 12:
        fail(f"{field} is too large")
    if result < 0 or (not allow_zero and result == 0):
        fail(f"{field} must be {'zero or more' if allow_zero else 'positive'}")
    return result


def text(value, places):
    with localcontext() as ctx:
        ctx.prec = 60
        d = Decimal(value.numerator) / Decimal(value.denominator)
        return str(d.quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP))


def frac(value):
    """Exact text: a terminating decimal when there is one (2.5), else the fraction (19/6)."""
    d = value.denominator
    for p in (2, 5):
        while d % p == 0:
            d //= p
    if d != 1:
        return str(value)
    with localcontext() as ctx:
        ctx.prec = 60
        return format((Decimal(value.numerator) / Decimal(value.denominator)).normalize(), "f")


def read_rubric(rubric):
    if not isinstance(rubric, dict) or set(rubric) - {"criteria", "title"} or not isinstance(rubric.get("criteria"), list):
        fail("rubric must be an object with a criteria list (and an optional title)")
    criteria = []
    names = set()
    for i, c in enumerate(rubric["criteria"]):
        if not isinstance(c, dict) or set(c) - {"name", "weight", "levels"}:
            fail(f"rubric.criteria[{i}] must be an object with name, weight and levels")
        name = c.get("name")
        if not isinstance(name, str) or not name.strip():
            fail(f"rubric.criteria[{i}].name must be a non-empty string")
        if name in names:
            fail(f"criterion {name!r} appears twice")
        names.add(name)
        weight = number(c.get("weight", 1), f"criteria {name!r} weight", allow_zero=False)
        levels = c.get("levels")
        if not isinstance(levels, list) or not levels:
            fail(f"criterion {name!r} needs a non-empty levels list")
        rows, labels = [], set()
        for j, lv in enumerate(levels):
            if not isinstance(lv, dict) or set(lv) - {"label", "points", "descriptor"} or "points" not in lv:
                fail(f"criterion {name!r} levels[{j}] must be an object with label, points and descriptor")
            label = lv.get("label", str(lv["points"]))
            if not isinstance(label, str) or label in labels:
                fail(f"criterion {name!r} has a missing or repeated level label")
            labels.add(label)
            descriptor = lv.get("descriptor", "")
            if not isinstance(descriptor, str):
                fail(f"criterion {name!r} level {label!r} descriptor must be a string")
            rows.append({"label": label, "points": number(lv["points"], f"criterion {name!r} level {label!r} points"),
                         "descriptor": descriptor})
        top = max(r["points"] for r in rows)
        if top == 0:
            fail(f"criterion {name!r} has no level with points above zero")
        criteria.append({"name": name, "weight": weight, "levels": rows, "max": top})
    if not criteria:
        fail("rubric.criteria must not be empty")
    return criteria


def read_bands(bands):
    if bands is None:
        return None
    if not isinstance(bands, list) or not bands:
        fail("bands must be a non-empty list of {min_percent, grade}")
    out = []
    for i, b in enumerate(bands):
        if not isinstance(b, dict) or set(b) != {"min_percent", "grade"} or not isinstance(b["grade"], str):
            fail(f"bands[{i}] must be {{min_percent, grade}}")
        out.append((number(b["min_percent"], f"bands[{i}].min_percent"), b["grade"]))
    return sorted(out, key=lambda b: b[0], reverse=True)


def grade(student, criteria, bands, scale, places):
    scores = student["scores"]
    if not isinstance(scores, dict):
        fail(f"scores of {student['student']!r} must be an object of criterion -> level label or points")
    unknown = sorted(set(scores) - {c["name"] for c in criteria})
    if unknown:
        fail(f"unknown criterion in scores of {student['student']!r}: {', '.join(unknown)}")
    rows, shares, total_w, raw, raw_max = [], Fraction(0), Fraction(0), Fraction(0), Fraction(0)
    feedback = []
    for c in criteria:
        if c["name"] not in scores:
            fail(f"{student['student']!r} has no score for {c['name']!r}")
        given = scores[c["name"]]
        level = None
        if isinstance(given, str) and any(lv["label"] == given for lv in c["levels"]):
            level = next(lv for lv in c["levels"] if lv["label"] == given)
            points = level["points"]
        else:
            points = number(given, f"score {c['name']!r} of {student['student']!r} (a level label or points)")
            if points > c["max"]:
                fail(f"score {c['name']!r} of {student['student']!r} exceeds the maximum {frac(c['max'])}")
            below = [lv for lv in c["levels"] if lv["points"] <= points]
            level = max(below, key=lambda lv: lv["points"]) if below else min(c["levels"], key=lambda lv: lv["points"])
        share = c["weight"] * points / c["max"]
        shares += share
        total_w += c["weight"]
        raw += points
        raw_max += c["max"]
        exact_level = level["points"] == points
        rows.append({"criterion": c["name"], "weight": frac(c["weight"]), "level": level["label"],
                     "points": frac(points), "max_points": frac(c["max"]), "share": str(share),
                     "percent": text(points / c["max"] * 100, places), "between_levels": not exact_level})
        line = f"{c['name']}: {level['label']} ({frac(points)}/{frac(c['max'])})"
        if level["descriptor"]:
            line += f" - {level['descriptor']}"
        if not exact_level:
            line += " [points between levels; descriptor of the level below]"
        feedback.append(line)
    percent = shares / total_w * 100
    out = {"student": student["student"], "criteria": rows, "raw_points": frac(raw), "raw_max": frac(raw_max),
           "weighted_score": str(shares), "weighted_max": frac(total_w), "percentage": text(percent, places),
           "percentage_exact": str(percent)}
    if scale is not None:
        out["score_on_scale"] = text(percent * scale / 100, places)
    if bands is not None:
        out["grade"] = next((g for low, g in bands if percent >= low), None)
    out["feedback"] = feedback
    if student.get("comment"):
        out["comment"] = student["comment"]
    out["_percent"] = percent
    return out


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = sorted(set(request) - {"rubric", "students", "scores", "bands", "scale", "decimals"})
    if extra:
        fail(f"unknown field(s): {', '.join(extra)}")
    criteria = read_rubric(request.get("rubric"))
    bands = read_bands(request.get("bands"))
    scale = number(request["scale"], "scale", allow_zero=False) if request.get("scale") is not None else None
    places = request.get("decimals", 2)
    if isinstance(places, bool) or not isinstance(places, int) or not 0 <= places <= 10:
        fail("decimals must be an integer from 0 to 10")
    if "students" in request and "scores" in request:
        fail("give students or scores, not both")
    if "students" in request:
        students = request["students"]
        if not isinstance(students, list) or not students or len(students) > 2000:
            fail("students must be a list of 1 to 2000 {student, scores}")
        for i, s in enumerate(students):
            if not isinstance(s, dict) or set(s) - {"student", "scores", "comment"} or "scores" not in s:
                fail(f"students[{i}] must be an object with student, scores and an optional comment")
            s.setdefault("student", str(i + 1))
            if s.get("comment") is not None and not isinstance(s["comment"], str):
                fail(f"students[{i}].comment must be a string")
    elif "scores" in request:
        students = [{"student": "1", "scores": request["scores"]}]
    else:
        fail("give students (a list) or scores (one student)")
    results = [grade(s, criteria, bands, scale, places) for s in students]
    percents = [r.pop("_percent") for r in results]
    out = {"criteria": [{"name": c["name"], "weight": frac(c["weight"]), "max_points": frac(c["max"])} for c in criteria],
           "results": results}
    if len(results) > 1:
        summary = {"students": len(results), "mean_percentage": text(sum(percents) / len(percents), places),
                   "min_percentage": text(min(percents), places), "max_percentage": text(max(percents), places)}
        if bands is not None:
            summary["grades"] = {g: sum(r.get("grade") == g for r in results) for _, g in sorted(bands, key=lambda b: -b[0])}
        out["summary"] = summary
    print(json.dumps(out, ensure_ascii=False))


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as error:  # last resort: a usage error, never a traceback
        fail(f"invalid input ({type(error).__name__})")

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/rubric_grade/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/rubric_grade/1.0.0/rubric_grade.py