Setting Up Cross-Language Code Plagiarism Detection for CS2

Cross-language plagiarism detection catches students who submit a translation of someone else's solution by comparing program structure rather than program text. Token normalization, AST canonicalization, and control-flow fingerprints each survive translation to a different degree, and stacking all three catches most of what a same-language scan misses. What follows is how we ran that stack on 1,412 submissions split across a Java-first CS1 and a Python-first CS2 in the same department.

The department asked not to be named, so I'll call it Northgate State. Two campuses, roughly 1,400 students a term across the intro sequence, eleven sections, and a shared assignment bank that had been quietly leaking between languages for at least three years before anyone noticed.

What a translated submission actually looks like

Here's the CS1 problem: longest run of equal adjacent values in an integer array. Here's the reference Java solution a student would have seen in section.

public static int longestRun(int[] a) {
    if (a.length == 0) return 0;
    int best = 1, cur = 1;
    for (int i = 1; i < a.length; i++) {
        if (a[i] == a[i - 1]) { cur++; } else { cur = 1; }
        if (cur > best) best = cur;
    }
    return best;
}

And here's what came in as a CS2 Python submission from a student who had, she later explained, "worked with a friend."

def longest_run(arr):
    if len(arr) == 0:
        return 0
    best = cur = 1
    for i in range(1, len(arr)):
        if arr[i] == arr[i - 1]:
            cur += 1
        else:
            cur = 1
        if cur > best:
            best = cur
    return best

Read them side by side and the relationship is obvious. Run a token-based similarity tool on them and you get almost nothing, because almost none of the tokens match. The Java version has braces, semicolons, type declarations, an increment operator, and a C-style loop header. The Python version has a range call, no braces, no semicolons, no types, and an augmented assignment. If you feed both into a scanner that expects one language, the scanner either refuses the mixed base or reports a score near zero, and the case walks out the door.

That gap is the whole problem. The logic is identical. The surface is not.

How cross-language code plagiarism detection actually works

Every practical approach in this space reduces to the same idea: throw away what the language forces you to write, keep what the author chose to write. The literature calls this normalization to a language-independent representation, and the cross-language work I've found most useful (Flores and colleagues, 2014, on source code reuse across languages) is essentially a careful argument about which parts of a program are language artifacts and which are authorial decisions.

In practice you get three layers, and they fail in different places.

Layer one, lexical normalization

Token-based detection goes back a long way. Baker's 1995 work on parameterized duplicate detection and Wise's YAP3 (1996) both operate on token sequences, and Prechelt, Malpohl, and Philippsen built JPlag (2002) on Wise's Greedy String Tiling. The mechanism is straightforward: lex the file, hash the tokens with something cheap like Karp-Rabin, find maximal matching runs, then tile the runs greedily so no token is claimed twice. A minimum match length (JPlag defaults around 9 tokens) keeps common idioms from lighting up the report.

None of that helps across languages until you normalize. We collapsed every identifier to a single placeholder, every numeric and string literal to another, and dropped the punctuation that one language requires and the other forbids.

DROP_TOKENS = {"{", "}", "(", ")", ";", ":"}

def normalize(tokens):
    out = []
    for t in tokens:
        if t.kind in ("IDENT", "TYPE"):
            out.append("V")          # all names collapse
        elif t.kind == "LITERAL":
            out.append("L")          # all constants collapse
        elif t.text in DROP_TOKENS:
            continue
        else:
            out.append(t.text.lower())
    return out

That gets you partway. The Java loop header tokenizes to something like for V V = L ; V < V . V ; V ++, and the Python for V in V ( L , V ( V ) ). Still not close, but the bodies start to align, and a GST run over the normalized streams will pick up the if V [ V ] == V [ V - L ] shape on both sides.

Layer one is cheap and fast and it catches the lazy translator, the student who pastes into a chat window and asks for a language conversion and turns it in unchanged. It'll miss anyone who renames and restructures.

Layer two, structural canonicalization

The second layer parses instead of lexing. Tree-based clone detection has a long record: Baxter and colleagues (1998) hashed AST subtrees for near-miss clone detection, Jiang and colleagues (2007) vectorized subtrees into characteristic vectors in DECKARD, and GumTree (Falleri and colleagues, 2014) produces fine-grained edit scripts between two trees. The relevant property for us is that an abstract syntax tree has already thrown away semicolons and braces and type annotations, because those are properties of the concrete syntax, not the abstract one.

So you walk each tree and emit a canonical skeleton. Not the whole tree, a skeleton: control constructs, comparison operators, index arithmetic, and the shape of expressions, with all leaves erased.

def skeleton(node):
    n = type(node).__name__
    if n == "If":
        parts = ["IF", skeleton(node.test), skeleton(node.body)]
        if node.orelse:
            parts += ["ELSE", skeleton(node.orelse)]
        return parts
    if n in ("For", "While", "ForEach"):
        return ["LOOP", skeleton(node.body)]
    if n == "Compare":
        return ["CMP", "".join(op_symbol(o) for o in node.ops)]
    if n == "Return":
        return ["RET"]
    if n == "Assign":
        return ["SET"]
    return [c for child in ast.iter_child_nodes(node) for c in skeleton(child)]

Both programs above emit, roughly:

[RET] [SET] [LOOP [IF [CMP ==] [SET] [ELSE [SET]]] [IF [CMP >] [SET]]] [RET]

Identical skeleton. Now you're comparing structure to structure, and the language is gone. Similarity over skeletons is usually some form of tree edit distance or a longest common subsequence over the serialized skeleton, normalized by the larger of the two lengths. A score of 1.0 means the control structures match exactly.

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.

Layer three, control-flow and feature fingerprints

The third layer is blunt and it catches the cases the first two miss. Extract a numeric feature vector per submission: number of loops, maximum loop nesting depth, number of conditional branches, count of each comparison operator, presence of early return, presence of accumulator variables, ratio of arithmetic to logical operations, and so on. Then compare with cosine similarity.

FeatureJava originalPython translationIndependent Python solution
Loops111
Max nesting depth222
Conditional branches334
Comparison operators==, <, >==, <, >!=, ==, >=
Early returnyesyesno
Accumulator count221

This layer is coarse. It scores the two translated programs around 0.97 and the independent solution around 0.71, which is exactly the problem: 0.71 is high enough that a naive threshold flags innocent students. Feature fingerprints are for ranking and triage, never for a finding on their own.

Running the scan on a real cohort

Northgate's CS1 is Java, CS2 is Python, and the shared assignment bank means the same seven problems appear in both courses under different names. That's the setup that makes translated code worth looking for in the first place. If your two courses never overlap in problem content, this entire workflow is wasted effort.

We ran the cross-language pass at the end of the term, after grades were posted but before the grade-appeal window closed, because that's when the department's academic integrity process wanted the evidence package.

  1. Export submissions from the LMS as plain files, one directory per student, preserving the student ID in the path.
  2. Build two bases: CS1 Java submissions and CS2 Python submissions, tagged with language.
  3. Run the same-language scan first, within each cohort, to get the ordinary copy cases out of the way.
  4. Run the cross-language pass with the normalization stack, comparing every CS1 submission against every CS2 submission.
  5. Union the results, deduplicate by student pair, and rank by the maximum of the three layer scores.
  6. Review every pair above 0.65 by hand before anyone gets an email.

The combinatorial cost matters here and it's the reason people don't do this. Forty students gives you 780 pairs against 780. Fourteen hundred students gives you 996,166 cross-language pairs. That's still tractable if you hash skeletons and bucket by feature vector before running the expensive tree comparison on survivors, and it's completely intractable if you naively compare every pair at full edit distance.

Codequiry scan monitor mid-run at 92% with CPU and memory gauges and a live per-file activity log
A check in flight: live progress, resource gauges, and a per-file activity log as each submission is scored.

The full run, 1,412 submissions across both bases, took just under four minutes on an eight-core machine once the files were parsed. Parsing dominated. The actual comparison over bucketed candidates was under forty seconds.

The numbers

Out of 996,166 cross-language pairs, 214 scored above our 0.65 review threshold. Manual review of those 214 pairs took a TA and me about five hours across two sittings, split roughly 60/40 between reading and writing up the findings.

MetricValue
Submissions scanned1,412
Cross-language pairs compared996,166
Pairs above 0.65214
Confirmed as translated copies61 pairs (26 groups)
Cleared as natural-solution collisions19 pairs
Confirmed cases the same-language scan had missed34 pairs
Wall-clock scan time3m 52s

Twenty-nine percent precision at the pair level looks terrible on a slide. It isn't, in context. A review of 214 pairs is an afternoon for one TA. A missed translated copy is a semester of a student getting credit for work he can't do, and a disciplinary hearing three years later when he can't pass the next course. The asymmetry of cost here is enormous, and any threshold decision should be made against that asymmetry rather than against a precision number.

Half the confirmed cases, 34 of 61, had scored below the same-language threshold entirely. Those are the ones the department never would have found. In nine of the 26 groups the CS1 side was itself a copy of another CS1 submission, so the graph had two hops, which is the kind of thing you only see when you plot the whole cohort as a graph instead of reading pairs.

Flagging 214 pairs to confirm 61 is not a precision problem. It's a reading problem, and reading is what TAs are for.

Where cross-language detection breaks down

Three failure modes, all predictable.

Low-entropy assignments. Count the vowels, reverse a string, validate a parenthesis sequence. There are maybe four reasonable ways to write these in any language, so the natural-solution collision rate goes through the roof. Our 19 cleared pairs were almost all from two problems of this shape. If a problem has fewer than fifteen lines in the natural solution, cross-language similarity carries no evidentiary weight at all, and you should exclude those files from the pass by name.

Library-dominated code. A Python submission that does all its work in three pandas calls has almost no control flow to compare. The skeleton is two lines long and every pair of pandas solutions looks alike. Structural comparison assumes the student wrote structure, and some assignments don't have any.

The idiom penalty. Java forces the student to declare types and to write explicit index loops; Python lets her write comprehension and slicing. A student who genuinely solves the problem in idiomatic Python looks structurally different from a Java original in ways that push similarity down, and a student who writes Java-flavored Python pushes it up. That's the signal working, mostly, but it means a translator who knows Python well is harder to catch than one who doesn't. The best case we found had been run through a language model and lightly edited, which brings us to the next section.

The AI layer, and why it made the review queue shorter

Of the 61 confirmed translated pairs, 11 had a Python side that also scored high on AI-generation detection. That's not surprising. Translating a 20-line solution from Java to Python is precisely the kind of task a student hands to a chat model, and the output has the signature: uniform naming, complete docstrings on trivial functions, a defensive guard clause that the Java original didn't have, and zero personal stylistic tics.

The practical value wasn't the accusation, it was the triage. A pair scoring high on both peer similarity and AI generation is a much stronger finding than either signal alone, because the two signals have different failure modes. Peer similarity false-positives on natural-solution collisions. AI detection false-positives on polished human code. The intersection is small and mostly real.

That's the argument for running both checks in one pass rather than three. We started this project with a token-similarity script, a separate web-search check for GitHub and Stack Overflow matches, and no AI detection at all, and reconciling three sets of student IDs across three output formats ate more time than the analysis did. Codequiry's code plagiarism checker gives you peer similarity, web matches, and an AI code detector score from one submission set, which is the reason we consolidated onto it for the following term.

Codequiry evidence review with a synced diff of two Java files and a list of GitHub and web matches
Evidence review: a synced diff of the matched lines next to every peer, GitHub and web source for the submission.

Worth saying plainly: the AI score never decided a case for us. It prioritized the queue. Anything above 0.8 on the Python side went to the front of the reading list, and everything else got read in score order after that. The findings that held up under a conversation with the student were the ones with a specific structural match to a specific source file, and those are peer-similarity findings regardless of how the code was produced.

The arms race after the first report

Word gets around. The term after we reported 26 groups, the translations got better. Two patterns showed up repeatedly.

The first is wrapping everything in a class. A student takes a translated function, drops it into a class Solution:, adds a __init__ that sets one attribute, and calls it from a main at the bottom. The skeleton gains three nodes on the Python side and the tree similarity drops by roughly 0.08. Not enough to escape a 0.65 threshold, and the feature vector barely moves, which is why the three-layer stack matters. Layer three doesn't care about the wrapper.

The second is replacing for i in range(...) with an explicit while and a manual counter. This one is more effective against naive tree comparison, because the loop node changes shape. It also changes the control-flow fingerprint, though, and it produces something no experienced Python programmer would write, which is itself a signal. We flagged two of those on style alone and then found the match anyway.

What defeats the stack is a student who reads the Java, understands the algorithm, closes the tab, and writes her own Python from her own mental model. At that point she has learned the material, and there is nothing to detect, and there shouldn't be. Anyone building a detector should be clear-eyed that the target is transcription, not comprehension.

Wiring it into the term calendar

The workflow that survived contact with an actual academic year is boring. Run the same-language peer scan after every assignment deadline, because early signals are cheap and let you have a conversation before the pattern hardens. Run the cross-language pass once, near the end of the term, when both cohorts have enough submissions to compare against each other. Run the web check on anything that survives either pass, and run AI detection on everything as a triage layer regardless.

Keep the old submissions. Three years of an assignment bank is worth more than any single term's results, because the majority of translated cases we found involved a Python submission matched against a Java file from a previous offering. Current-term cross-language matching found 22 of the 26 groups. The other four came from the archive.

What to check before you buy a tool for this

Most plagiarism tools in this space are same-language tools with a language flag. MOSS takes one language per run via -l, so a mixed base isn't a thing you can construct. JPlag 4.x will accept mixed languages in a single base, but each language defines its own token alphabet, so a Java file and a Python file share almost no tokens and land at a similarity near zero. Dolos parses with tree-sitter grammars that are language-specific in the same way. As far as I can tell, no mainstream tool does true cross-language comparison out of the box, which is why the approach here is a normalization stack you own on top of a scanner that gives you the raw material.

What you should actually check, in order: does it report peer, web, and AI scores from one submission set, or do you need three tools and a spreadsheet; does the similarity survive renaming and reformatting, which means asking whether it does anything beyond text diffing; how many languages does it parse (Codequiry's source code plagiarism checker covers 65, which matters if your department teaches C, C++, Java, Python, and JavaScript across four courses); and does it have an API, because the term-end run should be a cron job, not a Friday afternoon.

Codequiry supported programming languages page listing 65 approved languages and custom parser submission
65 supported languages, plus custom parsers for anything unusual a course or codebase throws at the engine.

If you're currently on MOSS because it's free and it works, that's a defensible position, and the Codequiry vs MOSS comparison is worth reading before you switch. MOSS's real limitations for this workflow are that it gives you an HTML page of aligned matches and nothing else, and that the alignment output is the raw material for the kind of normalization pass described above rather than a substitute for it. Budget the engineering time either way.

The one bug we shipped

Our normalizer, version 2.1, emitted Python's elif as a single token distinct from else followed by if. Java has no elif. So on exactly the construct that appears in half of all loop-and-branch assignments, the two canonical forms diverged and the skeleton comparison collapsed to a partial match. We found it in week two of the fall term, after a TA asked why her textbook example of a translated submission scored 0.31.

Every normalization stack has a bug like this. The fix is unglamorous: keep a small test corpus of five known translations with expected scores above 0.85, and run it before every scan.

Cross-language detection is not a solved problem and the tooling is thinner than the same-language market. But 34 cases that would have gone unnoticed, in one term, at one university, is a decent argument for building the pass. Start with the assignments that overlap across your courses, exclude anything with fewer than fifteen lines in the natural solution, and read every pair yourself until you trust the threshold. If you want the peer, web, and AI signals in one place while you do it, the Codequiry code plagiarism checker is where I'd start.