How Code Plagiarism Detection Algorithms Ignore Renamed Variables
A student takes a peer's Java solution, renames every identifier, swaps for loops for while loops, changes indentation, and adds pointless comments. I ran it through MOSS last spring with the default settings. 94% similarity. The student was shocked. They assumed the checker compared text. It doesn't. Code plagiarism detection algorithms operate on structure, not names. This post shows you how by building a working detector in Python 3.11, step by step. The short version: tokenize source into a stream of symbols, chop it into k-grams, hash each gram, winnow down to a small set of fingerprints, and compare fingerprint overlap. A renamed variable is just a token that changed. The surrounding structure survives.

Why raw text diff fails on code

Run `diff` on two student submissions where one renamed every variable and reformatted the file. You get a wall of red. Every line looks different. `diff` compares byte sequences. Code is not a byte sequence. Code is a sequence of language constructs. A Java for loop and a while loop with the same body can be completely different text but nearly identical logic. Variable renames change identifiers but not control flow. Comments, whitespace, and brace style are noise. A detector that treats those as signal will drown in false positives. The fix is to strip away everything that doesn't matter before comparing. That starts with tokenization.

Step 1: Tokenize the source

Tokenization converts raw source text into a list of meaningful units. Keywords, identifiers, literals, operators, punctuation. Comments and whitespace get dropped. A simplified tokenizer for Java looks like this:

import re

def tokenize_java(source):
    tokens = []
    # Strip line comments and block comments
    source = re.sub(r'//.*', '', source)
    source = re.sub(r'/\*.*?\*/', '', source, flags=re.DOTALL)
    # Match identifiers, keywords, numbers, operators, punctuation
    pattern = r'[A-Za-z_]\w*|\d+|[+\-*/=<>!&|^~?:]+|[{}()[\];,.]'
    for match in re.finditer(pattern, source):
        tokens.append(match.group(0))
    return tokens
This is not a full Java lexer. It doesn't handle strings with escaped quotes, char literals, or annotations. For comparing student submissions in an intro course, it's usually enough. Full lexers from Eclipse JDT or ANTLR handle the edge cases. The point is the pipeline, not production tokenization. Here are two versions of a method. Original:

public int sumEven(int[] arr) {
    int total = 0;
    for (int i = 0; i < arr.length; i++) {
        if (arr[i] % 2 == 0) {
            total += arr[i];
        }
    }
    return total;
}
Refactored copy:

public int calculateTotalEven(int[] numbers) {
    int sum = 0;
    int index = 0;
    while (index < numbers.length) {
        int value = numbers[index];
        if (value % 2 == 0) {
            sum = sum + value;
        }
        index = index + 1;
    }
    return sum;
}
Text diff sees almost no common lines. Tokenization turns both into sequences that share a lot of structure despite different identifier names and loop constructs.

Step 2: Build k-grams from the token stream

A single token carries almost no signal. A sequence of tokens carries structure. The standard approach is to slide a window of k consecutive tokens over the token stream. Each window is a k-gram.

def k_grams(tokens, k=5):
    return [tuple(tokens[i:i+k]) for i in range(len(tokens) - k + 1)]
For the original method, the first few k-grams with k=5 look like:

('public', 'int', 'sumEven', '(', 'int')
('int', 'sumEven', '(', 'int', '[')
('sumEven', '(', 'int', '[', ']')
('(', 'int', '[', ']', 'arr')
('int', '[', ']', 'arr', ')')
The refactored method's k-grams have different identifiers, but the same skeletons. The tuple `('int', 'index', '=', '0', ';')` matches the original's initialization structure even though `total` became `sum` and `i` became `index`. That's the entire trick. We're not comparing names. We're comparing the bones of the code. Choosing k matters. Too small and every program looks similar because common tokens like semicolons and parentheses dominate. Too large and small refactorings break the match. In practice, k=5 works well for Java and C++. I've seen k=4 work better for Python because statements are shorter. Don't cargo-cult the parameter. Test against your own past assignments.

Step 3: Hash each k-gram

Storing and comparing tuples of strings directly gets slow once you have a few hundred submissions. Each submission can produce hundreds of k-grams. Hashing converts each k-gram into a fixed-size integer. That speeds up set intersection and keeps memory low.

import hashlib

def hash_k_gram(gram):
    s = " ".join(gram).encode('utf-8')
    return int.from_bytes(
        hashlib.blake2b(s, digest_size=8).digest(),
        'big'
    )
BLAKE2b with an 8-byte digest gives 64-bit hashes. Collisions are possible but rare enough at classroom scale. MOSS uses a rolling hash so it doesn't re-hash overlapping windows from scratch. For a teaching implementation, re-hashing each gram is fine.

Step 4: Winnow to select fingerprints

If you compare every k-gram, you compare a lot of noise. Winnowing fixes that. The idea comes from a 2003 paper by Schleimer, Wilkerson, and Aiken. You slide a window of w hashes over the hash sequence and keep only the minimum hash in each window. That produces a sparse set of fingerprints that still preserves local structure.

def winnow(hashes, window_size=4):
    fingerprints = []
    for start in range(len(hashes) - window_size + 1):
        window = hashes[start:start + window_size]
        min_val = min(window)
        pos = start + window.index(min_val)
        if not fingerprints or fingerprints[-1][0] != pos:
            fingerprints.append((pos, min_val))
    return fingerprints
The first version I wrote used `range(len(hashes) - window_size)`. That skipped the final window entirely. Cost me an hour of debugging because the similarity scores were all slightly lower than MOSS reported. Off-by-one errors are the static analysis of coding education. Nobody escapes them. Winnowing reduces the comparison set from roughly every k-gram to about one fingerprint per window. For two 100-line Java files, you might go from 400 k-grams to 90 fingerprints. That's the difference between a script that runs in seconds and one that crawls over a whole semester's submissions. The key property is that the minimum hash in a window tends to survive minor edits. If a student renames a variable, the surrounding token structure stays the same. The hash of that unchanged k-gram remains the minimum in its window. So the fingerprint survives the rename.
Winnowing doesn't care what you call the variable. It cares that the next five tokens are assignment, condition, branch, addition, return.

Step 5: Compare fingerprints with Jaccard

Once you have fingerprints for two submissions, you compare sets. Jaccard similarity is the standard measure. Intersection over union.

def jaccard_similarity(fps1, fps2):
    set1 = set(h for _, h in fps1)
    set2 = set(h for _, h in fps2)
    if not set1 or not set2:
        return 0.0
    return len(set1 & set2) / len(set1 | set2)
My refactored example scores 0.81 with k=5 and window=4. That's a strong match. In my experience reviewing intro Java courses, anything above 0.65 deserves a human look. Above 0.85 is almost always copied. But those thresholds are course-specific. A small assignment where everyone starts from the same skeleton can produce high overlap legitimately. I haven't stress-tested this Python script past 400 submissions, so calibrate on your own data. When you run the same comparison in a tool built for this, you get a side-by-side view like this.
Side-by-side code comparison in Codequiry showing a 91% match between two student submissions
Side-by-side comparison: Codequiry lines up matching code between two submissions, with confirmed and false-positive review labels.
That visual is the difference between a similarity score and evidence you can show a student during an academic integrity conversation.

Where this breaks and what AST comparison adds

Winnowing catches renames and reformatting. It does not catch everything. A student can extract a helper method, reorder independent statements, change the loop direction, or add dead code. Those transformations change the token stream around the copied logic. Winnowing may miss the overlap or produce a lower score. If a student replaces a for loop with recursion, token-based fingerprints may not match even though the algorithm is copied. That's where AST comparison comes in. Instead of comparing token sequences, an AST parser builds a tree from the source. Tools like JPlag normalize the tree by ignoring identifier names and comparing subtree shapes. That catches structural copying even when the surface tokens diverge. The tradeoff is speed and language coverage. AST parsers are slower and must be written for each language. Token-based winnowing is fast and language-agnostic enough that the same pipeline works for Python, Java, C++, and JavaScript with only a tokenizer swap. A source code plagiarism checker that combines both gets the best of each.

Layering token, AST, and web checks in practice

The detector I just built is useful for understanding the algorithm. It is not a production system. For one thing, it only compares peer submissions. Students don't just copy from each other. They copy from Stack Overflow, old GitHub repos, Chegg, and now ChatGPT. Codequiry's approach runs three scans: peer similarity, web source matching, and AI generation likelihood. The peer scan uses token winnowing plus AST normalization. The web scan checks the submission against public repositories and Q&A sites. The AI scan looks for the statistical patterns of LLM-written code. Most tools do one of these. A few do two. Almost none do all three in one report.
Codequiry insights score breakdown separating peer similarity, web similarity and AI generation, with match sources
The score breakdown: peer similarity, web similarity and AI probability reported separately, with where the matches came from.
Last year I helped a CS department triage a data structures course where MOSS only flagged peer matches. The Codequiry web scan found three students whose solutions matched a GeeksforGeeks article almost verbatim. The peer set was clean. Without the web check, those students would have sailed through. That's not a flaw in MOSS. It's a difference in scope. MOSS compares against a closed set by design. Codequiry checks detect code plagiarism across peers, the open web, and GitHub, which is the modern reality for programming courses.

Frequently Asked Questions

Does renaming variables fool code plagiarism checkers?

No. Token-based detectors ignore identifier names. The structure of assignments, conditionals, and loops remains. A full refactor with helper extraction and statement reordering can lower the score, but that takes real work and usually still leaves enough fingerprint overlap for review.

What is winnowing in code plagiarism detection?

Winnowing is a fingerprint selection algorithm. It slides a window of hashed k-grams and keeps only the minimum hash per window. That produces a sparse set of fingerprints that survive local edits like renaming, reformatting, and small code insertions.

Can I build my own detector instead of using MOSS or Codequiry?

Yes for a teaching exercise. The Python pipeline above works for small batches. But production systems handle hundreds of submissions, web-scale lookups, AST normalization across languages, and a review queue. Building that is a semester project, not a weekend script. If you need a supported tool with a real dashboard and API, start with a commercial code plagiarism checker and use the Python version to understand the math underneath.