Skip to main content

The Curation Funnel and Acceptance-Rate Monitoring

Level 11: Data Engineering for AIhard30 mincuration stage orderacceptance-rate monitoringfunnel analysissilent failure detectionday-over-day anomaly detectionLAGwindow functions

Read the seven-stage curation funnel as acceptance rates, then catch the run where one stage silently started eating the corpus while every job still reported success.

Seven stages, and every one of them is allowed to delete your data

The two previous lessons each took one stage of a curation pipeline. Here is the whole thing, in the order NVIDIA's NeMo Curator runs it, which is the reference architecture worth being able to recite:

Order of evaluation
  1. extractraw crawl to text, about 96% accepted
  2. lang_idkeep the target languages, about 84%
  3. heuristic_filterword count, boilerplate, repetition, about 80%
  4. dedupexact then fuzzy, about 75%
  5. quality_classifiera model scores each doc, about 61%
  6. pii_redactionregex plus NER, about 99%
  7. decontaminationremove eval-set overlap, about 99%
The curation funnel, with each stage's healthy acceptance rate. A little under 29 percent of the crawl survives end to end, and every percentage here is a thing that can silently move.

Read it as a funnel, because that is what it is. A million extracted documents become fewer than three hundred thousand training documents, and the losses are intentional at every step. Which raises the operational question this lesson exists for: if every stage is supposed to delete data, how do you notice when one of them starts deleting the wrong amount?

Acceptance rate is the health metric of a stage

Every stage has exactly one number that describes its behaviour, and it is not a runtime and not a row count:

acceptance_pct = 100.0 * docs_out / docs_in

A raw output count cannot tell you anything, because a stage that emitted fewer documents this week might simply have been handed fewer. The ratio is comparable across weeks of different sizes, which is exactly what makes it monitorable. Multiply by 100.0 rather than 100, because docs_out / docs_in in integer arithmetic is 0 for every stage that accepts less than everything.

Healthy acceptance rates are remarkably stable week over week, and that stability is the whole basis of the monitor. Dedup removes about a quarter of what it is given, every single week, because the web does not suddenly stop mirroring things. When dedup accepts 95 percent one week, the crawl did not get cleaner, your hashing broke.

The worst failure is a successful run

Level 10's observability module named the failure mode: silent success. A job that crashes gets a red status, a page, and somebody looking at it within minutes. A job that finishes cleanly while quietly destroying the corpus gets a green tick and nobody looks at it for a month, by which time you have trained on it.

The run log below has no status column at all, deliberately, because every one of these runs succeeded. The engine had nothing to complain about. Somebody edited the quality classifier's score threshold, fat-fingered a decimal, and the stage now rejects almost everything it sees. It reports success on every run, because rejecting documents is literally its job.

So the monitor cannot watch statuses. It has to watch the rates, and the comparison it makes is against that stage's own recent history, not against a global constant. Every stage has a different healthy rate, so a single threshold like "alert below 50 percent acceptance" would page you every week for the quality classifier at a perfectly normal 61 percent while missing a redaction stage that fell from 99 to 80.

LAG is the natural tool: partition by stage, order by run date, and each row can see what the same stage did last time.

LAG(acceptance_pct) OVER (PARTITION BY stage ORDER BY run_dt) AS prior_pct

Then the alert condition is a comparison between two columns of the same row, and the threshold is expressed in percentage points, not percent. A fall from 61 to 8.9 is a drop of 52.1 points. Calling it "an 85 percent decrease" is also true and much harder to reason about when you are setting the alerting threshold at three in the morning.

Common mistake: alerting on the change in docs_out. Output counts move for a completely innocent reason all the time, which is that the stage above moved. When the language filter has a bad week, every stage below it emits fewer documents and every one of them fires. The acceptance rate is immune to that, because it divides the stage's output by its own input, so a stage whose upstream shrank keeps reporting the same healthy ratio. That is exactly what happens on the middle run in the seed below.

Interview nuance: "how would you know your pipeline silently broke" is a stock reliability question, and the strong answer has three beats. Name the metric that is invariant to volume (a rate, not a count). Say what you compare it against (the same stage's own trailing history, not a global constant). Say what the threshold is expressed in (percentage points, with a floor on the input volume so a tiny run does not produce a noisy ratio). The pattern transfers directly: the same LAG-against-your-own-history shape is how you monitor conversion funnels, null rates, and job durations.

On a real platform this differs. This run log is 21 rows you can read at a glance. A real curation pipeline emits these counters per stage per shard into a metrics store or a run-log table in the warehouse, and the monitor is a scheduled query with the alert wired to a channel rather than a query you run by hand. Some teams go further and put a data test on the rate itself so a bad run blocks the publish instead of merely reporting itself, which is the write-audit-publish gate from SQL Level 5 pointed at a percentage. The shape of the query does not change.

Sample data for this example
CREATE TABLE curation_runs (
  run_dt      TEXT,     -- YYYY-MM-DD, one full curation pass over that week's crawl
  stage       TEXT,     -- the curation stage name
  stage_order INTEGER,  -- 1..7, the order the stages run in
  docs_in     INTEGER,
  docs_out    INTEGER
);
INSERT INTO curation_runs (run_dt, stage, stage_order, docs_in, docs_out) VALUES
  ('2026-07-15', 'extract',            1, 1000000, 962000),
  ('2026-07-15', 'lang_id',            2,  962000, 803000),
  ('2026-07-15', 'heuristic_filter',   3,  803000, 640000),
  ('2026-07-15', 'dedup',              4,  640000, 480000),
  ('2026-07-15', 'quality_classifier', 5,  480000, 293000),
  ('2026-07-15', 'pii_redaction',      6,  293000, 290000),
  ('2026-07-15', 'decontamination',    7,  290000, 286000),
  ('2026-07-22', 'extract',            1, 1040000, 1000000),
  ('2026-07-22', 'lang_id',            2, 1000000, 620000),
  ('2026-07-22', 'heuristic_filter',   3,  620000, 397000),
  ('2026-07-22', 'dedup',              4,  397000, 298000),
  ('2026-07-22', 'quality_classifier', 5,  298000, 182000),
  ('2026-07-22', 'pii_redaction',      6,  182000, 180000),
  ('2026-07-22', 'decontamination',    7,  180000, 178000),
  ('2026-07-29', 'extract',            1, 1020000, 981000),
  ('2026-07-29', 'lang_id',            2,  981000, 822000),
  ('2026-07-29', 'heuristic_filter',   3,  822000, 656000),
  ('2026-07-29', 'dedup',              4,  656000, 492000),
  ('2026-07-29', 'quality_classifier', 5,  492000,  44000),
  ('2026-07-29', 'pii_redaction',      6,   44000,  43600),
  ('2026-07-29', 'decontamination',    7,   43600,  43000);
Worked example (SQL)
-- Every stage's acceptance rate across the three weekly runs, side by side. Six of the seven
-- rows barely move, which is what makes the seventh readable at a glance.
SELECT stage,
       MAX(CASE WHEN run_dt = '2026-07-15' THEN ROUND(100.0 * docs_out / docs_in, 1) END) AS pct_jul_15,
       MAX(CASE WHEN run_dt = '2026-07-22' THEN ROUND(100.0 * docs_out / docs_in, 1) END) AS pct_jul_22,
       MAX(CASE WHEN run_dt = '2026-07-29' THEN ROUND(100.0 * docs_out / docs_in, 1) END) AS pct_jul_29
FROM curation_runs
GROUP BY stage, stage_order
ORDER BY stage_order;

Apply

Your turn

The task this lesson builds to.

Write a query that returns the most recent run's funnel, as (stage, docs_in, docs_out, acceptance_pct), in stage order, over curation_runs(run_dt, stage, stage_order, docs_in, docs_out).

acceptance_pct is docs_out as a percentage of docs_in, rounded to 1 decimal. Find the most recent run_dt from the table rather than typing a date. Alias every column exactly.

3 hints and 1 automated check are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, plus 2 bonus drills.

Write a query that returns every stage whose acceptance rate fell by more than 20 percentage points against that same stage's previous run, as (run_dt, stage, prior_pct, current_pct), oldest run first and then in stage order, over curation_runs.

Both percentages are docs_out as a percentage of docs_in, rounded to 1 decimal. Compare each stage only against itself, and report only falls, never rises.

1 automated check is waiting in the workspace.