Same earbuds, $74.06 and $93.47, from an engine I planted to be caught
Same $79.00 earbuds, $74.06 for one cohort and $93.47 for another, a 26.2% gap on matched demand signals, and I built the engine that did it. It is a contextual bandit that never sees race or income and priced 10,000 synthetic shoppers. Everything in that sentence is seeded on purpose: the engine (a LinUCB bandit with 7 price arms that I call ShopMart Dynamic Pricing), the Aura Wireless Earbuds, the two cohorts of 2,880 and 3,037 decisions, and the protected label itself, which is whether a shopper sits in a majority-minority ZIP. The audit never receives that label. I built the structure in so I would know exactly what an honest audit had to find; you cannot ethically A/B-test charging demographic groups different prices on real logs.
I built it as a stand-in for a retailer's engine after the FTC's $60M settlement with Instacart over its Eversight pricing experiments (FTC, December 2025). That was an outcome-based action, not a finding of group-based discrimination: the complaint documented identical items shown to different shoppers at prices up to 23% apart. My engine prices on ZIP income index, device tier, referral source, dwell time, four ordinary demand and session signals and a loyalty flag, and not one of those is a protected attribute. Three proxies hide in that list anyway, and one of them is a pair.
The hero card before anything runs: two matched synthetic cohorts, one SKU, and a 26.2% gap from an engine that never sees a protected attribute. The ZIP and device on each card are the cohort's representative profile, not its definition.
Referral passes, dwell time passes, and the pair I hid between them does not
I planted the third proxy specifically to beat a per-feature audit. Two of the ten audited inputs are obvious: Zip Income Idx carries |r| 0.95 and mutual information 1.00 against the protected cohort and moves its price +7.8% under counterfactual replay, and Device Tier clears the 0.30 Pearson line and the 0.05 MI line at 0.32 and 0.08. Both come back VIOLATION. The population underneath is built XOR-style: an organic referral with a long dwell time marks the protected cohort, and so does a comparison-site referral with a short dwell, while the other two combinations mark the advantaged cohort. Read one column at a time, Referral shows |r| 0.00 and MI 0.00, Dwell Time shows 0.17 and 0.03, and both come back PASS. The engine still prices on the pair, because my LinUCB carries referral × dwell cross-terms the way many production engines carry cross-terms or trees.
The row that changed the build is the one labelled Referral × Dwell Time, Interaction: |r| 0.00, joint mutual information 0.73, counterfactual +1.7%, VIOLATION. That 0.73 is an interaction gain of +0.71 over the best single feature in the pair. The moment I saw the per-feature columns clear both components while the joint row lit up red is why the audit now computes joint MI over pairs instead of looping over columns. Pearson cannot see this leakage; mutual information over the pair can. A compliance program that checks inputs one at a time is checking the wrong object, and the loop I wrote first did exactly that.
Premium Member, the input I made the audit refuse to call, correlates with the protected cohort at |r| 0.24 and is also a real loyalty signal, so the code returns ABSTAIN and routes it to legal review, while cart size, repeat rate, session count and hour all PASS.
The audit table on the synthetic population. Referral and Dwell Time each pass on their own; the Interaction row between them carries a joint mutual information of 0.73 and comes back VIOLATION.
Drop ZIP and device, and my gate still fails at 0.59
I did not trust the interaction row until I had tried to make it irrelevant. The standard defence, fairness through unawareness, is to drop ZIP and device and retrain, so I ran that as a benchmark baseline against the same gate. That gate is an external legal standard, the EEOC four-fifths rule (29 CFR 1607.4(D)) adapted to price tiers, and as shipped the engine fails it at 0.43 against the 0.80 threshold, because 36% of the protected cohort receives the favorable price tier against 83% of the advantaged cohort. Sixty-four percent of the protected cohort sits in the top price tier and the mean overcharge runs +15.6%. Remove ZIP and device, retrain, and the ratio moves from 0.43 to 0.59 and still fails. The benchmark card gives the reason as Compound Proxy Still Leaks.
When I scored the audit against the labels it never saw, on the same seeded 10,000-decision synthetic population, it flagged the 3 planted proxies with 0 legitimate signals wrongly flagged and 1 of 1 dual-use signal correctly abstained. I read that as a labeled-set self-check that the method recovers planted structure, never as an accuracy rate on real pricing data; on an engagement the same machinery produces evidence for a legal team to weigh.
The benchmark on the labeled synthetic set. Dropping ZIP and device only moves the four-fifths ratio from 0.43 to 0.59, because the referral × dwell pair still carries the protected class.
The cap I would have proposed in a meeting: a 0.2% cost, 29% priced to the edge, still a Fail
The first fix I reached for was a price cap, the one anyone can explain in a sentence: no price above 115% of a proxy-free fair reference. On this engine it costs 0.2% of revenue. It also still fails the gate at 0.59, and the remediation dialog prints the gaming signature beside it: 29% Priced At The Cap, Gamed To The Boundary. A revenue-maximizer treats a ceiling as a target. It re-priced 29% of the protected cohort to within 1% of the line, which is why the cap is cheap and why it would not survive discovery. Watching that number appear is when I stopped treating remediation as a knob.
The control I shipped instead is fairness-aware reward shaping. It pulls every price continuously toward a proxy-free reference (a second bandit trained with the four proxy inputs dropped) and binary-searches the smallest pull weight that clears 0.80. On this engine that weight is 0.65, the four-fifths ratio lands at 0.82, Pass, and the revenue cost is 1.3%. With no edge for the optimizer to sit on, the gaming signature falls to 2%. The dialog's fine print, which I wrote, says revenue impact is specific to this synthetic engine and scenario, so I am not going to tell a Chief Revenue Officer that 1.3% is the price of fairness, only that the cheap fix fails and the one that passes has a cost their own engine will set. Equity, the audit layer I put on top of the engine, never sets a price; in the demo my LinUCB bandit is the engine under audit, and the design leaves a documented seam where a client's engine (Pricefx, PROS, Zilliant, Competera or a custom bandit) would be swapped in behind an adapter that is not code in this build.
Remediation on the synthetic engine: the 115% cap costs 0.2% of revenue, fails at 0.59 and is gamed by the engine, which prices 29% of the protected cohort at its edge; reward shaping passes at 0.82 for 1.3%.
What I decided a lawyer should get instead of a badge
The evidence pack exists because I would not put a compliance badge in front of a lawyer, and because regulators no longer need intent. New York's Algorithmic Pricing Disclosure Act took effect on November 10, 2025 with a civil penalty of up to $1,000 per violation when personal data moves a price, and the EU AI Act's high-risk obligations apply from August 2, 2026. When a Civil Investigative Demand lands on a team that never logged the right data, the answer is months of forensic extraction. So the last thing I have the audit do is seal a CID-ready evidence pack: all 10 inputs with their findings, the gate before and after, the constraint result, and a regulatory mapping that attaches each of the three VIOLATIONs to five regimes (EEOC four-fifths, the NY Algorithmic Pricing Disclosure Act, the Colorado AI Act, EU AI Act Articles 13/14 and FTC Act Section 5) and the abstained membership input to a single legal-review line, the whole body under a SHA-256 tamper-evident hash.
I let the agents in this build, a Feature Auditor and an Adversarial Challenger, write only the prose that explains a flag or argues the loyalty case for the abstained input; they never compute a number or set a verdict. Agents advise, code decides, and no model should be the thing that rules on whether 0.43 is under 0.80. The full walkthrough is in the AI pricing compliance demo.
What stays with me is how reasonable the losing sentence sounds. "We removed race and income from the model" is true of the engine under audit, and that engine produced a 26% gap. An audit that reads columns one at a time will confirm that sentence in good faith, and a pair of inputs that carries the protected class will pass on either side of it. I would rather hand a General Counsel a red row with 0.73 in it, and a pack that records what it cost to turn that gate green, than a clean per-feature table that was measuring the wrong thing.