Technical Implementation: JavaScript ABM for College Admissions Simulation

Source: technical-implementation.md


Technical Implementation: JavaScript ABM for College Admissions Simulation

Table of Contents

  1. JavaScript ABM Frameworks & Patterns
  2. D3.js for Admissions Visualization
  3. Canvas API vs SVG for Scale
  4. JSON Schema Design for Agents
  5. Simulation Architecture Patterns
  6. Correlated Random Number Generation
  7. Performance Benchmarks & Targets

1. JavaScript ABM Frameworks & Patterns

Framework Comparison

Framework Type Size Pros Cons Best For
AgentScript Full ABM ~50KB NetLogo semantics, MVC architecture, 2D/3D views Spatial grid focus, overkill for non-spatial models Spatial simulations with turtles/patches
SIM.JS Discrete Event ~15KB Event-driven, queues/resources built-in Callback-heavy, dated API Queuing systems, process flows
js-simulator Multi-agent DES ~20KB MASON-inspired, agent scheduling Small community, limited docs Agent scheduling problems
SimScript Discrete Event ~30KB TypeScript, async/await paradigm Newer, less battle-tested Modern DES with clean syntax
OESjs Object Event Sim ~40KB Academic rigor, both tick and event time Heavyweight, academic focus Research-grade simulations
Custom (vanilla JS) Bespoke 0 deps Full control, minimal overhead, no learning curve Must build everything Our use case -- domain-specific, single-file

Recommendation: Custom Vanilla JS

For a college admissions simulation in a single HTML file, a custom approach is strongly recommended:

Simulation Loop Strategy

Three options for driving the simulation:

Option A: requestAnimationFrame (rAF) -- for animated visualization

// Best for: animated step-by-step visualization
function simulationLoop(timestamp) {
    if (!paused) {
        const elapsed = timestamp - lastTimestamp;
        if (elapsed >= tickInterval) {
            simulation.step();           // advance one tick
            renderer.draw(simulation);   // update visualization
            lastTimestamp = timestamp;
        }
    }
    requestAnimationFrame(simulationLoop);
}
requestAnimationFrame(simulationLoop);

Option B: Synchronous batch -- for instant results

// Best for: running full simulation quickly, showing results after
function runFullSimulation() {
    const sim = new AdmissionsSimulation(config);
    for (const round of ['ED', 'EA_REA', 'EDII', 'RD', 'DECISION', 'WAITLIST']) {
        sim.executeRound(round);
    }
    return sim.getResults();
}

Option C: Web Worker -- for background computation with progress

// Best for: large student counts (5000+) without freezing UI
// main.js
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
worker.postMessage({ type: 'run', config: simConfig });
worker.onmessage = (e) => {
    if (e.data.type === 'progress') updateProgressBar(e.data);
    if (e.data.type === 'roundComplete') renderRoundResults(e.data);
    if (e.data.type === 'done') renderFinalResults(e.data);
};

Use synchronous batch for the simulation engine (it completes in <2s for 2000 students) combined with rAF for animating the results afterward. For truly large runs (5000+ students or repeated Monte Carlo), use a Web Worker to prevent UI freezing.

+--------------------+      +--------------------+      +--------------------+
|   User clicks      | ---> |  Simulation Engine  | ---> |  Animated D3       |
|   "Run Simulation" |      |  (sync, <2 seconds) |      |  Visualization     |
+--------------------+      +--------------------+      +--------------------+
                                    |                            ^
                                    |  results object            |  rAF loop
                                    +----------------------------+

Handling 5,000+ Student Agents

Key performance patterns for large agent counts:

  1. Typed Arrays for bulk data -- Store GPA/SAT as Float32Array for cache-friendly iteration
  2. Structure of Arrays (SoA) instead of Array of Structures (AoS): ```javascript // SLOW: Array of Structures (AoS) const students = [{ gpa: 3.8, sat: 1520 }, { gpa: 3.5, sat: 1400 }, ...];

// FAST: Structure of Arrays (SoA) for hot loops const gpas = new Float32Array(5000); const sats = new Uint16Array(5000); ``` 3. Object pooling -- Pre-allocate student and application objects, reuse across Monte Carlo runs 4. Batch scoring -- Score all students for one college at a time (better cache locality) rather than scoring all colleges for one student 5. Early exit -- If a college has filled its class, skip remaining applicants in that round


2. D3.js for Admissions Visualization

D3.js v7 Core APIs for This Project

2a. Sankey Diagrams (Student Flow Visualization)

The Sankey diagram is ideal for showing the flow of students through admission rounds:

High Schools --> [ED Round] --> Admitted / Deferred / Rejected
                [EA Round] --> Admitted / Deferred / Rejected
                [RD Round] --> Admitted / Rejected / Waitlisted
                [Decision Day] --> Enrolled / Declined
                [Waitlist] --> Admitted off WL / Final Rejected

Implementation with d3-sankey (v0.12.3+):

// CDN: https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js

const sankey = d3.sankey()
    .nodeId(d => d.id)
    .nodeWidth(20)
    .nodePadding(10)
    .extent([[0, 0], [width, height]]);

// Define nodes: school types, rounds, outcomes, colleges
const graph = sankey({
    nodes: [
        { id: "public_hs", name: "Public HS" },
        { id: "private_hs", name: "Private/Feeder" },
        { id: "ed_round", name: "Early Decision" },
        { id: "rd_round", name: "Regular Decision" },
        { id: "harvard_admit", name: "Harvard Admit" },
        // ...
    ],
    links: [
        { source: "public_hs", target: "ed_round", value: 300 },
        { source: "ed_round", target: "harvard_admit", value: 15 },
        // ...
    ]
});

// Render links as paths
svg.selectAll(".link")
    .data(graph.links)
    .join("path")
    .attr("d", d3.sankeyLinkHorizontal())
    .attr("stroke-width", d => Math.max(1, d.width))
    .attr("stroke", d => tierColor(d.target))
    .attr("fill", "none")
    .attr("opacity", 0.4);

2b. Force-Directed Graph (Student-College Arcs)

D3's force simulation can show students as particles attracted to their enrolled college:

const simulation = d3.forceSimulation(studentNodes)
    .force("charge", d3.forceManyBody().strength(-2))
    .force("link", d3.forceLink(enrollmentLinks).id(d => d.id).distance(100))
    .force("center", d3.forceCenter(width / 2, height / 2))
    .force("collide", d3.forceCollide(3))
    .force("x", d3.forceX(d => collegeX(d.enrolledAt)).strength(0.3))
    .force("y", d3.forceY(d => collegeY(d.enrolledAt)).strength(0.3))
    .on("tick", ticked);

function ticked() {
    studentCircles
        .attr("cx", d => d.x)
        .attr("cy", d => d.y);

    arcPaths
        .attr("d", d => bezierArc(d.source, d.target));
}

// Bezier arc between student and college
function bezierArc(source, target) {
    const dx = target.x - source.x;
    const dy = target.y - source.y;
    const dr = Math.sqrt(dx * dx + dy * dy) * 0.7; // curvature
    return `M${source.x},${source.y}A${dr},${dr} 0 0,1 ${target.x},${target.y}`;
}

2c. Animated Transitions

// Students appearing (fade in + scale up)
studentCircles.enter()
    .append("circle")
    .attr("r", 0)
    .attr("opacity", 0)
    .attr("fill", d => archetypeColor(d.archetype))
    .transition()
    .duration(500)
    .delay((d, i) => i * 2) // staggered entrance
    .attr("r", 3)
    .attr("opacity", 0.8);

// Students moving to college (smooth transition)
studentCircles.transition()
    .duration(1000)
    .ease(d3.easeCubicInOut)
    .attr("cx", d => collegePositions[d.enrolledAt].x)
    .attr("cy", d => collegePositions[d.enrolledAt].y);

// Rejected students fading out
rejectedCircles.transition()
    .duration(800)
    .attr("opacity", 0)
    .attr("r", 0)
    .remove();

Tier Color Scales

const tierColors = {
    HYPSM:     "#FFD700",  // Gold
    IvyPlus:   "#4169E1",  // Royal Blue
    NearIvy:   "#2DD4BF",  // Teal
    Selective:  "#A78BFA", // Violet
    LAC:       "#F97316"   // Orange
};

const tierScale = d3.scaleOrdinal()
    .domain(Object.keys(tierColors))
    .range(Object.values(tierColors));

// Continuous prestige color scale (1-10)
const prestigeScale = d3.scaleSequential(d3.interpolateYlOrRd)
    .domain([1, 10]);

Animating Thousands of Nodes Efficiently in D3

For 2000+ student nodes, standard D3 SVG becomes slow. Strategies:

  1. Use <circle> elements with minimal attributes -- no filters, no gradients, no text
  2. Batch enter/update/exit with .join() API (D3 v7) instead of manual enter/exit
  3. Use CSS transitions instead of D3 transitions for simple property changes
  4. Throttle tick handlers to every 2nd or 3rd frame: javascript let frameCount = 0; simulation.on("tick", () => { if (++frameCount % 2 === 0) ticked(); // render every other frame });
  5. Switch to Canvas for the student particles (see Section 3)

3. Canvas API vs SVG for Scale

Performance Crossover Points

Element Count SVG (60fps?) Canvas 2D (60fps?) Recommendation
< 200 Yes Yes SVG (easier interactivity)
200-1,000 Usually Yes SVG with care
1,000-3,000 Stutters Yes Hybrid or Canvas
3,000-5,000 Unusable Yes Canvas required
5,000-10,000 No Possible Canvas + optimizations
10,000+ No Struggles WebGL or OffscreenCanvas

Why SVG Degrades

Canvas 2D Approach for Student Particles

class ParticleRenderer {
    constructor(canvas, width, height) {
        this.ctx = canvas.getContext('2d');
        this.width = width;
        this.height = height;
    }

    drawStudents(students, collegePositions) {
        const ctx = this.ctx;
        ctx.clearRect(0, 0, this.width, this.height);

        // Batch by color for fewer state changes
        const byColor = new Map();
        for (const s of students) {
            const color = tierColors[s.enrolledTier] || '#888';
            if (!byColor.has(color)) byColor.set(color, []);
            byColor.get(color).push(s);
        }

        for (const [color, group] of byColor) {
            ctx.fillStyle = color;
            ctx.globalAlpha = 0.7;
            ctx.beginPath();
            for (const s of group) {
                ctx.moveTo(s.x + 3, s.y);
                ctx.arc(s.x, s.y, 3, 0, Math.PI * 2);
            }
            ctx.fill(); // Single fill call per color group
        }
    }

    drawArcs(enrollments, studentMap, collegePositions) {
        const ctx = this.ctx;
        ctx.lineWidth = 0.5;
        ctx.globalAlpha = 0.3;

        for (const e of enrollments) {
            const s = studentMap.get(e.studentId);
            const c = collegePositions[e.collegeId];
            const midX = (s.x + c.x) / 2;
            const midY = Math.min(s.y, c.y) - 30; // arc height

            ctx.strokeStyle = tierColors[c.tier];
            ctx.beginPath();
            ctx.moveTo(s.x, s.y);
            ctx.quadraticCurveTo(midX, midY, c.x, c.y);
            ctx.stroke();
        }
    }
}
+--------------------------------------------------+
|  SVG Layer (foreground)                           |
|  - College labels and icons                      |
|  - Tooltips and info panels                      |
|  - Axes, legends, annotations                    |
|  - Interactive hover targets (invisible rects)   |
|  - ~50-100 elements max                          |
+--------------------------------------------------+
|  Canvas Layer (background)                        |
|  - Student particles (2000+ dots)                |
|  - Bezier arc connections                        |
|  - Animated transitions (lerped positions)       |
|  - Hit-testing via color-picking on 2nd canvas   |
+--------------------------------------------------+

Implementation:

<div id="viz" style="position: relative;">
    <canvas id="particle-canvas" style="position: absolute; z-index: 1;"></canvas>
    <svg id="chrome-svg" style="position: absolute; z-index: 2;"></svg>
</div>

OffscreenCanvas with Web Workers (Advanced)

For 5000+ students with continuous animation:

// main thread
const offscreen = document.getElementById('particle-canvas').transferControlToOffscreen();
const worker = new Worker(URL.createObjectURL(new Blob([`
    let canvas, ctx;
    self.onmessage = (e) => {
        if (e.data.type === 'init') {
            canvas = e.data.canvas;
            ctx = canvas.getContext('2d');
        }
        if (e.data.type === 'frame') {
            drawParticles(e.data.students, ctx, canvas.width, canvas.height);
        }
    };
    function drawParticles(students, ctx, w, h) {
        ctx.clearRect(0, 0, w, h);
        // ... batch drawing as above
    }
`], { type: 'text/javascript' })));
worker.postMessage({ type: 'init', canvas: offscreen }, [offscreen]);

4. JSON Schema Design for Agents

Student Agent Schema (Complete, Annotated)

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "StudentAgent",
  "description": "A student agent in the college admissions simulation",
  "type": "object",
  "required": ["id", "gpa", "sat", "archetype", "schoolId", "wealthTier", "applicationList"],
  "properties": {
    "id": {
      "type": "string",
      "description": "Unique student identifier, format: 'S-{schoolId}-{index}'"
    },
    "schoolId": {
      "type": "string",
      "description": "ID of the student's high school"
    },

    "// --- ACADEMIC PROFILE ---": {},

    "gpa": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 4.0,
      "description": "Unweighted GPA (4.0 scale). Correlated with SAT via Cholesky decomposition (r ~ 0.75)"
    },
    "sat": {
      "type": "integer",
      "minimum": 400,
      "maximum": 1600,
      "description": "SAT composite score. Generated jointly with GPA to maintain realistic correlation"
    },
    "act": {
      "type": ["integer", "null"],
      "minimum": 1,
      "maximum": 36,
      "description": "ACT composite score (optional, converted to SAT-equivalent for scoring). Null if student only took SAT"
    },
    "satSuperscored": {
      "type": "integer",
      "minimum": 400,
      "maximum": 1600,
      "description": "Best section scores across multiple sittings. Typically SAT + U(0, 40)"
    },
    "classRank": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Percentile rank within high school (1.0 = top of class). Derived from GPA relative to school mean"
    },
    "courseRigor": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Fraction of available AP/IB courses taken. 1.0 = maxed out school offerings"
    },

    "// --- EXTRACURRICULAR & ESSAY ---": {},

    "ecStrength": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Normalized extracurricular quality (0=none, 0.5=solid clubs, 0.8=state level, 1.0=national/international)"
    },
    "essayStrength": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Essay quality. Partially correlated with wealth tier (access to counselors). Mean ~0.5, SD ~0.15"
    },
    "interviewScore": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Optional interview score. Generated only for colleges that interview (most Ivies)"
    },

    "// --- DEMOGRAPHICS & HOOKS ---": {},

    "archetype": {
      "type": "string",
      "enum": [
        "academic_star",
        "well_rounded",
        "athlete_recruit",
        "legacy_applicant",
        "first_gen",
        "creative_talent",
        "stem_focused",
        "average_applicant"
      ],
      "description": "Student archetype determining base stat distributions and behavior patterns"
    },
    "hooks": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": ["athlete", "legacy", "donor", "firstgen", "urm", "faculty_child", "sibling"]
      },
      "description": "Admissions hooks that provide scoring multipliers. A student can have multiple hooks"
    },
    "wealthTier": {
      "type": "integer",
      "minimum": 1,
      "maximum": 5,
      "description": "Family wealth (1=low income/Pell eligible, 2=working class, 3=middle class, 4=upper middle, 5=wealthy/donor)"
    },
    "race": {
      "type": "string",
      "enum": ["white", "asian", "black", "hispanic", "native", "multiracial", "international"],
      "description": "Used only for URM hook classification and demographic reporting"
    },
    "international": {
      "type": "boolean",
      "description": "International student flag. Affects financial aid eligibility and some schools' admission pools"
    },
    "needsFinAid": {
      "type": "boolean",
      "description": "Whether student requires financial aid. Affects need-aware schools' decisions"
    },

    "// --- SCHOOL CONTEXT ---": {},

    "schoolType": {
      "type": "string",
      "enum": ["public", "private", "magnet", "charter", "boarding", "homeschool"],
      "description": "Type of high school attended"
    },
    "feederSchool": {
      "type": "boolean",
      "description": "True if school is a known feeder to elite colleges. Provides implicit credibility boost"
    },
    "schoolPrestige": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "School's prestige/rigor rating. Contextualizes GPA (a 3.8 from a magnet > 3.8 from low-ranked public)"
    },

    "// --- APPLICATION STRATEGY ---": {},

    "applicationList": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Ordered list of college IDs this student is applying to (typically 8-20 schools)"
    },
    "edChoice": {
      "type": ["string", "null"],
      "description": "College ID for Early Decision (binding). Null if not applying ED"
    },
    "ediiChoice": {
      "type": ["string", "null"],
      "description": "College ID for ED II (binding, after ED deferral/rejection). Null if not applying EDII"
    },
    "eaChoices": {
      "type": "array",
      "items": { "type": "string" },
      "description": "College IDs for Early Action applications. Can include one REA school"
    },
    "reaChoice": {
      "type": ["string", "null"],
      "description": "College ID for Restrictive Early Action (e.g., Harvard, Yale, Stanford, Princeton, Notre Dame)"
    },

    "// --- SIMULATION STATE (mutable) ---": {},

    "decisions": {
      "type": "object",
      "additionalProperties": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": ["pending", "admitted", "rejected", "deferred", "waitlisted", "withdrawn", "enrolled", "declined"]
          },
          "round": {
            "type": "string",
            "enum": ["ED", "EA", "REA", "EDII", "RD", "WL"]
          },
          "score": {
            "type": "number",
            "description": "The admissions score this student received from this college"
          },
          "aidOffered": {
            "type": "number",
            "description": "Financial aid package amount (if admitted)"
          }
        }
      },
      "description": "Map of collegeId -> decision object. Updated each round"
    },
    "enrolledAt": {
      "type": ["string", "null"],
      "description": "College ID where student ultimately enrolls. Null until Decision Day"
    },
    "committed": {
      "type": "boolean",
      "default": false,
      "description": "True if student has committed (ED acceptance = immediately true)"
    },

    "// --- COMPUTED SCORES (cached) ---": {},

    "academicIndex": {
      "type": "number",
      "description": "Computed academic index: sigmoid(GPA_norm * 0.4 + SAT_norm * 0.4 + rigor * 0.2). Range [0, 1]"
    }
  }
}

College Agent Schema (Complete, Annotated)

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "CollegeAgent",
  "description": "A college agent in the admissions simulation",
  "type": "object",
  "required": ["id", "name", "tier", "acceptanceRate", "targetClassSize"],
  "properties": {
    "id": {
      "type": "string",
      "description": "Unique college identifier, e.g., 'harvard', 'mit', 'williams'"
    },
    "name": {
      "type": "string",
      "description": "Display name, e.g., 'Harvard University'"
    },

    "// --- CLASSIFICATION ---": {},

    "tier": {
      "type": "string",
      "enum": ["HYPSM", "IvyPlus", "NearIvy", "Selective", "LAC"],
      "description": "College tier for visualization coloring and behavior grouping"
    },
    "prestige": {
      "type": "number",
      "minimum": 1.0,
      "maximum": 10.0,
      "description": "Prestige score. HYPSM=9-10, Ivy+=7.5-9, NearIvy=6.5-7.5, Selective=5.5-6.5, LAC=6-8"
    },
    "usnewsRank": {
      "type": "integer",
      "minimum": 1,
      "description": "US News national ranking (used for student preference ordering)"
    },

    "// --- ADMISSIONS STATISTICS ---": {},

    "acceptanceRate": {
      "type": "number",
      "minimum": 0.01,
      "maximum": 1.0,
      "description": "Overall acceptance rate (e.g., 0.032 for Harvard = 3.2%)"
    },
    "edAcceptanceRate": {
      "type": "number",
      "minimum": 0.01,
      "maximum": 1.0,
      "description": "Early Decision acceptance rate. Typically 2-4x the RD rate"
    },
    "eaAcceptanceRate": {
      "type": ["number", "null"],
      "description": "Early Action acceptance rate. Null if EA not offered"
    },
    "reaAcceptanceRate": {
      "type": ["number", "null"],
      "description": "Restrictive Early Action acceptance rate. Null if REA not offered"
    },
    "deferralRate": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Fraction of early applicants deferred to RD pool (typically 0.5-0.8)"
    },
    "waitlistRate": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 0.5,
      "description": "Fraction of RD applicants placed on waitlist"
    },
    "waitlistAdmitRate": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Fraction of waitlisted students eventually admitted (highly variable, 0-0.4)"
    },

    "// --- CLASS COMPOSITION TARGETS ---": {},

    "targetClassSize": {
      "type": "integer",
      "minimum": 100,
      "description": "Target enrolled class size (e.g., Harvard ~1660, Williams ~550)"
    },
    "totalApplicants": {
      "type": "integer",
      "description": "Approximate total applicants per year (for calibration)"
    },
    "yieldRate": {
      "type": "number",
      "minimum": 0.1,
      "maximum": 1.0,
      "description": "Historical yield: fraction of admitted students who enroll. Harvard ~0.82, Cornell ~0.53"
    },
    "edFillRate": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Fraction of class typically filled via ED/REA (e.g., 0.45-0.55 for many Ivies)"
    },

    "// --- ACADEMIC PROFILE ---": {},

    "satP25": {
      "type": "integer",
      "minimum": 400,
      "maximum": 1600,
      "description": "25th percentile SAT of enrolled students"
    },
    "satP50": {
      "type": "integer",
      "minimum": 400,
      "maximum": 1600,
      "description": "Median SAT of enrolled students"
    },
    "satP75": {
      "type": "integer",
      "minimum": 400,
      "maximum": 1600,
      "description": "75th percentile SAT of enrolled students"
    },
    "gpaAvg": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 4.0,
      "description": "Average unweighted GPA of enrolled students"
    },

    "// --- HOOK & ALDC CAPACITY ---": {},

    "aldcCapacity": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 0.5,
      "description": "Fraction of class reserved for ALDC (Athletes, Legacies, Dean's list/Donors, Children of faculty). Typically 0.15-0.30"
    },
    "athleteSlots": {
      "type": "integer",
      "description": "Number of recruited athlete slots per class"
    },
    "legacyBoost": {
      "type": "number",
      "minimum": 1.0,
      "maximum": 5.0,
      "description": "Scoring multiplier for legacy applicants (typically 2.0-3.0)"
    },
    "donorBoost": {
      "type": "number",
      "minimum": 1.0,
      "maximum": 10.0,
      "description": "Scoring multiplier for major donor children (typically 3.0-5.0)"
    },
    "athleteBoost": {
      "type": "number",
      "minimum": 1.0,
      "maximum": 10.0,
      "description": "Scoring multiplier for recruited athletes (typically 3.0-4.0)"
    },
    "firstGenBoost": {
      "type": "number",
      "minimum": 1.0,
      "maximum": 3.0,
      "description": "Scoring multiplier for first-generation applicants (typically 1.2-1.5)"
    },

    "// --- ROUND CONFIGURATION ---": {},

    "edOffered": {
      "type": "boolean",
      "description": "Does this college offer Early Decision? (most private schools)"
    },
    "eaOffered": {
      "type": "boolean",
      "description": "Does this college offer unrestricted Early Action? (e.g., MIT, UChicago)"
    },
    "reaOffered": {
      "type": "boolean",
      "description": "Does this college offer Restrictive Early Action? (Harvard, Yale, Princeton, Stanford)"
    },
    "ediiOffered": {
      "type": "boolean",
      "description": "Does this college offer Early Decision II? (e.g., Vanderbilt, WashU, Tufts)"
    },

    "// --- YIELD PROTECTION ---": {},

    "yieldProtection": {
      "type": "boolean",
      "description": "Does this college practice yield protection (rejecting overqualified applicants)?"
    },
    "yieldProtectionThreshold": {
      "type": "number",
      "minimum": 0.0,
      "maximum": 1.0,
      "description": "Academic index above which yield protection may trigger. College suspects student won't attend"
    },

    "// --- FINANCIAL AID ---": {},

    "needBlind": {
      "type": "boolean",
      "description": "Need-blind admissions? If false, requiring aid may reduce admission chances"
    },
    "meetsFullNeed": {
      "type": "boolean",
      "description": "Meets 100% of demonstrated financial need?"
    },
    "avgAidPackage": {
      "type": "number",
      "description": "Average financial aid award for aided students"
    },

    "// --- SIMULATION STATE (mutable) ---": {},

    "slotsRemaining": {
      "type": "integer",
      "description": "Remaining class slots available (decremented as students commit)"
    },
    "aldcSlotsRemaining": {
      "type": "integer",
      "description": "Remaining ALDC slots"
    },
    "applicantPool": {
      "type": "object",
      "properties": {
        "ed": { "type": "array", "items": { "type": "string" }, "description": "Student IDs who applied ED" },
        "ea": { "type": "array", "items": { "type": "string" }, "description": "Student IDs who applied EA/REA" },
        "edii": { "type": "array", "items": { "type": "string" }, "description": "Student IDs who applied EDII" },
        "rd": { "type": "array", "items": { "type": "string" }, "description": "Student IDs who applied RD (includes deferred)" },
        "waitlist": { "type": "array", "items": { "type": "string" }, "description": "Student IDs on waitlist" }
      },
      "description": "Pools of applicants by round"
    },
    "admitted": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Student IDs admitted so far (across all rounds)"
    },
    "enrolled": {
      "type": "array",
      "items": { "type": "string" },
      "description": "Student IDs who have committed to enroll"
    }
  }
}

High School Schema (Supporting)

{
  "title": "HighSchoolAgent",
  "type": "object",
  "properties": {
    "id": { "type": "string", "description": "e.g., 'hs-exeter', 'hs-stuyvesant'" },
    "name": { "type": "string" },
    "type": {
      "type": "string",
      "enum": ["public", "private_day", "boarding", "magnet", "charter", "parochial"]
    },
    "region": {
      "type": "string",
      "enum": ["northeast", "southeast", "midwest", "west", "southwest", "international"]
    },
    "isFeeder": { "type": "boolean", "description": "Known feeder to T20 colleges" },
    "prestige": { "type": "number", "minimum": 0, "maximum": 1.0 },
    "studentCount": { "type": "integer", "description": "Graduating class size" },
    "gpaMean": { "type": "number", "description": "Mean unweighted GPA of graduating class" },
    "gpaSD": { "type": "number", "description": "Standard deviation of GPA" },
    "satMean": { "type": "integer", "description": "Mean SAT score" },
    "satSD": { "type": "integer", "description": "Standard deviation of SAT" },
    "apOffered": { "type": "integer", "description": "Number of AP courses offered (0-30+)" },
    "collegeGoingRate": { "type": "number", "description": "Fraction attending 4-year college" },
    "archetypeDistribution": {
      "type": "object",
      "description": "Probability distribution over student archetypes, must sum to 1.0",
      "properties": {
        "academic_star": { "type": "number" },
        "well_rounded": { "type": "number" },
        "athlete_recruit": { "type": "number" },
        "legacy_applicant": { "type": "number" },
        "first_gen": { "type": "number" },
        "creative_talent": { "type": "number" },
        "stem_focused": { "type": "number" },
        "average_applicant": { "type": "number" }
      }
    },
    "wealthDistribution": {
      "type": "array",
      "items": { "type": "number" },
      "description": "5-element array: probability of wealth tiers [1,2,3,4,5]"
    },
    "hookRates": {
      "type": "object",
      "description": "Probability of each hook occurring in students from this school",
      "properties": {
        "athlete": { "type": "number" },
        "legacy": { "type": "number" },
        "donor": { "type": "number" },
        "firstgen": { "type": "number" },
        "urm": { "type": "number" }
      }
    }
  }
}

5. Simulation Architecture Patterns

Tick-Based vs Event-Driven: Recommendation

Criterion Tick-Based Event-Driven
College admissions rounds Natural fit (6 discrete rounds) Overcomplicated
Visualization sync Easy (render after each tick) Must aggregate events
Debugging Step through rounds linearly Harder to trace event chains
Performance Predictable, batch-friendly Event queue overhead
Implementation Simple loop Need priority queue

Verdict: Tick-based is far superior for this domain. College admissions happen in discrete, sequential rounds with clear boundaries. Event-driven simulation is overkill.

Main Simulation Loop Architecture

 +---------+     +---------+     +---------+     +---------+     +---------+     +---------+
 |  TICK 1 |---->|  TICK 2 |---->|  TICK 3 |---->|  TICK 4 |---->|  TICK 5 |---->|  TICK 6 |
 |   ED    |     | EA/REA  |     |  EDII   |     |   RD    |     |Decision |     |Waitlist |
 +---------+     +---------+     +---------+     +---------+     +---------+     +---------+
      |               |               |               |               |               |
      v               v               v               v               v               v
  Score ED        Score EA         Score EDII     Score RD         Students       Pull from
  applicants      applicants       applicants     applicants       choose best    waitlist
  Admit/Reject    Admit/Defer      Admit/Reject   Admit/WL/Rej    enrollment     to fill gaps
  ED commits      /Reject                                          Decline rest

Pseudocode: Main Simulation Loop

class AdmissionsSimulation {
    constructor(config) {
        this.students = [];        // all StudentAgent objects
        this.colleges = [];        // all CollegeAgent objects
        this.rng = mulberry32(config.seed);   // seeded RNG
        this.roundOrder = ['ED', 'EA_REA', 'EDII', 'RD', 'DECISION', 'WAITLIST'];
        this.currentRound = 0;
        this.results = [];         // per-round snapshots for visualization
    }

    initialize() {
        // 1. Generate students from high school distributions
        for (const school of this.config.highSchools) {
            this.generateStudents(school);
        }
        // 2. Build application lists for each student
        for (const student of this.students) {
            this.buildApplicationList(student);
        }
        // 3. Route applications to college pools
        this.routeApplications();
    }

    run() {
        this.initialize();
        for (const round of this.roundOrder) {
            this.executeRound(round);
            this.results.push(this.snapshot(round));
        }
        return this.results;
    }

    executeRound(round) {
        switch (round) {
            case 'ED':
                this.processEarlyDecision();
                break;
            case 'EA_REA':
                this.processEarlyAction();
                break;
            case 'EDII':
                this.processEDII();
                break;
            case 'RD':
                this.processRegularDecision();
                break;
            case 'DECISION':
                this.processStudentDecisions();
                break;
            case 'WAITLIST':
                this.processWaitlist();
                break;
        }
    }

    // --- Core scoring (used by all admission rounds) ---

    scoreApplicant(student, college) {
        // Academic Index (0-1): sigmoid of weighted GPA + SAT + rigor
        const gpaNorm = student.gpa / 4.0;
        const satNorm = (student.sat - 400) / 1200;
        const rigorNorm = student.courseRigor;
        const rawAcademic = gpaNorm * 0.4 + satNorm * 0.4 + rigorNorm * 0.2;
        const academicIndex = 1 / (1 + Math.exp(-10 * (rawAcademic - 0.5)));

        // Extracurricular + Essay component (0-1)
        const softScore = student.ecStrength * 0.5 + student.essayStrength * 0.5;

        // Base score
        let score = academicIndex * 0.6 + softScore * 0.4;

        // Hook multipliers (compound)
        for (const hook of student.hooks) {
            score *= this.getHookMultiplier(hook, college);
        }

        // Round multiplier (ED gets a boost)
        score *= this.getRoundMultiplier(student, college);

        // School context bonus
        if (student.feederSchool) score *= 1.1;

        // Randomness: +-25%
        const noise = 1 + (this.rng() - 0.5) * 0.5;  // range [0.75, 1.25]
        score *= noise;

        // Yield protection check
        if (college.yieldProtection && academicIndex > college.yieldProtectionThreshold) {
            const roundName = this.getCurrentRoundForStudent(student, college.id);
            if (roundName === 'RD') {  // only in RD, not early rounds
                score *= 0.6;  // significant penalty
            }
        }

        return score;
    }

    getHookMultiplier(hook, college) {
        const multipliers = {
            athlete: college.athleteBoost || 3.5,
            donor: college.donorBoost || 4.0,
            legacy: college.legacyBoost || 2.5,
            firstgen: college.firstGenBoost || 1.4,
            faculty_child: 2.0,
            sibling: 1.3,
            urm: 1.3
        };
        return multipliers[hook] || 1.0;
    }
}

State Management: Student Application Tracking

Each student's decisions object serves as a state machine per college:

                      +-----------+
                      |  PENDING  |
                      +-----+-----+
                            |
              +-------------+-------------+
              |             |             |
        +-----v---+  +-----v-----+  +----v----+
        | ADMITTED |  | DEFERRED  |  | REJECTED|
        +-----+---+  +-----+-----+  +---------+
              |             |
              |      +------v------+
              |      |  (re-enter  |
              |      |  RD pool)   |
              |      +------+------+
              |             |
              |   +---------+---------+
              |   |         |         |
              | +-v---+ +---v----+ +--v------+
              | |ADMIT| |WAITLIST| | REJECTED|
              | +--+--+ +---+----+ +---------+
              |    |        |
         +----v----v--+  +--v---------+
         |  ENROLLED  |  | WL_ADMITTED|
         | (committed)|  +-----+------+
         +-----+------+       |
               |          +----v------+
               |          |  ENROLLED |
               |          +-----------+
         +-----v------+
         |  DECLINED   |
         +-------------+

Yield Cascade (Decision Day + Waitlist)

processStudentDecisions() {
    // Sort students by quality (best students choose first, or all choose simultaneously)
    // Each student picks their top admitted school based on preference ordering

    for (const student of this.students) {
        if (student.committed) continue;  // Already committed via ED/EDII

        const admissions = Object.entries(student.decisions)
            .filter(([_, d]) => d.status === 'admitted')
            .map(([collegeId, d]) => ({
                collegeId,
                college: this.collegeMap.get(collegeId),
                aidOffered: d.aidOffered
            }));

        if (admissions.length === 0) continue;

        // Student preference: prestige > financial fit > personal fit
        admissions.sort((a, b) => {
            // Factor 1: Prestige (weighted heavily)
            const prestigeDiff = b.college.prestige - a.college.prestige;
            if (Math.abs(prestigeDiff) > 0.5) return prestigeDiff;

            // Factor 2: Financial fit (if aid needed)
            if (student.needsFinAid) {
                const aidDiff = (b.aidOffered || 0) - (a.aidOffered || 0);
                if (aidDiff !== 0) return aidDiff;
            }

            // Factor 3: Small random factor for personal preference
            return this.rng() - 0.5;
        });

        // Enroll at top choice
        const choice = admissions[0];
        student.enrolledAt = choice.collegeId;
        student.decisions[choice.collegeId].status = 'enrolled';

        // Decline all others
        for (const other of admissions.slice(1)) {
            student.decisions[other.collegeId].status = 'declined';
        }

        // Update college state
        const college = this.collegeMap.get(choice.collegeId);
        college.enrolled.push(student.id);
        college.slotsRemaining--;
    }
}

processWaitlist() {
    // After Decision Day, some colleges are under-enrolled
    // Pull from waitlist in score order until class is filled

    for (const college of this.colleges) {
        const deficit = college.targetClassSize - college.enrolled.length;
        if (deficit <= 0 || college.applicantPool.waitlist.length === 0) continue;

        // Score and sort waitlisted students
        const waitlisted = college.applicantPool.waitlist
            .map(sid => ({ student: this.studentMap.get(sid), score: 0 }))
            .filter(({ student }) => !student.committed)  // Skip already committed
            .map(entry => {
                entry.score = this.scoreApplicant(entry.student, college);
                return entry;
            })
            .sort((a, b) => b.score - a.score);

        // Admit top students off waitlist until deficit filled
        let filled = 0;
        for (const { student } of waitlisted) {
            if (filled >= deficit) break;

            student.decisions[college.id].status = 'admitted';

            // Student must decide: accept waitlist offer or keep current choice?
            if (this.studentPrefersWaitlistOffer(student, college)) {
                // Withdraw from previous enrollment
                if (student.enrolledAt) {
                    const prev = this.collegeMap.get(student.enrolledAt);
                    prev.enrolled = prev.enrolled.filter(id => id !== student.id);
                    prev.slotsRemaining++;
                    student.decisions[student.enrolledAt].status = 'declined';
                }

                student.enrolledAt = college.id;
                student.decisions[college.id].status = 'enrolled';
                student.committed = true;
                college.enrolled.push(student.id);
                college.slotsRemaining--;
                filled++;
            }
        }
    }
}

Architecture Diagram

+=========================================================================+
|                       ADMISSIONS SIMULATION ENGINE                       |
+=========================================================================+
|                                                                         |
|  +------------------+    +-----------------+    +--------------------+  |
|  | STUDENT GENERATOR|    | COLLEGE CONFIG  |    | APPLICATION LIST   |  |
|  | (from HS dists)  |--->| (from JSON)     |--->| BUILDER            |  |
|  +------------------+    +-----------------+    | (strategy per      |  |
|         |                       |               |  archetype)        |  |
|         v                       v               +--------------------+  |
|  +------+------+         +------+------+                |               |
|  | Student[]   |         | College[]   |                |               |
|  | (2000 objs) |         | (30 objs)   |<---------------+               |
|  +------+------+         +------+------+                                |
|         |                       |                                       |
|         +----------+------------+                                       |
|                    |                                                    |
|                    v                                                    |
|  +==========================================+                           |
|  |         ROUND EXECUTOR (tick loop)       |                           |
|  |==========================================|                           |
|  | for round in [ED, EA, EDII, RD, DEC, WL]|                           |
|  |   1. Get applicant pool for round        |                           |
|  |   2. Score all applicants                |                           |
|  |   3. Sort by score descending            |                           |
|  |   4. Admit top N (within capacity)       |                           |
|  |   5. Handle deferrals/waitlists          |                           |
|  |   6. Update state machines               |                           |
|  |   7. Emit round snapshot                 |                           |
|  +==========================================+                           |
|                    |                                                    |
|                    v                                                    |
|  +-----------------+-------------------+                                |
|  | RESULTS: per-round snapshots        |                                |
|  | - Student positions (x,y for viz)   |                                |
|  | - Decision state per student/college|                                |
|  | - Aggregate stats (fill rates, etc) |                                |
|  +-------------------------------------+                                |
|                                                                         |
+=========================================================================+
                    |
                    v
+=========================================================================+
|                       VISUALIZATION LAYER                               |
+=========================================================================+
|                                                                         |
|  +-------------------+    +-------------------+   +------------------+  |
|  | Canvas Layer      |    | SVG Layer         |   | Controls         |  |
|  | (z-index: 1)      |    | (z-index: 2)      |   | (HTML/CSS)       |  |
|  |                   |    |                   |   |                  |  |
|  | - Student dots    |    | - College labels  |   | - Seed input     |  |
|  | - Bezier arcs     |    | - Tooltips        |   | - Student count  |  |
|  | - Particle effects|    | - Legend           |   | - Speed slider   |  |
|  +-------------------+    | - Sankey overlay  |   | - Round stepper  |  |
|                           +-------------------+   +------------------+  |
|                                                                         |
+=========================================================================+

6. Correlated Random Number Generation

Seeded PRNG: Mulberry32

Mulberry32 is the best choice for this project: tiny, fast, 32-bit state, full period of 2^32, and produces high-quality randomness.

/**
 * Mulberry32 seeded PRNG
 * @param {number} seed - 32-bit integer seed
 * @returns {function} - Returns float in [0, 1) on each call
 */
function mulberry32(seed) {
    return function() {
        seed |= 0;
        seed = seed + 0x6D2B79F5 | 0;
        let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
        t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
        return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
}

// Usage:
const rng = mulberry32(12345);
rng(); // 0.3730747371446341 (always the same for seed 12345)
rng(); // 0.6936038890853524 (deterministic sequence)

Box-Muller Transform (Uniform -> Normal)

Converts two uniform random numbers into two independent standard normal random numbers.

/**
 * Box-Muller transform: generates two standard normal variates from two uniform variates
 * @param {function} rng - Seeded PRNG returning uniform [0,1)
 * @returns {[number, number]} - Two independent standard normal values
 */
function boxMuller(rng) {
    // Ensure u1 is not 0 (log(0) is -Infinity)
    let u1 = rng();
    while (u1 === 0) u1 = rng();
    const u2 = rng();

    const r = Math.sqrt(-2.0 * Math.log(u1));
    const theta = 2.0 * Math.PI * u2;

    return [
        r * Math.cos(theta),  // z0 ~ N(0,1)
        r * Math.sin(theta)   // z1 ~ N(0,1)
    ];
}

/**
 * Generate a normally distributed random number with given mean and std dev
 * @param {function} rng - Seeded PRNG
 * @param {number} mean
 * @param {number} sd - Standard deviation
 * @returns {number}
 */
function normalRandom(rng, mean, sd) {
    const [z] = boxMuller(rng);
    return mean + z * sd;
}

Cholesky Decomposition for Correlated GPA/SAT

GPA and SAT are strongly correlated (~0.75). To generate realistic correlated pairs:

Mathematical Background:

Given a correlation matrix R:

R = | 1.00  0.75 |
    | 0.75  1.00 |

The Cholesky decomposition L satisfies R = L * L^T:

L = | 1.000   0.000 |
    | 0.750   0.661 |

Where L[1][1] = sqrt(1 - 0.75^2) = sqrt(0.4375) = 0.6614

To generate correlated pair (GPA, SAT): 1. Generate two independent standard normals: z1, z2 2. Multiply by L: x1 = z1, x2 = 0.75z1 + 0.661z2 3. Scale to GPA and SAT ranges

/**
 * 2x2 Cholesky decomposition
 * @param {number} rho - Correlation coefficient (-1 < rho < 1)
 * @returns {object} - Lower triangular matrix elements
 */
function cholesky2x2(rho) {
    return {
        l11: 1.0,
        l21: rho,
        l22: Math.sqrt(1 - rho * rho)
    };
}

/**
 * Generate correlated GPA/SAT pair
 * @param {function} rng - Seeded PRNG
 * @param {object} params - School-specific distribution parameters
 * @returns {object} - { gpa, sat }
 */
function generateCorrelatedGpaSat(rng, params) {
    const {
        gpaMean, gpaSD,
        satMean, satSD,
        correlation  // typically 0.70-0.80
    } = params;

    // Step 1: Two independent standard normals via Box-Muller
    const [z1, z2] = boxMuller(rng);

    // Step 2: Apply Cholesky decomposition to induce correlation
    const L = cholesky2x2(correlation);
    const x1 = L.l11 * z1;                    // = z1
    const x2 = L.l21 * z1 + L.l22 * z2;       // = rho*z1 + sqrt(1-rho^2)*z2

    // Step 3: Scale to GPA and SAT distributions
    let gpa = gpaMean + gpaSD * x1;
    let sat = satMean + satSD * x2;

    // Step 4: Clamp to valid ranges
    gpa = Math.max(0.0, Math.min(4.0, gpa));
    sat = Math.max(400, Math.min(1600, Math.round(sat / 10) * 10));  // round to nearest 10

    return { gpa: Math.round(gpa * 100) / 100, sat };
}

Generalized N-dimensional Cholesky (for future expansion)

If we later want correlated GPA, SAT, EC strength, and essay quality:

/**
 * General Cholesky decomposition for NxN positive-definite matrix
 * @param {number[][]} matrix - NxN correlation/covariance matrix
 * @returns {number[][]} - Lower triangular matrix L where matrix = L * L^T
 */
function choleskyDecomposition(matrix) {
    const n = matrix.length;
    const L = Array.from({ length: n }, () => new Float64Array(n));

    for (let i = 0; i < n; i++) {
        for (let j = 0; j <= i; j++) {
            let sum = 0;
            for (let k = 0; k < j; k++) {
                sum += L[i][k] * L[j][k];
            }
            if (i === j) {
                L[i][j] = Math.sqrt(matrix[i][i] - sum);
            } else {
                L[i][j] = (matrix[i][j] - sum) / L[j][j];
            }
        }
    }
    return L;
}

/**
 * Generate N correlated standard normals
 * @param {function} rng - Seeded PRNG
 * @param {number[][]} L - Cholesky lower triangular matrix
 * @returns {number[]} - N correlated standard normals
 */
function correlatedNormals(rng, L) {
    const n = L.length;
    // Generate N independent standard normals
    const z = [];
    for (let i = 0; i < n; i += 2) {
        const [z1, z2] = boxMuller(rng);
        z.push(z1);
        if (i + 1 < n) z.push(z2);
    }

    // Multiply by L
    const result = new Float64Array(n);
    for (let i = 0; i < n; i++) {
        for (let j = 0; j <= i; j++) {
            result[i] += L[i][j] * z[j];
        }
    }
    return result;
}

// Example: 4x4 correlation matrix for GPA, SAT, EC, Essay
const corrMatrix = [
  //  GPA    SAT    EC    Essay
    [ 1.00,  0.75,  0.30,  0.20 ],  // GPA
    [ 0.75,  1.00,  0.25,  0.15 ],  // SAT
    [ 0.30,  0.25,  1.00,  0.40 ],  // EC
    [ 0.20,  0.15,  0.40,  1.00 ],  // Essay
];
const L = choleskyDecomposition(corrMatrix);
const [gpaZ, satZ, ecZ, essayZ] = correlatedNormals(rng, L);

Additional Useful Distributions

/**
 * Beta distribution via rejection sampling (for EC/essay scores bounded [0,1])
 * @param {function} rng - Seeded PRNG
 * @param {number} alpha - Shape parameter 1
 * @param {number} beta - Shape parameter 2
 * @returns {number} - Value in [0, 1]
 */
function betaRandom(rng, alpha, beta) {
    // Use Joehnk's method for small alpha, beta
    // For larger values, use the gamma ratio method
    const gamma1 = gammaRandom(rng, alpha);
    const gamma2 = gammaRandom(rng, beta);
    return gamma1 / (gamma1 + gamma2);
}

/**
 * Gamma distribution (Marsaglia and Tsang's method)
 * Needed for beta distribution above
 */
function gammaRandom(rng, shape) {
    if (shape < 1) {
        return gammaRandom(rng, shape + 1) * Math.pow(rng(), 1 / shape);
    }
    const d = shape - 1/3;
    const c = 1 / Math.sqrt(9 * d);
    while (true) {
        let x, v;
        do {
            const [z] = boxMuller(rng);
            x = z;
            v = 1 + c * x;
        } while (v <= 0);
        v = v * v * v;
        const u = rng();
        if (u < 1 - 0.0331 * x * x * x * x) return d * v;
        if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) return d * v;
    }
}

/**
 * Weighted random selection (for archetypes, wealth tiers)
 * @param {function} rng - Seeded PRNG
 * @param {Array} items - Items to choose from
 * @param {number[]} weights - Corresponding weights (need not sum to 1)
 * @returns {*} - Selected item
 */
function weightedChoice(rng, items, weights) {
    const total = weights.reduce((a, b) => a + b, 0);
    let threshold = rng() * total;
    for (let i = 0; i < items.length; i++) {
        threshold -= weights[i];
        if (threshold <= 0) return items[i];
    }
    return items[items.length - 1];
}

7. Performance Benchmarks & Targets

Target Performance Budget

Operation Target Time Agent Count Notes
Student generation <200ms 2,000 Correlated GPA/SAT, archetype assignment
Application list building <100ms 2,000 x ~12 apps Strategy varies by archetype
Single round scoring <150ms 2,000 x 30 colleges Most students apply to 8-15 schools
All 6 rounds <1,000ms 2,000 Includes state updates
Results serialization <100ms 2,000 Snapshot for visualization
Total simulation <1,500ms 2,000 students, 30 colleges
D3 initial render <500ms 2,000 nodes Canvas particles + SVG chrome
Animated round transition 1,000ms 2,000 nodes rAF-driven, 60fps

Performance Optimization Strategies

1. Batch Processing Without Blocking UI

async function runSimulationAsync(config, onProgress) {
    const sim = new AdmissionsSimulation(config);
    sim.initialize();

    for (let i = 0; i < sim.roundOrder.length; i++) {
        const round = sim.roundOrder[i];
        sim.executeRound(round);
        onProgress({ round, progress: (i + 1) / sim.roundOrder.length, snapshot: sim.snapshot(round) });

        // Yield to browser every round (allows UI updates)
        await new Promise(resolve => setTimeout(resolve, 0));
    }

    return sim.getResults();
}

2. Progressive Rendering

function progressiveRender(simulation, renderer) {
    const rounds = ['ED', 'EA_REA', 'EDII', 'RD', 'DECISION', 'WAITLIST'];
    let roundIndex = 0;

    function step() {
        if (roundIndex >= rounds.length) {
            renderer.showFinalStats();
            return;
        }

        // Run one round
        simulation.executeRound(rounds[roundIndex]);
        const snapshot = simulation.snapshot(rounds[roundIndex]);

        // Animate the results of this round
        renderer.animateRound(snapshot, () => {
            // When animation completes, run next round
            roundIndex++;
            step();
        });
    }

    step();
}

3. Web Worker Architecture (for 5000+ students)

// Inline Web Worker (no separate file needed for single-HTML constraint)
const workerCode = `
    // Include simulation engine code here (or importScripts)

    self.onmessage = function(e) {
        if (e.data.type === 'run') {
            const sim = new AdmissionsSimulation(e.data.config);
            sim.initialize();

            for (const round of sim.roundOrder) {
                sim.executeRound(round);
                self.postMessage({
                    type: 'roundComplete',
                    round: round,
                    snapshot: sim.snapshot(round)
                });
            }

            self.postMessage({
                type: 'done',
                results: sim.getResults()
            });
        }
    };
`;

const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));

4. Memory-Efficient Data Structures

// For Monte Carlo (running simulation 100+ times):
// Use compact representation instead of full objects

class CompactStudentStore {
    constructor(capacity) {
        this.count = 0;
        this.capacity = capacity;

        // 4 bytes each, cache-friendly contiguous arrays
        this.gpa = new Float32Array(capacity);
        this.sat = new Uint16Array(capacity);
        this.ecStrength = new Float32Array(capacity);
        this.essayStrength = new Float32Array(capacity);
        this.academicIndex = new Float32Array(capacity);
        this.archetype = new Uint8Array(capacity);   // enum index
        this.wealthTier = new Uint8Array(capacity);
        this.hookBitmask = new Uint8Array(capacity);  // bit flags: athlete=1, legacy=2, donor=4, etc.
        this.enrolledAt = new Int8Array(capacity);    // college index (-1 = none)

        // Application decisions: 2000 students x 30 colleges = 60,000 entries
        // Use Uint8Array with status enum
        this.decisions = new Uint8Array(capacity * 30);  // 30 max colleges
    }

    getDecision(studentIdx, collegeIdx) {
        return this.decisions[studentIdx * 30 + collegeIdx];
    }

    setDecision(studentIdx, collegeIdx, status) {
        this.decisions[studentIdx * 30 + collegeIdx] = status;
    }
}

// Status enum for compact storage
const STATUS = {
    NONE: 0,
    PENDING: 1,
    ADMITTED: 2,
    REJECTED: 3,
    DEFERRED: 4,
    WAITLISTED: 5,
    ENROLLED: 6,
    DECLINED: 7,
    WITHDRAWN: 8
};

5. Benchmarking Harness

function benchmark(label, fn) {
    const start = performance.now();
    const result = fn();
    const elapsed = performance.now() - start;
    console.log(`${label}: ${elapsed.toFixed(1)}ms`);
    return result;
}

// Usage:
const students = benchmark('Generate students', () => generateAllStudents(config));
const results = benchmark('Full simulation', () => simulation.run());
benchmark('Render', () => renderer.draw(results));

Scaling Projections

Student Count Est. Sim Time Rendering Recommended Approach
500 <200ms SVG only Synchronous, all SVG
1,000 <500ms SVG/Canvas hybrid Synchronous, hybrid viz
2,000 <1,500ms Canvas + SVG chrome Synchronous, hybrid viz
5,000 <4s Canvas required Web Worker + progressive
10,000 <10s Canvas + OffscreenCanvas Web Worker mandatory
50,000 (Monte Carlo) <30s total No per-student viz Worker, compact arrays, aggregate viz only

Summary of Key Recommendations

  1. Use vanilla JS -- No ABM framework needed. The domain is too specific (6 discrete rounds, no spatial model) for AgentScript or SIM.JS to add value.

  2. Tick-based simulation loop with 6 rounds. Synchronous execution for up to 2000 students; Web Worker for 5000+.

  3. Mulberry32 seeded PRNG for reproducibility. Box-Muller transform for normal distributions. Cholesky decomposition for correlated GPA/SAT pairs.

  4. Hybrid Canvas/SVG rendering -- Canvas for student particles and arcs, SVG for college labels, legends, and interactive elements.

  5. D3.js v7 for orchestration -- Use d3-sankey for flow diagrams, d3-force for optional layout, d3-transition for animated round progression, d3-scale for tier color mapping.

  6. Progressive rendering -- Run one round at a time, animate results, then proceed. Gives users a clear narrative of the admissions process.

  7. Compact data structures for Monte Carlo scenarios -- TypedArrays, bitmask hooks, enum status codes for memory efficiency when running 100+ simulation iterations.


References