The Feedback Trap
About This Sketch
Goodhart's Law made visible: two populations of particles navigate toward their respective targets. The amber particles receive constant feedback and cluster tightly around the metric—but as the metric drifts from the true goal, so do they. The sienna particles update infrequently, aimed only at the true goal; they wander between corrections but converge more reliably on what actually matters. Watch the drift bar fill as the metric decouples. This is the feedback trap in motion.
Algorithm
Visualizes Goodhart's Law in action: a "true goal" sits fixed at canvas center
while a "metric target" starts co-located but drifts away over time, tracing
a slow Lissajous-like path. Two populations of particles are shown:
Frequent-feedback particles (warm amber) receive constant corrections toward
the metric target—they cluster tightly around it, improving their "scores"
while drifting steadily away from the true goal as the metric decouples.
Sparse-feedback particles (dark sienna) only receive directional updates
every ~90 frames, aimed directly at the true goal. They meander between
updates but converge more reliably on what actually matters.
A drift bar in the top-left tracks how far the metric has decoupled from
the true goal. Trails on both populations reveal their paths over time,
making the divergence visible.
This sketch accompanies the post "The Feedback Trap" and visualizes why
optimizing for measurable proxies often corrupts the underlying goal.
Pseudocode
SETUP:
Initialize canvas (400x300)
Place true goal at center (200, 150)
Initialize metric position at true goal
Create 10 frequent-feedback particles at random positions
Create 10 sparse-feedback particles at random positions with random timers
DRAW (every frame):
Get current theme colors
Fade background (trail effect)
Increment frame counter
Compute Goodhart drift:
drift = min(frame * 0.22, 85)
metricX = TRUE_X + sin(frame * 0.018) * drift
metricY = TRUE_Y + cos(frame * 0.013) * drift * 0.55
Draw true goal (concentric rings, sienna)
Draw metric target (small circle, amber, at drifted position)
Draw dashed line from true goal to metric (shows gap)
For each frequent-feedback particle:
Apply attraction toward metric target (continuous, noisy)
Dampen velocity
Clamp to canvas bounds
Append to trail
Draw trail (fading amber) and particle dot
For each sparse-feedback particle:
Decrement timer
If timer <= 0:
Reset timer to ~90 frames
Set velocity directly toward true goal
Apply gentle damping
Clamp to canvas bounds
Append to trail
Draw trail (fading sienna) and particle dot
Draw drift bar (fills with amber as metric decouples)
Draw legend
Draw caption
Source Code
let sketch = function(p) {
// The Feedback Trap: Goodhart's Law visualized.
// A "true goal" sits at center. A "metric target" starts co-located
// but drifts away as feedback pressure accumulates (Goodhart drift).
// Frequent-feedback particles (accent1) chase the metric—improving
// their scores while diverging from the true goal.
// Sparse-feedback particles (accent2) aim directly at the true goal,
// updating only every ~90 frames; they converge more reliably.
// A drift bar shows how far the metric has decoupled from the goal.
const W = 400, H = 300;
const TRUE_X = 200, TRUE_Y = 150;
let metricX, metricY;
let frequent = [];
let sparse = [];
let frame = 0;
const TRAIL_LEN = 40;
p.setup = function() {
p.createCanvas(W, H);
p.randomSeed(42);
metricX = TRUE_X;
metricY = TRUE_Y;
for (let i = 0; i < 10; i++) {
frequent.push({
x: p.random(30, 370),
y: p.random(30, 270),
vx: 0, vy: 0,
trail: []
});
sparse.push({
x: p.random(30, 370),
y: p.random(30, 270),
vx: 0, vy: 0,
timer: Math.floor(p.random(0, 90)),
trail: []
});
}
};
p.draw = function() {
const colors = getThemeColors();
p.background(...colors.bg, 45);
frame++;
// Goodhart drift: metric target slowly decouples from true goal
let drift = Math.min(frame * 0.22, 85);
metricX = TRUE_X + Math.sin(frame * 0.018) * drift;
metricY = TRUE_Y + Math.cos(frame * 0.013) * drift * 0.55;
// --- True goal rings ---
p.noFill();
for (let r = 60; r >= 20; r -= 20) {
p.stroke(...colors.accent2, p.map(r, 20, 60, 160, 40));
p.strokeWeight(1);
p.ellipse(TRUE_X, TRUE_Y, r * 2, r * 2);
}
p.fill(...colors.accent2);
p.noStroke();
p.ellipse(TRUE_X, TRUE_Y, 10, 10);
p.fill(...colors.accent3, 160);
p.textSize(8);
p.textAlign(p.CENTER);
p.text('TRUE GOAL', TRUE_X, TRUE_Y + 40);
// --- Metric target ---
p.noFill();
p.stroke(...colors.accent1, 200);
p.strokeWeight(1.5);
p.ellipse(metricX, metricY, 22, 22);
p.fill(...colors.accent1, 220);
p.noStroke();
p.ellipse(metricX, metricY, 6, 6);
p.fill(...colors.accent1, 160);
p.textSize(8);
p.textAlign(p.CENTER);
p.text('METRIC', metricX, metricY + 18);
// Dashed line: true goal → metric (shows divergence)
p.stroke(...colors.accent3, 60);
p.strokeWeight(1);
let steps = 12;
for (let i = 0; i < steps; i++) {
if (i % 2 === 0) {
let x1 = p.lerp(TRUE_X, metricX, i / steps);
let y1 = p.lerp(TRUE_Y, metricY, i / steps);
let x2 = p.lerp(TRUE_X, metricX, (i + 0.7) / steps);
let y2 = p.lerp(TRUE_Y, metricY, (i + 0.7) / steps);
p.line(x1, y1, x2, y2);
}
}
// --- Frequent-feedback particles: chase metric ---
for (let pt of frequent) {
let dx = metricX - pt.x;
let dy = metricY - pt.y;
pt.vx += dx * 0.045 + p.random(-0.25, 0.25);
pt.vy += dy * 0.045 + p.random(-0.25, 0.25);
pt.vx *= 0.87;
pt.vy *= 0.87;
pt.x = p.constrain(pt.x + pt.vx, 4, W - 4);
pt.y = p.constrain(pt.y + pt.vy, 4, H - 4);
pt.trail.push({ x: pt.x, y: pt.y });
if (pt.trail.length > TRAIL_LEN) pt.trail.shift();
// Trail
p.noFill();
for (let i = 1; i < pt.trail.length; i++) {
let a = p.map(i, 0, pt.trail.length, 0, 90);
p.stroke(...colors.accent1, a);
p.strokeWeight(1);
p.line(pt.trail[i-1].x, pt.trail[i-1].y, pt.trail[i].x, pt.trail[i].y);
}
p.noStroke();
p.fill(...colors.accent1, 210);
p.ellipse(pt.x, pt.y, 6, 6);
}
// --- Sparse-feedback particles: update toward true goal every ~90 frames ---
for (let pt of sparse) {
pt.timer--;
if (pt.timer <= 0) {
pt.timer = 80 + Math.floor(p.random(20));
let dx = TRUE_X - pt.x;
let dy = TRUE_Y - pt.y;
let d = Math.sqrt(dx * dx + dy * dy) + 1;
pt.vx = (dx / d) * 2.2;
pt.vy = (dy / d) * 2.2;
}
pt.vx *= 0.97;
pt.vy *= 0.97;
pt.x = p.constrain(pt.x + pt.vx, 4, W - 4);
pt.y = p.constrain(pt.y + pt.vy, 4, H - 4);
pt.trail.push({ x: pt.x, y: pt.y });
if (pt.trail.length > TRAIL_LEN) pt.trail.shift();
// Trail
p.noFill();
for (let i = 1; i < pt.trail.length; i++) {
let a = p.map(i, 0, pt.trail.length, 0, 90);
p.stroke(...colors.accent2, a);
p.strokeWeight(1);
p.line(pt.trail[i-1].x, pt.trail[i-1].y, pt.trail[i].x, pt.trail[i].y);
}
p.noStroke();
p.fill(...colors.accent2, 210);
p.ellipse(pt.x, pt.y, 6, 6);
}
// --- Drift bar (top left) ---
let barX = 10, barY = 22, barW = 100, barH = 6;
let driftFraction = Math.min(drift / 85, 1);
p.noStroke();
p.fill(...colors.accent3, 30);
p.rect(barX, barY, barW, barH, 2);
p.fill(...colors.accent1, 160);
p.rect(barX, barY, barW * driftFraction, barH, 2);
p.fill(...colors.accent3, 160);
p.textSize(8);
p.textAlign(p.LEFT);
p.text('metric drift', barX, barY - 3);
// --- Legend ---
p.fill(...colors.accent1, 200);
p.ellipse(W - 118, 14, 6, 6);
p.fill(...colors.accent3, 180);
p.textSize(8);
p.textAlign(p.LEFT);
p.text('frequent feedback', W - 110, 18);
p.fill(...colors.accent2, 200);
p.ellipse(W - 118, 28, 6, 6);
p.fill(...colors.accent3, 180);
p.text('sparse feedback', W - 110, 32);
// Caption
p.fill(...colors.accent3, 55);
p.textAlign(p.CENTER);
p.textSize(8);
p.text('optimizing for the metric \u00b7 diverging from the goal', W / 2, H - 5);
};
};