Technical Implementation: JavaScript ABM for College Admissions Simulation
technical-implementation.md
Technical Implementation: JavaScript ABM for College Admissions Simulation
Table of Contents
- JavaScript ABM Frameworks & Patterns
- D3.js for Admissions Visualization
- Canvas API vs SVG for Scale
- JSON Schema Design for Agents
- Simulation Architecture Patterns
- Correlated Random Number Generation
- 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:
- No spatial component -- AgentScript's patches/turtles grid is irrelevant to admissions
- Fixed rounds, not continuous time -- We have 6 discrete rounds (ED, EA/REA, EDII, RD, Decision Day, Waitlist), not continuous events
- Simple agent interactions -- Students apply, colleges decide; no emergent spatial behavior
- Single-file constraint -- External ABM libraries add unnecessary dependency management
- Full visualization control -- Custom rendering integrates better with D3.js than framework views
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);
};
Recommended Hybrid Approach
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:
- Typed Arrays for bulk data -- Store GPA/SAT as Float32Array for cache-friendly iteration
- 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/[email protected]/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:
- Use
<circle>elements with minimal attributes -- no filters, no gradients, no text - Batch enter/update/exit with
.join()API (D3 v7) instead of manual enter/exit - Use CSS transitions instead of D3 transitions for simple property changes
- 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 }); - 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
- SVG maintains a full DOM tree; each
<circle>is a DOM node - Browser must hit-test, style-compute, and layout every element
- DOM manipulation (enter/exit) triggers reflow
- Memory: each SVG element is ~1KB; 5000 elements = ~5MB DOM
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();
}
}
}
Hybrid Architecture (Recommended)
+--------------------------------------------------+
| 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 55 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, 55 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 55 colleges = 110,000 entries
// Use Uint8Array with status enum
this.decisions = new Uint8Array(capacity * 55); // 55 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
-
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.
-
Tick-based simulation loop with 6 rounds. Synchronous execution for up to 2000 students; Web Worker for 5000+.
-
Mulberry32 seeded PRNG for reproducibility. Box-Muller transform for normal distributions. Cholesky decomposition for correlated GPA/SAT pairs.
-
Hybrid Canvas/SVG rendering -- Canvas for student particles and arcs, SVG for college labels, legends, and interactive elements.
-
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.
-
Progressive rendering -- Run one round at a time, animate results, then proceed. Gives users a clear narrative of the admissions process.
-
Compact data structures for Monte Carlo scenarios -- TypedArrays, bitmask hooks, enum status codes for memory efficiency when running 100+ simulation iterations.
References
- AgentScript -- JavaScript ABM library
- SIM.JS -- Discrete event simulation in JS
- D3.js -- Data visualization library
- d3-sankey -- Sankey diagram plugin
- d3-force -- Force-directed graph layout
- Mulberry32 -- Seeded PRNG
- Box-Muller Transform -- Uniform to normal conversion
- Cholesky Decomposition -- Correlated random generation
- Canvas Optimization (MDN) -- Performance techniques
- OffscreenCanvas -- Worker-based rendering
- SVG vs Canvas Performance -- Benchmark comparison