Before I added this post's own entry, my content calendar held 580 records, and every one
of them carries a field called targetWords: how long this post is supposed to be. I added
the field, I wrote most of the values, and I set some of them to 2,200 by hand. Last night
an odd question landed on me: who has ever checked that number?
The answer came back as nobody. Nine separate places in the publishing pipeline and on the
site count words — gates, validators, the reading-time helper, even the statistic on my
about page — and not one of them compares the finished post to the calendar's target. The
ones that do compare are comparing a different number.
Proving an absence is hard, so I looked as carefully as I could. The name targetWords
appears in four files: the calendar itself and three code files. The calendar validator
reads it, but only to ask whether it sits between 800 and 3,800 — it checks the shape of
the number, not whether it was honoured. The generator reads it and embeds it in a prompt.
The fourth is a test, and that one only confirms a clamping line is still in place. Not a
single line opens a published file and asks what the calendar had promised.
First I broke my own ruler
My first result said posts from the agent era were 50% under target. I took my hands
off the keyboard. I knew the posts I had written over the last two months ran around
2,000 words; when a measurement tells you "half that", you check the measurement first.
I had two bugs. The first: I was stripping HTML/JSX tags with an <[^>]+> pattern. That
pattern looks for tags but makes no distinctions; if the prose contains an ordinary
expression starting with an angle bracket — a date filter, a comparison — it treats that as
the start of a tag and swallows everything in between. If three paragraphs sit in between,
it eats three paragraphs. The second was sneakier: in JavaScript, \w is ASCII only. The
Turkish word "Koşularım" looks like three separate words to a \w-based pattern. One bug
undercounted, the other overcounted, and together they produced a number that belonged to
no article at all.
I rewrote the counter and tested it against known inputs: plain prose, words inside a code
block, inline code, a sentence containing an angle bracket, a real tag, a Mermaid diagram,
link text, Turkish letters, a thousands separator. I kept at it until ten cases out of ten
returned the number I expected — in two of them my expectation was the thing that was
wrong, and I fixed that instead. I took the word-splitting rule from the repository's own
reading-time library, so the difference between us became "what we count", not "how we
split". That distinction carries the rest of this article.
I did not enjoy writing that paragraph, but skipping it would have been worse: a script
that scans text produces part of what it finds. If you do not test the ruler before you
measure, you cannot tell how much of your finding belongs to the world and how much to your
regex. I fell into the same trap during the mutation round where I deliberately broke six
rules — there, too, the
thing declaring "tests are green" was the tests themselves.
519 posts, one question: did I keep my word?
Of the 580 calendar entries, I could match 519 to a published Turkish post: first by
comparing the calendar title to the post title, then by the slug derived from that title,
and failing that by the file name's prefix (the generator used to truncate long titles). I
left the 61 unmatched entries out. I counted using my recipe's definition — code blocks
excluded; excluding inline code as well was my own addition, the recipe does not say it.
The tolerance is mine too: ±15%.
| Measure | Result |
|---|---|
| Within tolerance (±15%) | 275 posts — 53.0% |
| Below target | 163 posts — 31.4% |
| Above target | 81 posts — 15.6% |
| Median deviation | −6.9% |
| Bottom decile (p10) | −41.3% |
| Top decile (p90) | +24.7% |
| Planned total | 954,650 words |
| Written total | 872,610 words |
I would like to look at the median and call it fine. The median is −6.9%, so the typical
post closes a little under its target. But the spread between p10 and p90 is 66 points, and
the total gap between plan and output is 82,040 words — roughly thirty-five articles.
The average shows none of them. Google's SRE book puts it in one line: "Most metrics are
better thought of as distributions rather than averages." On my own blog I had been hiding
behind exactly that average.
Split by month, the table starts talking (514 of the 519 fall in these five months; the
remaining five are old posts from 2024):
| Month | Posts | Median deviation | Within tolerance |
|---|---|---|---|
| 2026-06 | 86 | −12.1% | 45.3% |
| 2026-07 | 186 | −1.5% | 36.6% |
| 2026-08 | 90 | −24.0% | 36.7% |
| 2026-09 | 81 | −4.8% | 86.4% |
| 2026-10 | 71 | −3.8% | 85.9% |
August collapses, and in September compliance doubles. No gate caused that improvement — I
changed the writer that month. The calendar queue ran dry on 3 September, the hourly
generation cron produced its last post on the 6th, and the cron was removed from the
workflow on the 9th; writing moved to the agent on my Mac. So the target starting to hold is
the result of a coincidence, not a control. If an unmeasured target is being met, you do not
know that it is being met.
17 August: the floor moved three times, the calendar never heard
Three commits touched the length logic that day. The first landed at 10:58 in the morning —
fix(content): enforce substantive verified articles — and tied the until-then flat floor
of 200 to the target:
const minimumArticleWords = Math.max(800, Math.floor(targetWords * 0.65));
For a topic with a 2,200-word target, the minimum suddenly became 1,430 words. It was a good
decision. It lived eight and a half hours.
At 19:33 the second commit landed: fix(ci): stop transient workflow failure storms — a CI
stability fix. It contains these two lines:
const effectiveTargetWords = Math.min(1500, Math.max(800, targetWords));
const minimumArticleWords = Math.max(800, Math.floor(effectiveTargetWords * 0.55));
The code comment states the reasoning honestly: free providers reliably produce 800–1,500
words in a single request, and a higher target pushes a perfectly good 900-word article into
endless retries and a quota storm. I still agree with that: a target that asks for more than
you can produce is not a target, it is a source of failures.
The decision is not the problem. Where the decision was written down is. That evening the
length contract was written into the code and not into the calendar. The calendar went on
saying 2,200 — and still does: 356 of the 580 entries carry a target above 1,500. For a
2,200-word topic the floor fell from 1,430 to 825, and the commit message says nothing about
it; its body is entirely empty.
At 21:07 the same evening an Ansible guide was published: 841 words. Sixteen words above
the new floor. The post had been shaped by the gate's 825, not by the calendar's 2,200.
The following week wrote the same story nine times:
| Date | Calendar target | Written (code excluded) | Deviation |
|---|---|---|---|
| 17 Aug | 2,200 | 1,107 | −50% |
| 17 Aug | 2,200 | 841 | −62% |
| 18 Aug | 2,200 | 767 | −65% |
| 18 Aug | 2,200 | 998 | −55% |
| 19 Aug | 2,200 | 1,105 | −50% |
| 20 Aug | 2,200 | 1,142 | −48% |
| 22 Aug | 2,200 | 750 | −66% |
| 23 Aug | 2,400 | 761 | −68% |
| 23 Aug | 2,200 | 789 | −64% |
All nine below tolerance, all nine green through the gates — because the gate was asking
about the number it had lowered itself, not the number in the calendar. This is not breaking
a rule; it is quietly rewriting one. It had happened to me before: seven of the seventeen
claims in my instruction
files described something the
code no longer did.
The period before that morning is not innocent either. On 18 May, after deleting 36
half-finished articles, the floor I added was a flat 200 words. While the calendar was asking
for 800 to 2,400, the number that satisfied the gate was 200. In those 91 days, 43 of the 324
posts I could tie to a calendar entry came in under 800 words. The worst is a career post from
15 August: 397 words against a 2,200-word target. It is still live, and its page still
says "12 min read".
A floor is not a target
This is the heart of my mistake, and I have nowhere to hide: I set a floor and thought I had
set a target.
The two do different jobs. A floor is a fault alarm — it stands against the model cutting off
mid-sentence, against a half-finished draft leaking into publication. As the person who
deleted 36 half-articles, I know exactly why that floor exists and I still stand behind it. A
target is a statement of intent: "treat this topic at this depth." The floor says "not a
disaster"; the target says "good". In my case, two thousand words sat between them.
Setting a floor relaxes you, because something on the screen turns green. That green only
means "the post wasn't truncated"; it never means "the post kept its promise". For five months
I looked at the first and read it as the second. Worse, because the floor is derived from the
target — multiplied by 0.55 — the family resemblance misleads you: the number comes from the
target, so it looks as though it is checking the target. It is not. It is checking half of it.
Nine counters, one target
Here is the most uncomfortable finding. Nine different places count words: the generator's
publish gate, the generator's "this draft is short, expand it" decision, the English post
validator, the translation validator, the weekly series validator, the reading-time library,
the newsletter script, the total on my about page, and the "finish time" badge that runs in
the browser on every post page. Each one wrote its own stripping rules.
I handed the same file to four of them — the CI measurement post from 25 October:
| Who counts | Result |
|---|---|
| My recipe's definition (code excluded) | 2,249 |
| The generator's publish gate | 2,402 |
| The "expand it" counter | 2,444 |
| The weekly series validator | 2,445 |
| The reading-time library | 2,351 |
Five definitions, five numbers, one post. A 200-word spread may look harmless in an article;
it does not look harmless when you are standing on either side of a threshold.
The gap is not always that polite. In the mutation post from 20 October my recipe's counter
says 2,246 and the generator's publish gate says 1,339. Same file, 907 words — 40% of the
article. The cause is exactly what that post describes: a bare angle bracket left behind when
inline code is unwrapped falls into the tag stripper and takes the text after it. The post
documents the bug that misleads its own counter; I only saw it today, by placing two counters
side by side.
The thresholds do not talk to each other either. The English post validator rejects anything
under 800 words; the translation validator says 200 for the same body — a factor of four. The
weekly series validator wants a band of 800–1,600 words, while this month the calendar is
asking for anywhere between 1,900 and 4,400. The reading-time library counts the contents
of code blocks as words, so the duration shown to the reader includes lines the reader does not
read. And the badge running in the browser on that same page assumes 220 words per minute, so
one page carries two different reading speeds.
The clearest evidence sits on my about page. There is a statistic there labelled "thousand
words", and today it reads 2,074 — over two million words. The counter behind that total
matches none of the five definitions above: it first looks for a wordCount field, falls back
to splitting the body on whitespace, and failing that multiplies the reading time by 200. So
the largest number I show my readers comes from the loosest definition. The tag dictionary
turned out the same way: eight separate places read that one
field. The difference is that
there a gate existed and one path bypassed it, while here no gate looks at all.
The calendar validator deserves its own paragraph: it filters down to entries that are neither
generated nor rejected and inspects only those. Since the queue ran dry on 3 September that
set has been empty, so today it inspects nothing. Sure enough, an entry dated 2 October carries
targetWords: 4400 — above the validator's own ceiling of 3,800 — and nobody noticed.
Google has a name for this
The SLO chapter of the SRE Workbook splits a metric in two: the specification and the
implementation. The specification is "the assessment of service outcome that you think
matters to users, independent of how it is measured". The implementation is the specification
plus a way to measure it. And the key sentence: "A single SLI specification might have multiple
SLI implementations." Depending on where you measure, coverage, quality and cost all change.
My targetWords field was a specification: "treat this topic at a depth of about 2,200 words".
I ended up with nine implementations and none of them looked back at the specification. Another
warning in the same chapter is addressed to me personally: "Your SLIs should be specific and
measurable." My target was specific. It was not measurable, because nothing measured it.
The same chapter also warns about where a target should come from: "we advise against picking an
SLO based upon current performance" — though it adds that current performance is a reasonable
starting point when you have nothing else. Looking at my calendar through that lens turned up a
detail that embarrassed me: I did not write all of those targets. In the generator's
topic-planning step, the model is asked for a title, tags and targetWords together; if the
returned number is below 1,000 it defaults to 1,400, and above that it is clamped to 1,500. So in
some entries the "target" was an estimate a model found reasonable at the time. If you do not
know where a number came from, it is not surprising that you never ask whether it was met.
The classic engineering version of this trap is Prometheus's histogram_quantile. The docs
explain that when a quantile does not land on a bucket boundary the function interpolates within
the bucket, and that for classic histograms this "assumes a uniform distribution of observations
within the bucket". The same specification returns different numbers depending on bucket layout.
Nobody is lying; "p99" is simply not a number on its own — it is a contract.
"Is word count even a measure?"
You are right, it is a poor one. Length does not stand in for depth; a 900-word measurement post
beats a 2,400-word word salad any day. I am not sanctifying the word target.
But there is a difference between a bad proxy and an unmeasured proxy. A bad proxy pulls you in
the wrong direction; an unmeasured proxy makes you feel good about yourself, which is worse. The
2,200 in my calendar did stand for something real: "don't treat this topic superficially." On an
evening in August that intention dropped to 825 and nobody wrote down that it had dropped.
Whichever measure you pick, the question stays the same: is anything looking at the number you
picked?
Dropping a measure is a legitimate decision, too. That is not what I did. I did not drop it; I
forgot it. Had I dropped it, I would have deleted it from the calendar.
What I am doing about it
-
One gate that measures the target. A validator that compares the finished post to
targetWordsbefore publication — and a decision that names the canonical counter. If I do not write down which definition wins, I will simply have added a tenth counter. - The gate should not settle for warning. For a post that breaks the tolerance I will require a reason field: a short post can be legitimate, but it should not be unexplained. A warning that leaves the build green is a second unmeasured target.
- Stop limiting the calendar validator to pending entries. With an empty queue it inspects nothing today; that is exactly how the 4,400-word entry escaped.
- Separate the target by language. This post's own measurement shows a structural gap between the Turkish and English bodies, and a single target does not fit both.
- Test the counter. A handful of known-input cases for every script that scans text. Today I produced two wrong numbers and started the work from there.
Conclusion
For 519 posts I did not know whether I was keeping my word; today I know, and the answer is "in
about half of them". But the ratio is not what bothers me. What bothers me is this: when
compliance went from 36.7% to 86.4% in September, nothing told me. The same silence greeted the
decline and the recovery, because nothing was measuring either one.
An unmeasured target is unmanaged even when it is met. If it holds, that is luck; if it does not,
there is no news. And if nine separate things are measuring it, you do not have a target — you
have nine opinions. The question you can ask your own setup today: is there a line anywhere that
compares the number in the plan with the finished work?
Top comments (0)