Where ideas percolate and thoughts brew

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);
    };
};