Skip to content

OA Simulator & Exam Integrity: Marking Schemes & Scoring

Part 2 of 5 in the OA Simulator & Exam Integrity series.

OA Structure & Timer Logic covered how an attempt is shaped and clocked. This page covers what happens to a finished attempt's raw answers: how they turn into marks. All of it lives in one dependency-free module, @edikylab/oa-engine (ediky-shared/packages/oa-engine/src/index.js), shared verbatim between the website and desktop builds — the scoring math cannot drift between the two the way a duplicated implementation could.

The marking scheme is data, not code

DEFAULT_MARKING is the fallback every OA scores against unless its creator overrides it in the builder:

javascript
export const DEFAULT_MARKING = Object.freeze({
    codingMarks: Object.freeze({ Easy: 15, Medium: 25, Hard: 35 }),
    correctMarks: Object.freeze({ Easy: 1, Medium: 2, Hard: 3 }),
    negativeMarks: 0.5,
});

Both codingMarks and correctMarks are tiered by difficulty rather than a flat rate — a Hard DSA problem is worth more than an Easy one, matching how real OAs actually weight sections rather than paying every question the same. negativeMarks is a single flat deduction, independent of difficulty. normalizeMarking() accepts either the tiered { Easy, Medium, Hard } shape or a bare legacy number (applied uniformly across all three tiers) — attempts and templates saved before difficulty-tiering existed keep scoring correctly with no migration step, and a creator can override only Hard in the builder and let Easy/Medium fall back to defaults.

Four question kinds, two scoring rules

questionKind(section) classifies every question as judge (DSA/coding, run through Judge0 — see Judge0 & Code Execution), concept (CS Fundamentals / Aptitude / DSA-Technical MCQ, AI- or MCQ-graded), sql (AI-graded SQL), or cf (a Codeforces problem, verified against the candidate's own CF submission history rather than judged locally — see Results & CP Comparison). Only two scoring rules exist across all four kinds:

  • Coding-shaped (judge, sql, cf) — never penalized. A solved question earns the full codingMarks weight for its difficulty; an unsolved-but-attempted judge/sql question earns partial credit proportional to test cases passed. cf has no partial credit (no local test cases to award it against) — it is binary: an Accepted submission was found on Codeforces, or the question scores 0.
  • Concept-shaped (concept) — full correctMarks if correct, proportional credit if an AI grader scores a free-text answer partially right, and −negativeMarks only if the candidate answered and was fully wrong. An untouched question of either shape scores a flat 0, never negative — only an attempted-and-wrong MCQ/theory answer is penalized.
javascript
// Simplified real shape of questionMarks (oa-engine/src/index.js)
function questionMarks(q) {
  if (!q.attempted) return 0;                 // skipped: flat zero, never negative
  const weight = (q.kind !== "concept" ? codingMarks : correctMarks)[tierOf(q)];
  if (q.solved) return weight;
  if (q.kind !== "concept") {
    // Coding: proportional credit, no penalty for a wrong run.
    return q.bestTotal > 0 ? weight * (q.bestPassed / q.bestTotal) : 0;
  }
  // Concept/MCQ: proportional AI credit if partially right, else negative marking.
  if (q.aiScore > 0) return weight * (q.aiScore / 100);
  return -negativeMarks;
}

Aggregation and the percentage

computeAttemptResult({ questions, perProblem, submissions, startedAtMs, marking }) runs this per question, then sums:

diagram100%
rendering diagram…

drag to pan · ctrl / ⌘ + scroll to zoom

marksRaw is rounded to 2dp (marks can be fractional — a 12/15-case partial credit isn't a whole number) and reported alongside marksMax so the result page can show "62.5 / 160" as well as a percentage. scorePct is separately clamped to [0, 100] — a sheet net-negative from heavy wrong-MCQ penalties never renders a nonsensical negative bar on the history chart, while score itself stays the honest, possibly-negative raw number underneath.

python
# Reference form: partial credit for an unsolved coding question
def coding_credit(passed, total, weight):
    if total == 0:
        return 0
    return weight * (passed / total)   # e.g. 12/15 cases -> 0.8 * weight

Breakdown for weak-area feedback

topicBreakdown() and difficultyBreakdown() reduce the same perQuestion array into { topic, total, solved, solveRate } and { difficulty, total, solved } buckets — the numbers behind the result page's radar/weakness view. weakTopics() filters that breakdown to topics at or below a 60% solve rate, sorted worst-first; it accepts either one attempt's perQuestion array or several concatenated, which is what powers the "Retry Weak Areas" generator across a candidate's full history, not just their latest sitting.

Company presets weight selection, not scoring

It's worth restating from OA Structure & Timer Logic: COMPANY_PRESETS only bias which questions selectQuestions() picks (soft weighting, with graceful fallback when a company's tag pool is sparse — Uber has zero tagged problems in the live bank, Microsoft has one). Marking is a fully separate, orthogonal axis set independently in the builder. A Cisco-flavored question mix can be scored under a D.E. Shaw-flavored marking scheme, or any custom one — the engine never couples the two.

Self-assessment

Word count for this page: 875 words.


← Previous: OA Structure & Timer Logic→ Next: Risk Engine & the Hash-Chained Audit Log

Ediky Workflow — internal engineering documentation.