decisions / R /code.R
fmegahed's picture
Deploy 71faef1 from GitHub
39181ba verified
Raw History Blame Contribute Delete
11.3 kB
# R code of a run: the text that the visitor copies, downloads or finds in the
# bundle. It uses only functions of decisions and base R (dplyr only for the
# subset of the injuries sample). Every string of the visitor goes through
# encodeString(), so quotes, backticks and new lines stay valid R.
# An R string with double quotes
r_string <- function(x) encodeString(x, quote = '"')
# A name for the left side of `name = value`: bare when R allows it, else quoted
r_name <- function(x) ifelse(make.names(x) == x, x, r_string(x))
# Folder names for the answers: a name with one of `/ \ : * ? " < > |`, a
# control character or a leading dot is cleaned, and the clean names stay
# unique. The names of the result are the answers.
answer_folders <- function(answers) {
folder <- answers |>
stringr::str_replace_all('[/\\\\:*?"<>|[:cntrl:]]', "_") |>
stringr::str_remove("^\\.+") |>
stringr::str_squish()
folder[folder == ""] <- "answer"
stats::setNames(make.unique(folder, sep = "_"), answers)
}
# The reference files of a spec: one row for each file, with its answer, the
# folder of the answer, the source path and the file name in the folder.
# A score question has none (Jev attaches references to noul and choice only).
reference_files <- function(spec) {
empty <- tibble::tibble(
answer = character(), folder = character(), path = character(), file = character()
)
if (is.null(spec$references) || spec$question$type == "score") return(empty)
folders <- answer_folders(names(spec$question$answers))
purrr::imap(spec$references, \(paths, answer) {
file <- basename(paths)
twice <- duplicated(file) | duplicated(file, fromLast = TRUE)
file[twice] <- paste0(seq_along(file)[twice], "_", file[twice])
tibble::tibble(answer = answer, folder = folders[[answer]], path = paths, file = file)
}) |>
purrr::list_rbind()
}
# The name of a run for file names: "my parts" gives "my_parts"
run_name <- function(spec) {
name <- janitor::make_clean_names(spec$name, case = "snake")
if (name == "") "run" else name
}
# The variable that holds the question in the code
question_var <- function(spec) {
switch(spec$source, textiles = "weave", injuries = "injury", "question")
}
# Strings in lines of at most `width` characters, with a comma after each
# string but the last. Used for long lists of files.
pack_lines <- function(items, indent = " ", width = 80) {
lines <- character()
line <- ""
for (i in seq_along(items)) {
piece <- if (i < length(items)) paste0(items[i], ",") else items[i]
if (nzchar(line) && nchar(indent) + nchar(line) + 1 + nchar(piece) > width) {
lines <- c(lines, paste0(indent, line))
line <- piece
} else {
line <- stringr::str_trim(paste(line, piece))
}
}
c(lines, paste0(indent, line))
}
# c("a", "b") as code: on one line when short, else in packed lines
r_vector <- function(strings, indent = "") {
one <- paste0("c(", paste(r_string(strings), collapse = ", "), ")")
if (nchar(indent) + nchar(one) <= 80) return(one)
lines <- pack_lines(r_string(strings), paste0(indent, " "))
paste0("c(\n", paste(lines, collapse = "\n"), "\n", indent, ")")
}
# The six lines of the comment on paths
paths_comment <- function(spec, refs) {
folders <- if (nrow(refs) > 0) {
'the folder "images" and the folder "references"'
} else {
'the folder "images"'
}
working_directory <- c(
"working directory (RStudio: Session > Set Working Directory > To Source",
"File Location). getwd() shows where R looks."
)
lines <- if (spec$mode == "text" && spec$source == "injuries") {
c(
"Paths. This run used the sample texts of the package: the dataset",
"ohio_injuries comes with decisions, so no path needs a change.",
'Your own texts use a file next to this script: "data.csv".',
'Put this script and the file "data.csv" in one folder, and make it the',
working_directory
)
} else if (spec$mode == "text") {
column <- if (is.null(spec$text_column)) {
'The bundle holds your texts there, in the column "text".'
} else {
sprintf(
'Name your file "data.csv". The texts are in the column %s.',
r_string(spec$text_column)
)
}
c(
"Paths. This run used your own texts. The code reads them from the file",
'"data.csv" next to this script.',
column,
'Put this script and the file "data.csv" in one folder, and make it the',
working_directory
)
} else if (spec$source == "textiles") {
c(
"Paths. This run used the sample images of the package: system.file()",
"finds them on any computer, so no path needs a change.",
'Your own images use relative paths, such as "images/part_001.jpg".',
sprintf("Put this script, %s in one", folders),
"folder, and make it the working directory (RStudio: Session > Set Working",
"Directory > To Source File Location). getwd() shows where R looks."
)
} else {
c(
"Paths. This run used your own images. The code reads them with relative",
'paths, such as "images/part_001.jpg". The bundle holds no input image.',
sprintf("Put this script, %s in one", folders),
"folder, and make it the working directory (RStudio: Session > Set Working",
"Directory > To Source File Location). getwd() shows where R looks.",
'Put your images in the folder "images", as the code below lists them.'
)
}
paste0("# ", lines, collapse = "\n")
}
# The definition of `references` from relative paths (empty without references)
references_code <- function(refs) {
if (nrow(refs) == 0) return("")
by_answer <- refs |>
dplyr::summarise(
code = paste0(
" ", r_name(dplyr::first(answer)), " = ",
r_vector(paste0("references/", dplyr::first(folder), "/", file), indent = " ")
),
.by = answer
)
paste0("\nreferences <- list(\n", paste(by_answer$code, collapse = ",\n"), "\n)")
}
# The comment above the inputs of Try on 5
TRY_COMMENT <- "# The inputs of Try on 5 in the app"
# The lines that keep the rows of Try on 5: x[c(3, 17), ] for a data frame,
# x[c(3, 17)] for a vector. Empty with no rows.
keep_code <- function(var, rows, frame = FALSE) {
if (length(rows) == 0) return("")
sprintf(
"\n%s\n%s <- %s[c(%s)%s]",
TRY_COMMENT, var, var, paste(rows, collapse = ", "), if (frame) ", " else ""
)
}
# The lines that define `texts` (or `injuries`). With `rows` (Try on 5), one
# more line keeps those rows.
text_code <- function(spec, rows = NULL) {
if (spec$source == "injuries") {
return(paste0(paste(
"set.seed(2026)",
"# This line needs the package dplyr.",
"injuries <- ohio_injuries |>",
" dplyr::slice_sample(n = 20, by = collapsed_nature_title)",
sep = "\n"
), keep_code("injuries", rows, frame = TRUE)))
}
if (is.null(spec$text_column)) {
return(paste0('texts <- read.csv("data.csv")$text', keep_code("texts", rows)))
}
# read.csv() changes odd column names unless check.names is FALSE
read <- if (make.names(spec$text_column) == spec$text_column) {
'read.csv("data.csv")'
} else {
'read.csv("data.csv", check.names = FALSE)'
}
# A CSV file keeps its own row numbers ("Row 3"): the app drops empty texts
file_rows <- as.integer(stringr::str_extract(spec$inputs$file[rows], "[0-9]+$"))
paste0("texts <- ", read, "[[", r_string(spec$text_column), "]]", keep_code("texts", file_rows))
}
# The lines that define `images` and `references`. With `rows` (Try on 5),
# the code names only the images of those rows.
inputs_code <- function(spec, refs, rows = NULL) {
if (spec$mode == "text") return(text_code(spec, rows))
if (spec$source == "textiles") {
by_answer <- refs |>
dplyr::summarise(
code = paste0(
" ", r_name(dplyr::first(answer)), " = file.path(folder, ",
r_vector(file, indent = " "), ")"
),
.by = answer
)
images <- if (is.null(rows)) {
'images <- file.path(folder, textiles$file[textiles$role == "input"])'
} else {
paste0(TRY_COMMENT, "\nimages <- file.path(folder, ", r_vector(spec$inputs$file[rows]), ")")
}
return(paste0(
'folder <- system.file("extdata", "textiles", package = "decisions")\n',
images, "\n",
"references <- list(\n", paste(by_answer$code, collapse = ",\n"), "\n)"
))
}
inputs <- if (is.null(rows)) spec$inputs else spec$inputs[rows, ]
images <- if (nrow(inputs) == 0) {
'images <- list.files("images", full.names = TRUE)'
} else {
labels <- unique(stats::na.omit(inputs$label))
folders <- answer_folders(union(names(spec$question$answers), labels))
sub <- ifelse(is.na(inputs$label), "", paste0(folders[inputs$label], "/"))
files <- r_string(paste0("images/", sub, inputs$file))
paste0("images <- c(\n", paste(pack_lines(files), collapse = "\n"), "\n)")
}
paste0(images, references_code(refs))
}
# The call of the question constructor, with the exact text of the question
question_code <- function(question, var) {
answers <- question$answers
args <- r_string(question$instructions)
if (question$type == "noul") {
given <- answers[nzchar(answers)]
if (length(given) > 0) args <- c(args, paste0(names(given), " = ", r_string(given)))
} else {
names_r <- r_name(names(answers))
padded <- stringr::str_pad(names_r, max(nchar(names_r)), side = "right")
args <- c(args, paste0(
"c(\n", paste0(" ", padded, " = ", r_string(answers), collapse = ",\n"), "\n )"
))
}
paste0(
var, " <- question_", question$type, "(\n",
paste0(" ", args, collapse = ",\n"), "\n)"
)
}
# The code of a run, as one string. `date` is the day of the run. `rows` are
# the rows of Try on 5 (NULL for all inputs).
run_code <- function(spec, date = Sys.Date(), rows = NULL) {
refs <- reference_files(spec)
var <- question_var(spec)
data_arg <- if (spec$mode == "text") {
paste0("text = ", if (spec$source == "injuries") "injuries$final_narrative" else "texts")
} else {
"image = images"
}
if (nrow(refs) > 0) data_arg <- paste0(data_arg, ", references = references")
paste(
sprintf("# The run of the decisions Space on %s, in R. Run it with your own key.", format(date)),
'library(decisions) # install once: pak::pak("fmegahed/decisions")',
"",
paths_comment(spec, refs),
inputs_code(spec, refs, rows),
"",
question_code(spec$question, var),
"",
"answers <- decide(",
sprintf(' decider_typesafe("%s"), %s,', MODEL, var),
paste0(" ", data_arg, ","),
sprintf(
" max_size = %s, upscale = %s",
IMAGE_SETTINGS$max_size, toupper(IMAGE_SETTINGS$upscale)
),
")",
"answers",
sep = "\n"
)
}
# The number of lines of code, for the closed card: the top-level calls, so
# comments and blank lines do not count. Code that does not parse counts the
# lines that are not blank and not only a comment.
code_lines <- function(code) {
calls <- tryCatch(length(parse(text = code, keep.source = FALSE)), error = \(e) NULL)
if (!is.null(calls)) return(calls)
lines <- stringr::str_trim(stringr::str_split_1(code, "\n"))
sum(lines != "" & !startsWith(lines, "#"))
}