⚖️ Numbers you can defend
Make every number and label on your app true: know what one row means, use medians, treat official totals as alarms, and remember empty beats made-up.
You'll leave with
- A checklist of what each number on your page would need to be true
- The official figure your total must land near, noted for the check you'll automate in Check the AI's work
- Honest labels, empty states and a methodology note readers can trust
The List, my explorer for New York City's surcharge (an extra yearly tax) on expensive homes that aren't anyone's main residence, once showed $5.3 billion where $354.2 million was right. The city's data listed a whole apartment building as if it were one home, my code believed it, and the total came out about 15 times too high. I'm a data person, not a 10× engineer, and most rules here are ones I learned by getting a number wrong.
Your app (the website you're building in this course) doesn't exist yet, so this is the cheapest moment to decide what it may claim: rules first, then three prompts that write them into your notes.
⚡ The 60-second version
- For every number, write down what it would take to be true, and what one line of your data stands for.
- Find an official figure your total should land near. A big miss is a bug alarm.
- Missing data says "n/a" (not available), never 0, and the page says how each number was made.
Only show what your data can support
Before you draw a chart, ask what each number would require you to know. First Bites, my first-foods tracker for babies, records which foods a baby ate, not how much, so it can never honestly say "40% of daily iron". It tags foods as a "good source" of a nutrient instead (roughly 10% of the daily value per serving, from USDA data) and counts how many of 12 nutrients got one this week. Its legend says "variety guide, not exact amounts", and cucumber honestly gets no tags.
🦥 What we skip: precise-looking math the data can't feed. Percentages wait for the day an app records amounts.
Say what a score measures
Acento, my Argentine Spanish trainer, first scored you with the browser's speech recognition (the feature that turns your voice into text). It's built to understand people despite their accent, so its score measures being understood, not sounding native. Acento said so from day one ("scores intelligibility, not accent"): for learners polishing an accent, that score would sit near 100% forever.
So its accent mode compares your recording with a native speaker's, calibrated so that two native voices saying the same sentence define a perfect score. That's a free test, too: fed the second native voice as the learner, it must score exactly 100%, and a different sentence far lower.
What does calibrate mean?
Setting a scale against a known answer, like zeroing a kitchen scale with the empty bowl on it. Until then, a raw score means nothing.
Measure both sides the same way
Acento's rhythm check compares how much neighboring syllables differ in length. My first version measured the learner one way and the native reference another, and told a native recording it had "English timing": 55 against the reference's 24. With one method for both sides, it scores 21 against 24. Feed in a known-good input: a native clip must come out native.
Prefer the official index
Habooby, my Phoenix dust and air-quality map, started with a hand-rolled air-quality score that skipped the official averaging, and it disagreed with the official number by up to about 14 points. Now it shows the provider's official per-pollutant sub-indexes, and the headline equals the highest. Anything you derive yourself gets the label "est." (estimate).
What's an official index?
A number an agency computes with a published recipe, so everyone's "AQI 150" means the same. The US Air Quality Index averages each pollutant over hours (8 for ozone, 24 for particles) into a sub-index; the headline is the highest.
Know what one row means
What's a row?
Data comes as a table: each row one record, each column one field (a name, a date). What a row stands for (a building, an apartment, a logged meal) is this section's question.
New York's roll, the city's official list of properties that may owe the surcharge, has a trap: a whole co-op building with thousands of apartments can be one row, but the $1 million threshold applies to each apartment.
Condo or co-op?
A condo owner owns the unit itself, so each unit is its own record. A co-op owner owns shares in a corporation that owns the building, so the records can hold one row for all of it.
📍 My first version tested each building's total value against the per-apartment rule. Two big housing co-ops, about 10,900 and 8,000 apartments each worth well under $1 million, were charged tens of millions a year, and the co-op leaderboard showed $5.3 billion. Dividing each building's value by its apartment count fixed it: 5,526 of 6,011 co-op buildings fall under the line, and the leaderboard says $354.2 million.
Then fix it in every sum, not only in the import (the step that reads the raw files). My totals code still counted each co-op building as one home: the app said $2.58 billion while my notes said $2.9 billion, and that gap was the clue. Now one helper (a small piece of code everything else calls) answers "how many homes is this row?", and every sum uses it:
// a co-op building row stands for all its apartments; anything else is one home
const homesOnRow = r => r.kind === 'coop_building' ? r.apartments : 1;
const total = rows.reduce((sum, r) => sum + r.surcharge * homesOnRow(r), 0);
Count the denominator the same way
The List's "31,541 properties" means little until you know "out of how many", the denominator. Re-reading the same two official files with no filter gave 959,710, so the roll is 3.29% of every property in the city's two residential tax classes. Both numbers count the same kind of row. A census household count would look believable and count something else.
The downtown trap
Ticket Heat, my Phoenix traffic-ticket and crash map, ranks ZIP codes per 1,000 residents. Downtown and the airport topped it because of through-traffic: lots of driving, few residents. Both are flagged now, with a "hide low-residential ZIPs" toggle (an on/off switch), and with them hidden, the worst residential ZIPs turned out to be different ones.
What's a per-capita rate?
A count divided by the number of people, so big and small places compare fairly: incidents ÷ residents × 1,000. It breaks when the people causing the count live elsewhere.
Official numbers as alarms
Government files often hold more than the program you care about. New York's roll "includes, but is not limited to" the properties that may owe, so my script applies the published rules itself (condos and co-op apartments worth $1 million or more, houses over $5 million) and removes duplicates (the same property twice, spotted by its ID number). It lands on 31,541, against a published count of "more than 31,000". A match is a good sign, not proof (the co-op bug hid inside a correct count); a miss means stop and find out why.
Read the rule exactly
Marginal or flat?
Marginal, like income tax: you pay the higher rate only on the part above the line. Flat: the whole value is charged at its band's rate, so crossing a line raises the entire bill.
This surcharge is flat. A condo worth $2,999,999 pays 4% of its whole value, about $120,000; at $3,000,001, it pays 5.25%, about $157,500. One extra dollar, about $37,500 more tax. My own notes once called the rate marginal, which hides every such cliff. Read word for word, the official table showed them: the distance to the next cliff became a feature, and the 5,959 properties near one became a headline finding.
Make the official total an alarm
Compare your grand total with a published official figure. With nobody exempt, The List's surcharges come to about $2.9 billion a year (the gross). The city expects about $500 million (the net, after exemptions), so about 5 of every 6 dollars are expected to be exempted. Plausible. A gross several times bigger is not, and that's how the co-op bug was caught. It's now assertion 41 in the app's self-check: recompute the gross from the finished data file, and fail outside $2.5 to $3.5 billion.
What are an assertion and a self-check?
An assertion is a line of code that says "this must be true" and stops with an error if it isn't. A self-check is a file of them; you'll build yours in Check the AI's work.
When a reader says a number is off
📍 A user told me The List's "pays now" figure (a home's current property tax) didn't match real bills. Checking, I "found" a 7.5-times error, and it was mine: I'd applied the wrong tax class's ratio by hand. But the user was right. The app estimated current tax, and for 1–3 family homes it overstated it by a median of 2.6 times (half the homes by more, half by less), because the city caps how fast small homes' taxable values rise. Those figures now come from the city's own assessment data (its official valuations for tax), each marked "source" or "estimated".
So redo your hand math first, then listen. And compare against the source that decides the outcome: a private weather dashboard of mine compares forecasts for the airport station, where the official reading is taken, and shows a downtown model separately, labeled "model, downtown".
A typical value that survives typos
Medical Price & Bill Coach, my hospital price tool, found 1 absurdly low and 4 absurdly high prices for one common MRI code in one market. A $20 MRI is a typo, not a deal, and a list price nobody pays isn't typical either.
List price, cash price, negotiated rate?
Hospitals publish a list price (the "gross charge", which almost nobody pays), a cash price for people paying themselves, and a rate negotiated with each insurer. Mix the kinds, and an ordinary hospital looks "most expensive".
Take five made-up prices for one scan: $20, $950, $1,000, $1,050 and $9,000. The median (the middle value) is $1,000 with or without the two odd ones. The average jumps from $1,000 to about $2,400.
What are percentiles and the middle half?
Sort the values. The 25th percentile sits a quarter of the way up and the 75th three quarters up; between them is the middle half, called the IQR (interquartile range).
Then flag odd values against a benchmark, a trusted reference price: here, what Medicare (US government health insurance for older people) pays for the same thing.
q1 = 25th percentile q3 = 75th percentile IQR = q3 - q1
likely error if value < 0.25 × benchmark
or value < q1 - 1.5 × IQR or value > q3 + 1.5 × IQR
typical price = median of the values that aren't flagged
the chart = a band from q1 to q3, with the true lowest and highest at the ends
Flag, don't delete. Flagged rows stay visible, muted and labeled "likely error", with a count of how many were left out, because people distrust numbers that silently disappear.
Sanity-check a surprising ranking
on a private build of that tool of the price tool, one facility looked like the most expensive in its market because its own file listed its full list price as its cash price, and a drug looked 55 times over benchmark because one side was priced per unit, the other per injection. The real story was data quality. Compare like with like, drop rows the publisher marks "DO NOT USE", and require a minimum number of sources (say, 3 facilities) before ranking.
Ship the explaining sentence
Some true numbers invite a false conclusion. Condos and co-ops on The List sold for about 2.8 to 2.9 times the city's value for them, which reads like a scandal. It isn't: the law makes the city value them by the rent they could earn. Houses, valued differently, sold for about 0.83 times. They're the control group, a comparison that should behave normally. So the explaining sentence ships right beside the number, everywhere it appears; tucked into an FAQ (a page of common questions), it gets left behind when people share the number.
Model one input, and label it
Estimating something nobody knows? Make it your only modeled input (your own estimate, not a published figure) and put it on a slider. Nobody knows how many owners will claim an exemption, so that's The List's one modeled input, defaulting to what the official figure implies: 83% exempt collects $492.3 million, beside the city's estimate of about $500 million. Each assumption gets a badge (a small colored label), "modeled" in amber or "published" in a neutral color. Never red, which reads as an alarm.
A proxy is a weak signal
Here a proxy is a stand-in you can measure for something you can't (nothing to do with a reverse proxy). Owning a home through an LLC, a common kind of small company, looks suspicious. But the exemption explicitly covers majority owners of an owning LLC, and the city publishes a form for exactly that. So The List calls an LLC owner a weak signal, never proof, says "may qualify for an exemption", and skips the rule for co-ops, whose corporation owns every unit by design.
⚠️ Calling a real person's home a tax dodge on the strength of a proxy could be both wrong and defamatory. Neutral words, always.
Write down judgment calls
183 condo records are each a whole apartment block on one tax lot (one property in the tax records), yet legally one unit with one deed. I followed the law and didn't divide them like co-ops; only the display changed. They carry about $180 million a year, so a rewrite that quietly "fixed" them would move the headline a lot. Written down, the decision only changes on purpose.
Empty beats made-up
🛑 When a number could get someone hurt, show only what a real source gives you, and call the rest unknown. On an unlisted river-flow side project of mine, only one hazard window (a range of water levels where a stretch of river turns dangerous) is filled in, because invented hazard bands would be worse than none. The page calls its flow ranges community reference values, not an official safety rating.
Say "n/a" with a reason instead of leaving a blank. That project's server answers with JSON (data written as plain text) like {"available": false, "reason": "no forecast point for this gauge"}, and the page shows an "n/a" badge with the reason. An early "Alert me when dates open" button that only popped up a message was removed: a fake button promises coverage you don't have.
No data yet? Say so
When my price tool set up a market before any hospitals were added, the market stayed out of the menu, and its pages return a real 404 (the "not found" status code) saying "coming soon", so search engines don't list empty pages. A test checks for status 404, the words "coming soon", and no $ sign anywhere. A private report of mine went live as an empty state, "No data yet: run fetch to populate.", rather than demo numbers someone might trust.
What's an empty state?
What a screen shows when there's nothing to show yet: a first visit, no search results, data that hasn't arrived. Done on purpose, it says what will fill it.
Never print a missing value as zero
When Habooby's 30-day rain history fails to load, that option is disabled (greyed out) instead of showing 0.00, and the pop-up skips readings that came back null. Zero inches is a claim. "Didn't load" isn't.
What's null?
The value programs use for "nothing here". It isn't zero: zero inches of rain is a measurement, while null means nobody measured.
Say it on the page
My price tool puts "Estimates, not quotes; not legal, medical or financial advice" on every output, shows when its data was last refreshed, and labels gaps it filled with arithmetic as implied estimates. Its methodology page counts its coverage (how many hospitals, and so on) whenever the page is built, so it never goes stale like a typed-in "300 hospitals".
What's a methodology page?
The page that shows your work: where the data comes from, how each number is calculated, what's left out and how fresh it is. Few read it, and they're the ones who'd otherwise say you're wrong.
Label numbers by what they are. Habooby's air data comes from a forecast model's grid, not monitoring stations, so its card says "Modeled air quality at your spot", never "nearest station, 2 miles away". Ticket Heat labels police calls as calls, not citations. The List words each listing link by how well it matched ("matched by address and apartment", or "the most recent listing at this address, not necessarily this apartment"), because only 553 of 23,442 links are confirmed down to the apartment.
⚠️ Disclaimers wrap, never clip. The List's disclaimer strip once had a "truncate" style, a CSS rule (CSS is the code that styles a page) that cuts text off with "…". Under 1,100 pixels wide, it hid the clause that matters most: "...not owed if the home is someone's main residence".
Honesty rules as queries that must return zero
Write what your app may never claim as a question whose only right answer is "none". An unlisted food-safety map of mine may never call an incident "confirmed" unless the source's own text says so. After every import, my check must return 0, and it has, across every imported row:
What are a database, a query and SQL?
A database is a program that keeps data in tables. A query is a question you ask it, in SQL, its standard language (ilike is PostgreSQL's case-insensitive match).
select count(*) from incidents
where status = 'confirmed'
and (status_source_text is null
or status_source_text not ilike '%confirm%');
The is null line catches a missing source text, which not ilike alone lets through. With a JSON file instead, the same rule is a filter that must come back empty.
Label strong content, and make it opt-in
Acento teaches real Argentine slang, insults included. Every card carries a register badge (how polite or rude a word is: neutral, informal, vulgar or offensive), and a "Spicy" switch hides vulgar items unless a learner turns it on. Offensive terms are framed for recognition, not use. One shared helper decides visibility for the decks, search, counts and stats, so hidden words can't leak into any of them.
Do it with Claude: the prompts
Five prompts carry this lesson. You'll run three today, before any code exists (steps 1 to 3 below). The other two wait until your app exists:
- "(Once your app exists) Compare our grand total with <official figure> and add a check that fails outside a sane band."
- "Find every place we render null or missing data as 0."
What's happening underneath: these checks will live in the self-check file you build in Check the AI's work, so a future edit can't quietly bring the bug back.
Open your app's folder in VS Code (your code editor) and start Claude Code (the AI assistant that works in that folder) as in Set up your workshop. In the examples, say your app maps your city's restaurant inspections.
What's NOTES.md?
The notes file in your app's folder that Claude reads every session, from Work with Claude Code (no file yet? create an empty one). Markdown (.md) is plain text where a line starting with ## is a heading.
List every number and what it needs
If you vetted a dataset in Find an idea the data can carry, your 20 real rows are in its samples folder: put your own file's name where the prompt below says samples/inspections.json. A tracker has no dataset: drop "and samples/inspections.json" from the prompt, because one row is one thing a person logs.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: Claude reads both files, counts with a short Node script (Node runs JavaScript on your computer) and writes the table. Click NOTES.md in the file list on the left; Ctrl+Shift+V (Mac: Cmd+Shift+V) shows it formatted:
## Numbers
One row = one violation (not one inspection, not one restaurant).
| Number on the page | Needs to be true | Supported? |
|-----------------------|--------------------------------------|-------------------------------------|
| Restaurants inspected | distinct restaurant IDs, not rows | yes |
| Violations this year | a date on every row | yes |
| Current grade | today's grade (we have the last one) | relabel: "grade at last inspection" |
| Average fine | a fine amount per violation | NO: no fine column, drop it |
Check it yourself: read the first three rows of your sample file (a tracker: three entries you'd log). Does one row stand for what the table says? If every number says "yes", ask which ones need data you don't have. A table with no flags usually means Claude was being agreeable.
Write down the figure your total must land near
Now the alarm: a figure someone official published that your total should land near, from an annual report, a budget or a press release.
EXAMPLE — YOU ASK CLAUDE CODE
## Official figure
"Inspectors conducted about 24,000 restaurant inspections." Annual report, p. 4, LINK, checked TODAY'S DATE.
It counts inspections, so our total counts distinct inspection IDs, not rows.
Band: 20,000 to 28,000, because re-inspections and closures move it.
What's happening underneath: Claude searches the web and reads pages, and it can misread a table, mix up years or quote the wrong program. The band turns "close enough" into a rule a script can enforce later.
Check it yourself: open the link, press Ctrl+F (Mac: Cmd+F; on a phone, Find in page in the browser menu) and type the number. Chrome and Edge search PDFs the same way. Not there? Delete it and ask again.
💡 Tracker? Nobody publishes a total for what your users log. Write a known answer instead, like Acento's second native voice that must score exactly 100: a tiny made-up log, and the numbers it must produce, worked out by hand.
Draft the words on the page
Last, the words readers will see, drafted now so Claude builds the page around them.
EXAMPLE — YOU ASK CLAUDE CODE
## Words on the page
Disclaimer: Official inspection records, not a safety rating. Data as of DATE.
Labels: "grade at last inspection" (not "current grade"); "violations cited" (not "inspections")
Empty state: "No inspections on record for this address yet."
n/a: fines: "n/a: the city doesn't publish fine amounts"
# How these numbers work
Sources: each with a link and the date we downloaded it
What one row means: one violation cited during an inspection
How each number is calculated: one line per number in NOTES.md
What isn't covered: fines; restaurants never inspected
Data as of: DATE
Then paste the two later prompts at the bottom of NOTES.md, as they are:
## For Check the AI's work
- (Once your app exists) Compare our grand total with the figure under Official figure and add a check that fails outside the band.
- Find every place we render null or missing data as 0.
What's happening underneath: Claude writes from your own table. The risk is an AI filling gaps with plausible numbers; "use no number that isn't in NOTES.md", plus the search for digits, is what stops it.
Check it yourself: open methodology.md from the file list and read it out loud. Every number must also be in NOTES.md; anything else is made up, so trace it or delete it.
Your turn
- NOTES.md has ## Numbers: every number your app will show, what one row means, what each needs to be true, and at least one flagged, dropped or relabeled.
- ## Official figure holds the quoted sentence, its link, the date checked and a band, and you found the sentence on the source page yourself. (Tracker: a known answer, worked out by hand.)
- ## Words on the page has a disclaimer, a plain label per number, an empty-state message per screen and an "n/a" reason per gap.
- methodology.md lists sources, a data date, how each number is calculated and what isn't covered, and every number in it is also in NOTES.md.
- The two later prompts sit under ## For Check the AI's work.
Next, Build static-first: your app's first working version as one HTML file and a data file, wearing the labels you wrote today.