Grading short answers with a rubric

library(decisions)

Overview

A rubric lists the ideas that a good answer has. A decision model can check each idea with a true-or-false question. You can also list the wrong claims that students often make, and check them in the same way.

This vignette checks short answers to one question about this package:

What does decide() do when one call of a batch fails?

A good answer has four ideas:

Two wrong claims are common:

We make one question_noul() for each idea and for each wrong claim. All six questions go into one call of decide(), so a student answer costs one call.

The results below come from live calls. The package saved them in the package folder extdata, so this vignette builds without a key and without a network. The code that made them is in vignette_results.R in the folder data-raw of the GitHub repository.

Rubric

The instructions of each question refer to the text, which is the answer of the student.

rubric <- list(
  keeps_row = question_noul("Does the text say that the failed input keeps its row in the output?"),
  na_answers = question_noul("Does the text say that the answers of the failed row are NA?"),
  error_text = question_noul("Does the text say that the reason of the failure is in the .error column?"),
  others_continue = question_noul("Does the text say that the other inputs of the batch still get their answers?"),
  stops_all = question_noul("Does the text say that decide() stops with an error, or returns nothing, when one call fails?"),
  drops_row = question_noul("Does the text say that decide() removes the failed input from the output?")
)

The student sees only words of the rubric. For each question, we write what the student sees when the probability is above the threshold of the question (if_above), and when it is below (if_below). NA means that the student sees nothing.

rubric_text <- tibble::tribble(
  ~question, ~kind, ~if_above, ~if_below,
  "keeps_row", "idea", "Covered: the failed input keeps its row.", "Missing: say what happens to the row of the failed input.",
  "na_answers", "idea", "Covered: the answers of the failed row are NA.", "Missing: say what the answers of the failed row are.",
  "error_text", "idea", "Covered: the reason is in the .error column.", "Missing: say where to find the reason of the failure.",
  "others_continue", "idea", "Covered: the other inputs still get their answers.", "Missing: say what happens to the other inputs.",
  "stops_all", "wrong", "Check again: one failed call does not stop decide().", NA_character_,
  "drops_row", "wrong", "Check again: decide() does not remove the row of a failed call.", NA_character_
)

Sample answers

We wrote 20 sample answers for this vignette. The package has them as the dataset rubric_answers, with the labels. Some answers are good, some have wrong claims, and some are vague. For each answer, we also wrote a label for each question: TRUE if the answer says it, FALSE if not.

samples <- rubric_answers
samples[1:3, ]
#> # A tibble: 3 × 8
#>   answer_id answer               keeps_row na_answers error_text others_continue stops_all drops_row
#>       <int> <chr>                <lgl>     <lgl>      <lgl>      <lgl>           <lgl>     <lgl>    
#> 1         1 decide() keeps the … TRUE      TRUE       TRUE       TRUE            FALSE     FALSE    
#> 2         2 The failed input st… TRUE      TRUE       TRUE       FALSE           FALSE     FALSE    
#> 3         3 It stops and throws… FALSE     FALSE      FALSE      FALSE           TRUE      FALSE

Setup

decision_setup() holds the decider and the six questions. We use Jev. It reads the key from TYPESAFE_API_KEY. The saved results below come from the model jev-1.14.0, which the default "jev-latest" named on 2026-10-23. In real grading, give the model a fixed version (for example decider_typesafe("jev-1.14.0")), so that the answers do not change when the default model changes.

setup <- decision_setup(decider_typesafe(), rubric)
#> Using model = "jev-latest".

decide() takes the setup in place of the decider and the questions. The answers are the only input.

scored <- decide(setup, text = samples$answer)

Each question gives its own columns. The column keeps_row_.pred_true is the probability that the answer says it.

dplyr::select(scored, dplyr::ends_with("_.pred_true"), .error)
#> # A tibble: 20 × 7
#>    keeps_row_.pred_true na_answers_.pred_true error_text_.pred_true others_continue_.pred_true
#>                   <dbl>                 <dbl>                 <dbl>                      <dbl>
#>  1                 0.93                  0.99                  0.98                       0.97
#>  2                 0.96                  0.98                  0.94                       0.18
#>  3                 0.03                  0.04                  0.05                       0.05
#>  4                 0.03                  0.06                  0.04                       0.13
#>  5                 0.56                  0.8                   0.09                       0.76
#>  6                 0.7                   0.09                  0.45                       0.69
#>  7                 0.04                  0.03                  0.03                       0.06
#>  8                 0.06                  0.06                  0.03                       0.03
#>  9                 0.97                  0.95                  0.96                       0.28
#> 10                 0.54                  0.97                  0.04                       0.71
#> 11                 0.02                  0.07                  0.03                       0.44
#> 12                 0.97                  0.05                  0.33                       0.55
#> 13                 0.06                  0.04                  0.03                       0.1 
#> 14                 0.1                   0.07                  0.04                       0.81
#> 15                 0.5                   0.98                  0.98                       0.12
#> 16                 0.65                  0.83                  0.03                       0.95
#> 17                 0.05                  0.06                  0.09                       0.06
#> 18                 0.92                  0.98                  0.88                       0.91
#> 19                 0.15                  0.07                  0.04                       0.19
#> 20                 0.57                  0.07                  0.19                       0.16
#> # ℹ 3 more variables: stops_all_.pred_true <dbl>, drops_row_.pred_true <dbl>, .error <chr>

Thresholds

A probability is not a grade. For each question, we choose a threshold: the answer counts as “says it” when the probability is at or above the threshold. For each question, yardstick::roc_curve() gives the sensitivity and the specificity at each threshold. We take the thresholds that maximize sensitivity + specificity - 1, and then the median of them.

thresholds <- purrr::map(names(rubric), \(question) {
  tibble::tibble(
    truth = factor(samples[[question]], levels = c(TRUE, FALSE)),
    probability = scored[[paste0(question, "_.pred_true")]]
  ) |>
    yardstick::roc_curve(truth, probability) |>
    dplyr::mutate(j = sensitivity + specificity - 1) |>
    dplyr::filter(dplyr::near(j, max(j)), is.finite(.threshold)) |>
    dplyr::summarise(threshold = median(.threshold), j = max(j))
}) |>
  purrr::set_names(names(rubric)) |>
  dplyr::bind_rows(.id = "question")

thresholds
#> # A tibble: 6 × 3
#>   question        threshold     j
#>   <chr>               <dbl> <dbl>
#> 1 keeps_row            0.5  0.75 
#> 2 na_answers           0.8  1    
#> 3 error_text           0.19 1    
#> 4 others_continue      0.69 0.875
#> 5 stops_all            0.32 1    
#> 6 drops_row            0.36 1

Twenty answers are few, and we set the thresholds on the same answers that we use to check them. So the thresholds are rough. Use more labeled answers for a real course, and keep some of them to check the thresholds.

We join the thresholds to the text of the rubric.

feedback_rubric <- rubric_text |>
  dplyr::left_join(thresholds[c("question", "threshold")], by = "question")

feedback_rubric
#> # A tibble: 6 × 5
#>   question        kind  if_above                                                  if_below threshold
#>   <chr>           <chr> <chr>                                                     <chr>        <dbl>
#> 1 keeps_row       idea  Covered: the failed input keeps its row.                  Missing…      0.5 
#> 2 na_answers      idea  Covered: the answers of the failed row are NA.            Missing…      0.8 
#> 3 error_text      idea  Covered: the reason is in the .error column.              Missing…      0.19
#> 4 others_continue idea  Covered: the other inputs still get their answers.        Missing…      0.69
#> 5 stops_all       wrong Check again: one failed call does not stop decide().      <NA>          0.32
#> 6 drops_row       wrong Check again: decide() does not remove the row of a faile… <NA>          0.36

Feedback

give_feedback() takes the output of decide() for one answer and the rubric. It returns the lines that the student sees. It never shows a probability. When the call failed, it says so and shows no provider text.

give_feedback <- function(scored, rubric) {
  if (!is.na(scored$.error)) {
    return("The check did not work. Try again.")
  }
  probability <- purrr::map_dbl(rubric$question, \(question) scored[[paste0(question, "_.pred_true")]])
  lines <- ifelse(probability >= rubric$threshold, rubric$if_above, rubric$if_below)
  lines[!is.na(lines)]
}

Here are three new answers.

new_answers <- c(
  "The row of the failed call stays in the output. Its answers are NA, and .error has the reason.",
  "decide() stops with an error at the first failed call, and you get nothing back.",
  "It keeps going with the other inputs."
)

new_scored <- decide(setup, text = new_answers)

purrr::map(seq_along(new_answers), \(i) give_feedback(new_scored[i, ], feedback_rubric))
#> [[1]]
#> [1] "Covered: the failed input keeps its row."      
#> [2] "Covered: the answers of the failed row are NA."
#> [3] "Covered: the reason is in the .error column."  
#> [4] "Missing: say what happens to the other inputs."
#> 
#> [[2]]
#> [1] "Missing: say what happens to the row of the failed input."
#> [2] "Missing: say what the answers of the failed row are."     
#> [3] "Missing: say where to find the reason of the failure."    
#> [4] "Missing: say what happens to the other inputs."           
#> [5] "Check again: one failed call does not stop decide()."     
#> 
#> [[3]]
#> [1] "Missing: say what happens to the row of the failed input."
#> [2] "Missing: say what the answers of the failed row are."     
#> [3] "Missing: say where to find the reason of the failure."    
#> [4] "Missing: say what happens to the other inputs."

Setup file

write_setup() saves the decider and the questions as one JSON file. The file holds no key.

write_setup(setup, "rubric_setup.json")

The thresholds and the text of the rubric are not part of the setup. Save them as well.

saveRDS(feedback_rubric, "rubric.rds")

Shiny app

The app below lets a student type an answer and press a button. It shows the rubric text. It uses shiny, which the package does not need. Save give_feedback() in the file R/feedback.R of the app folder. Shiny reads that folder by itself. Put rubric_setup.json and rubric.rds in the app folder too. The app needs R 4.3 or newer, because it uses @ on S7 objects, and the purrr package, because give_feedback() uses it.

read_setup() takes a credentials function. The function runs when decide() sends the requests. So the host of the app gives the key. On Posit Connect, for example, you set the variable TYPESAFE_API_KEY in the settings of the content. The key is a secret of the host. It is never a field of the app and never in the file.

library(shiny)
library(decisions)

rubric <- readRDS("rubric.rds")

ui <- fluidPage(
  titlePanel("Check your answer"),
  p("What does decide() do when one call of a batch fails?"),
  textAreaInput("answer", "Your answer", width = "100%", rows = 6),
  actionButton("check", "Check"),
  textOutput("provider"),
  uiOutput("feedback")
)

server <- function(input, output, session) {
  # Replace the function with the key source of your host
  setup <- read_setup("rubric_setup.json", credentials = \() Sys.getenv("TYPESAFE_API_KEY"))

  output$provider <- renderText(
    paste0("Your answer goes to ", setup@decider@provider, " (", setup@decider@base_url, ").")
  )

  output$feedback <- renderUI({
    req(nzchar(input$answer))
    scored <- decide(setup, text = input$answer)
    tags$ul(lapply(give_feedback(scored, rubric), tags$li))
  }) |>
    bindEvent(input$check)
}

shinyApp(ui, server)

Privacy

The answer of the student goes to the provider that the decider names. With Jev, that is TypeSafe. The app shows the provider, so the student knows. Read the terms of the provider before you send student work to it. To keep the answers on your computer, use a local model. Set the setup to decider_systemone() with a Laya server, as the vignette “Open models on your own computer” shows. Then the answers never leave the computer.

Credits

mirror server hosted at Truenetwork, Russian Federation.