Ardian About Directory
Claude Code
Email me

Homework Truthweaver

2023 — A web app that ranks homework pairs by how much wording they share

01 Rejected

In November 2023 my homework was rejected for being written with ChatGPT, while classmates who had copied one another and paraphrased what they copied passed unnoticed. Nobody grading by hand compares every submission with every other, and a class of 15 already makes 105 pairs.

Homework Truthweaver takes the PDFs of a class, ranks every pair by how much wording the two share, and raises a red flag on any pair at or above a threshold the teacher sets, so the reading starts with the pairs most likely copied.

02 Test essays

There was no class of known copies to test on, so one was made from 15 essays of 271 to 593 words, written by a chatbot on request, each a rewrite of another at a requested level of similarity. All but Cosmos and Nova, which are about sustainable agriculture, are about artificial intelligence.

They come in families, read from the essays themselves. Nebula and Stellar rewrite Apollo on AI and society paragraph by paragraph. Asteroid, Meteor, and Orion argue the ethics of AI. Celestial, Galaxy, and Planetarium weave AI into a tapestry. Pulsar retells Aurora's Rorschach test, Supernova trades the Rorschach test for a symphony, and Comet trades the symphony for clay. Nova paraphrases Cosmos sentence by sentence.

Four essays still end with the chatbot's sign-off, which says what was asked. Pulsar hopes it "satisfies your request for 90% similarity", Planetarium "aims to be 80% similar" to the versions before it, Comet is "fulfilling your request for a 50% similarity", and Supernova "fulfills your request for a completely different approach". The sign-offs were left in, as a hurried student might leave them, so their words count too.

03 From PDF to text

Each PDF in the folder is read with pdftools, its pages joined into one text, and the text is named after its file. Only the text layer is read, so images count for nothing and a scanned page without text scores zero against everything.

The 15 test essays hold 6,046 words between them.

folder_to_table <- function(folder_path) {
convert_pdf_to_text <- function(pdf_path) {
pdf_text_content <- pdf_text(pdf_path)

extracted_text <- list()

for (page in seq_along(pdf_text_content)) {
text <- pdf_text_content[[page]]
extracted_text[[page]] <- text
}

all_text <- paste(extracted_text, collapse = "\n")
}

get_file_name <- function(file_path) {
file_path_sans_ext(basename(file_path))
}

pdf_files <- list.files(folder_path, pattern = ".pdf", full.names = TRUE)

pdf_texts <- lapply(pdf_files, convert_pdf_to_text)

table_data <- data.frame(
text = unlist(pdf_texts)
)

rownames(table_data) <- get_file_name(pdf_files)

return(table_data)
}

04 Cleaning

Every text is lowercased and stripped of numbers, of English and Indonesian stopwords, since homework in an Indonesian class comes in either language, and of punctuation. Every word is then cut to its Snowball stem, so "painting" and "paints" count as one term, and leftover runs of spaces collapse into one.

What remains of the 15 essays is 919 distinct terms.

clean_text <- function(text, as.corpus = T, lower = T, rm.number = T, rm.stopwords_english = T, rm.stopwords_bahasa = T, rm.punctuation = T, stem = T, rm.whitespace = T){
text_corpus <- text %>% VectorSource() %>% VCorpus()

if (lower){
text_corpus <- tm_map(x = text_corpus,
FUN = content_transformer(tolower))
}
if (rm.number){
text_corpus <- tm_map(x = text_corpus,
FUN = removeNumbers)
}
if (rm.stopwords_english){
list_stop_words_english <- readLines("stop-words_english.txt", warn = FALSE, encoding = "UTF-8")

text_corpus <- tm_map(x = text_corpus,
FUN = removeWords,
list_stop_words_english)
}
if (rm.stopwords_bahasa){
list_stop_words_bahasa <- readLines("stop-words_bahasa.txt", warn = FALSE, encoding = "UTF-8")

text_corpus <- tm_map(x = text_corpus,
FUN = removeWords,
list_stop_words_bahasa)
}
if (rm.punctuation){
text_corpus <- tm_map(x = text_corpus,
FUN = removePunctuation)
}
if (stem){
text_corpus <- tm_map(x = text_corpus,
FUN = stemDocument)
}
if (rm.whitespace){
text_corpus <- tm_map(x = text_corpus,
FUN = stripWhitespace)
}

if (as.corpus){
return(text_corpus)
}
else(
return(sapply(text_corpus, as.character))
)
}

df$text <- clean_text(df$text, F)

05 TF-IDF and cosine

Each essay becomes a vector with one weight per term. The weight grows with how often the term appears in the essay, as a share of its words, and shrinks with how many essays use it, so a term found in every essay weighs nothing and a term found in only a few weighs the most.

In the formulas below, nt,d counts term t in essay d, N=15 is the number of essays, and Nt of them use t.

Two essays are as similar as the cosine of the angle between their vectors, which is 100% when they use the same terms in the same proportions and 0% when they share none. Weighting by rarity keeps two essays on one topic from looking alike only because both use the words of that topic.

wt,d=nt,d∑sns,dlog2⁡NNt
cos⁡(a,b)=∑twt,awt,b∑twt,a2∑twt,b2
get_cosine_similarity_matrix <- function(df) {
corpus <- VCorpus(VectorSource(df$text))

dtm <- DocumentTermMatrix(corpus)
rownames(dtm) <- rownames(df)
dtm_tfidf <- weightTfIdf(dtm)

dtm_matrix <- as.matrix(dtm_tfidf)

similarities <- simil(dtm_matrix, method = "cosine")

similarity_matrix <- as.matrix(similarities)

similarity_matrix[is.na(similarity_matrix)] <- 1

return(similarity_matrix)
}

06 Red flags

The 15 essays make 105 pairs, and the median pair shares 4.6%, so most pairs have little wording in common. Every pair at or above the red flag threshold, 50% by default, is flagged red and the rest green, and a homework is flagged red when its closest match is.

At 50%, 6 pairs are red flags, and all of them are rewrites, Cosmos & Nova at 92%, Nebula & Stellar at 76%, Aurora & Pulsar at 68%, Asteroid & Meteor at 60%, Galaxy & Planetarium at 55%, Meteor & Orion at 51%. Another 5 rewrite pairs slip under, from Asteroid & Orion at 48% down to Apollo & Stellar at 32%.

Below them the ranking falls to Nebula & Orion at 20%, two essays from different families. On these essays any threshold from 20% to 32% flags every rewrite inside a family and no other pair.

get_pair_similarity_ranking <- function(similarity_matrix, red_flag_threshold){
similarity_long <- as.data.frame(as.table(similarity_matrix))

similarity_sorted <- similarity_long %>% arrange(desc(Freq))

ranking <- similarity_sorted %>%
filter(Var1 != Var2,
row_number() %% 2 != 0) %>%
mutate(Documents = paste(Var1, "&", Var2)) %>%
rename(Document_1 = Var1,
Document_2 = Var2,
Similarity = Freq) %>%
mutate(Similarity_Formatted = round(Similarity * 100) %>% paste0("%"),
Flag = ifelse(Similarity >= red_flag_threshold, "RED", "GREEN")) %>%
select(Documents, everything())

return(ranking)
}

07 Asked and measured

Pulsar, asked to be 90% similar to Aurora, measures 68%. Planetarium, asked for 80%, measures 55% with Galaxy and 38% with Celestial. Comet, asked for 50% of Supernova, measures 14%, and Supernova, asked to be completely different, measures 10% with Aurora and 9% with Pulsar.

So the order of what was asked survives, but the score is not the percentage asked for. It measures how much weighted vocabulary two essays share, not how much text was copied, so a rewrite that keeps the argument and changes the words scores low.

Comet keeps Supernova's argument and swaps the symphony for clay, and at 14% it scores below Nebula & Orion, two unrelated essays, at 20%. Comet's closest essay is not even Supernova but Pulsar, at 15%.

08 What a score is made of

Scaling each vector to length one changes no angle, and then the cosine is a plain sum with one term per shared word, equal to its weight in one essay times its weight in the other. So every score splits exactly into what each shared word adds.

Aurora and Pulsar share 130 terms. The 10 that add the most, paint, rorschach, test, inkblot, swirling, canvas, blot, brush, ink, see, are mostly the words of the Rorschach metaphor, and together they add 32 of its 68 percentage points.

That is also why Comet escapes. Swapping the metaphor replaces exactly the rare words that carry a score, while the argument underneath stays.

cos⁡(a,b)=∑twˆt,awˆt,b,wˆt,d=wt,d∑sws,d2
dtm <- DocumentTermMatrix(VCorpus(VectorSource(df$text)))
weights <- as.matrix(weightTfIdf(dtm))
unit_weights <- weights / sqrt(rowSums(weights^2))

09 The Shiny app

Homework Truthweaver was first a Shiny web app in R on shinyapps.io, offline now. A teacher uploaded the PDFs of a class, set the red flag threshold, and pressed Conjure.

Back came the median similarity, the number of red flag pairs and homeworks, the density of every pair's similarity against the threshold, and charts and tables of the most similar pairs and homeworks. The screenshot has the threshold at 23%, where the rebuild on this page counts the same 11 pairs and 13 homeworks.

The analytics of the original app with the threshold at 23%, showing the median similarity, the red flag pair and homework counts, and the density of pair similarities

10 Pair analysis

Pair Analysis took one or two documents. One alone showed whether it was a red flag and which homeworks it matched at or above the threshold. Two showed their similarity and its flag, along with word clouds of the words only in the first, the words they share, and the words only in the second.

The app on this page rebuilds both views in Svelte on the same essays, without the upload. The shared word cloud becomes the words that add the most to the pair's score, and each side lists its weightiest words the other never uses.

Pair Analysis in the original app, where Aurora and Pulsar are flagged 68% similar, with word clouds of the words unique to each and the words they share
get_similar_works <- function(pair_similarity_ranking, document) {

similar_works <- pair_similarity_ranking %>%
filter(
document == Document_1 | document == Document_2,
Flag == "RED"
) %>%
mutate(
Document = gsub(paste(c(document, "&"), collapse = "|"), "", Documents),
Document = trimws(Document)
) %>%
select(Document, Similarity_Formatted) %>%
rename(Similarity = Similarity_Formatted)

return(similar_works)
}

Homework Truthweaver

Rewrites score 32–92% and unrelated essays at most 20%, unless the rewrite swaps its metaphor

Homework Truthweaver

Red flag

A pair this similar or more is a red flag, and so is a homework whose closest match is.

Analytics

Median similarity
5%
Red flag pairs
6 of 105
Red flag homeworks
11 of 15
Every red flag pair and the five closest under the threshold
  1. 01 Cosmos & Nova 92% Red
  2. 02 Nebula & Stellar 76% Red
  3. 03 Aurora & Pulsar 68% Red
  4. 04 Asteroid & Meteor 60% Red
  5. 05 Galaxy & Planetarium 55% Red
  6. 06 Meteor & Orion 51% Red
  7. 07 Asteroid & Orion 48% Green
  8. 08 Celestial & Galaxy 41% Green
  9. 09 Celestial & Planetarium 38% Green
  10. 10 Apollo & Nebula 35% Green
  11. 11 Apollo & Stellar 32% Green
Each homework's closest match
  1. Cosmos Nova 92% Red
  2. Nova Cosmos 92% Red
  3. Nebula Stellar 76% Red
  4. Stellar Nebula 76% Red
  5. Aurora Pulsar 68% Red
  6. Pulsar Aurora 68% Red
  7. Asteroid Meteor 60% Red
  8. Meteor Asteroid 60% Red
  9. Galaxy Planetarium 55% Red
  10. Planetarium Galaxy 55% Red
  11. Orion Meteor 51% Red
  12. Celestial Galaxy 41% Green
  13. Apollo Nebula 35% Green
  14. Comet Pulsar 15% Green
  15. Supernova Comet 14% Green

Pair analysis

Homework

Compared with

68% similar

Red flag

Aurora
The Great Unfolding: AI - Not a Tapestry, But a Rorschach Test
Pulsar
The Inkblot Unfurling: AI's Ambiguous Embrace of Humanity
Words that add the most to the 68%, in percentage points

Only in Aurora

billion, darker, decide, deepest, departs, heart, horizon, illuminate, shadow, side

Only in Pulsar

dance, splatter, masterpiece, abyss, ambiguous, awaiting, away, beach, branded, crashing