Source: technical-implementation.md
| 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 |
For a college admissions simulation in a single HTML file, a custom approach is strongly recommended:
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
+----------------------------+
Key performance patterns for large agent counts:
// 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
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);
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}`;
}
// 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();
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]);
For 2000+ student nodes, standard D3 SVG becomes slow. Strategies:
<circle> elements with minimal attributes -- no filters, no gradients, no text.join() API (D3 v7) instead of manual enter/exitjavascript
let frameCount = 0;
simulation.on("tick", () => {
if (++frameCount % 2 === 0) ticked(); // render every other frame
});| 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 |
<circle> is a DOM nodeclass 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>
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]);
{
"$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]"
}
}
}
{
"$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"
}
}
}
{
"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" }
}
}
}
}
| 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.
+---------+ +---------+ +---------+ +---------+ +---------+ +---------+
| 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
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;
}
}
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 |
+-------------+
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++;
}
}
}
}
+=========================================================================+
| 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 | |
| +-------------------+ +------------------+ |
| |
+=========================================================================+
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)
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;
}
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 };
}
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);
/**
* 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];
}
| 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 |
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();
}
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();
}
// 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));
// 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
};
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));
| 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 |
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.