This post is part of the Agentic Developer: Zero to Advanced series. It follows on from Part 6 – building the graph, and it is the third post of Arc 2, the agent loop.
Rewritten on 2026-10-18 on the synthetic void dataset, with fresh runs and rebuilt animations. The first version used GDXray images that I may not redistribute. Several conclusions changed, and I say which ones where they appear. The headline that changed most: two plain rules beat everything with a model in it.
Part 5 ended on a sentence I want to keep: an agent loop does not add information, it only adds decisions. Part 6 then redrew that loop as a graph. Before adding more nodes, this post asks the question those two leave open: which of these decisions does a model actually need to make?
For the inspection job, the honest answer is two. Everything else, the order of the steps, the business rules, the final verdict, can be plain Python. So in this post the code owns the order, and the model is called in exactly two nodes. I build it, test every branch, and score it on the same held-out images as Part 5. Then I take the model’s guidance away to see what the structure buys on its own. The answer is smaller than I hoped, and more useful.
Each section has a short narrated animation if you would rather watch.
0.1 An agent, or a pipeline?
In Parts 5 and 6 the model chose the next step: measure another box, or submit. That is an agent. But look at what the inspection actually is. Find boxes. Measure them. Decide which are real. Apply the customer’s rules. Say pass or not. Write a note. The order never changes. Letting a model choose it adds cost and a way to fail, and buys nothing.
So I split the job in two kinds of steps:
- Steps with a fixed answer (find boxes, measure, apply rules, pick a verdict): plain Python.
- Steps that need judgment (is this blob a defect? how do I explain this to a person?): a model.

Daily-life version: an airport security line. The stations are fixed: ID, bag scan, body scan. The only place a person uses judgment is when the scanner beeps. You do not hire a decision-maker to tell passengers which station comes next.
If you want the longer version of this distinction, Anthropic’s Building effective agents calls the fixed-order kind a workflow and the model-chooses-the-path kind an agent. This post builds a workflow on purpose.
0.2 The state
Same idea as Part 6, a typed whiteboard. Each node fills in its own part.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
class PipelineState(TypedDict):
image: str
candidates: list[dict] # from detect
measurements: list[dict] # from measure
labels: list[dict] # from classify
classify_status: str # "ok" | "failed" | "skipped"
kept: list[dict] # survivors after rules, each with a severity
rejected: list[dict] # everything dropped, with the reason: the audit trail
verdict: str # "pass" | "review" | "escalate"
reason: str
report: dict | None
trace: Annotated[list, operator.add] # reducer: one line per node that ran
llm_calls: int
tokens: intOnly trace has a reducer, because every node adds a line to it. The rest are replaced. Note the rejected list: every box that did not make it has a reason attached (classifier, R1 border, R2 min area). When someone asks “why was this box dropped?”, the answer is in the state.
0.3 Nodes one and two: the Part 5 tools, called by code
These are the same two tools as Part 5, find_candidates and measure_candidate. The difference is who calls them. In Part 5 the model spent about five steps asking for them one box at a time. Here the code calls them once per image.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def detect_node(state: PipelineState) -> dict:
cands = find_candidates(state["image"])["candidates"]
out = {"candidates": cands, "trace": [{"node": "detect", "n": len(cands)}]}
if not cands:
out |= {"verdict": "pass", "reason": "the detector found no candidates"}
return out
def measure_node(state: PipelineState) -> dict:
ms = [measure_candidate(state["image"], c["id"]) for c in state["candidates"]]
return {"measurements": ms, "trace": [{"node": "measure", "n": len(ms)}]}No ground truth reaches the model, same rule as Part 5. Scoring only happens afterwards.
0.4 Node three: classify, the first model call
One batched call labels every candidate on the image: defect, false_alarm or uncertain, with a confidence. The prompt carries dev-data guidance like Part 5’s design fix (brighter boxes are real far more often: about 7 in 10 above contrast 30, about 1 in 10 below 15).
Part 4 taught validation and one retry, and Part 5 taught that a well-formed answer is not necessarily a correct one. So the check here has two layers. The JSON has to match the schema, and it has to cover exactly the candidate ids we sent. A model that silently skips a box has produced valid JSON and a wrong answer.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
for _ in range(MAX_CLASSIFY_ATTEMPTS):
resp = client.chat.completions.create(model=MODEL, messages=messages, temperature=0.2)
...
try:
parsed = Labels(**json.loads(strip_fences(raw)))
got = [lab.id for lab in parsed.labels]
if sorted(got) != sorted(ids): # well-formed is not enough: it must cover exactly our ids
raise ValueError(f"expected exactly one label for each id in {sorted(ids)}, got {sorted(got)}")
return {"labels": [...], "classify_status": "ok", ...}
except (json.JSONDecodeError, ValidationError, ValueError) as exc:
error = f"{type(exc).__name__}: {exc}"
messages += [{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation:\n{error}\nReturn ONLY the corrected JSON."}]
return {"labels": [], "classify_status": "failed", ...} # never an empty list that looks like "all clear"If it fails twice, the node says so (classify_status: "failed") instead of returning an empty list. An empty list would look exactly like “no defects”, which is the worst possible silent failure.
I also made the classifier swappable. The graph takes a name, and band replaces the model with a one-line rule (contrast >= 8, the best non-trivial F1 on the dev split, band_dev_sweep.py). That is not decoration, it is the control experiment further down.
0.5 Nodes four and five: rules and verdict, plain Python
Business rules are the things a QC owner would sign off on, not things a model should “feel”.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def apply_rules(measurements, labels):
"""Returns (kept, rejected). A candidate has to be labeled defect/uncertain AND pass every rule."""
...
if lab["label"] == "false_alarm":
rejected.append({"id": lab["id"], "by": "classifier"})
elif m["edge_distance_px"] == 0: # R1: touches the image border, the ring is clipped, not measurable
rejected.append({"id": lab["id"], "by": "R1 border"})
elif m["area_px"] < MIN_AREA_PX: # R2: too small to report
rejected.append({"id": lab["id"], "by": "R2 min area"})
else:
uncertain = lab["label"] == "uncertain" or lab["confidence"] < CONF_MIN
kept.append({"id": lab["id"], "severity": severity(m["area_px"]), "uncertain": uncertain, ...})A rule overrides the classifier. If the model calls a border-touching box a defect, R1 still drops it. The rules are the safety net under the model.
Show the verdict node (decide)
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def decide_node(state: PipelineState) -> dict:
"""Escalate whenever the pipeline itself is unsure. Never guess a pass or a fail."""
kept = state.get("kept") or []
if state["classify_status"] == "failed":
verdict, reason = "escalate", "the classifier output was invalid after a retry"
elif len(kept) > MAX_KEPT:
verdict, reason = "escalate", f"{len(kept)} candidates survived (limit {MAX_KEPT}); the detector looks confused"
elif any(k["uncertain"] for k in kept):
verdict, reason = "escalate", "at least one surviving candidate is uncertain"
elif kept:
verdict, reason = "review", f"{len(kept)} confident defect(s) survived the rules"
else:
verdict, reason = "pass", "no candidate survived the classifier and the rules"
return {"verdict": verdict, "reason": reason, "trace": [{"node": "decide", "verdict": verdict}]}Three verdicts, and the third one is the interesting one:
| Verdict | Meaning | What happens next |
|---|---|---|
pass |
nothing survived the classifier and the rules | image goes through |
review |
one or more confident defects survived | normal QC review |
escalate |
the pipeline itself is not sure | a human looks first (Part 9 makes this a real pause) |
Daily-life version: a bank clerk with an approval limit. Small amounts, they just approve. Big or strange ones, they say “let me get my manager” instead of guessing. escalate is the manager.
0.5.1 Try the rules yourself
Here is every one of the 1,059 boxes the detector produced on the first 200 dev images and the first 200 held-out images, with its real label. Move the sliders to build your own rules and watch two things: how it does on the dev images (where I looked at the data to pick my numbers) and on the held-out images (never looked at). Try “Just R1 and R2” and then add a contrast rule on top, and see what the contrast rule costs.
| images | boxes kept | real found | of real | precision | recall |
|---|
Three readings of what you just played with.
First, R1 does a lot of work for nothing: on the held-out images 187 of the detector’s 513 boxes touch the image border, and none of them is real. R2 (area under 20 px) drops 73 more, 4 of them real. Together the two rules cut the boxes from 513 to 253 and keep 77 of the 81 real ones, which is a precision of 30.4% and a recall of 19.9%.
Second, the contrast band costs real voids. The band floor of 8 that I picked on dev (it had the best F1 of any non-trivial floor) drops 93 more boxes (253 down to 160), 15 of them real voids, leaving 62. Dev F1 was almost flat across floors from 0 to 10 (0.263 to 0.269), so I should have read that as “the floor barely matters on F1”, and it matters a lot to recall.
Third, this is a small amount of signal. There are 81 real boxes among the held-out candidates, so one box moves precision by a point. Do not tune to the decimal.
The widget uses the same data as this animation, which shows where the dev bands come from:
0.6 Node six: report, the second model call
The last node writes the note a QC engineer reads. The rule I used: code owns the facts, the model only writes the prose. The verdict, the kept ids, the severities and the rejection reasons all come from the state. The model gets them as a JSON record and writes two or three sentences.
Two guards:
- No call when there is nothing to say. On a
passthe note is a template. Do not pay a model to write “nothing found”. - A mechanical check on the prose. The note may only cite candidate ids that exist on this image. If it cites one that does not, the model’s text is thrown away and the template is used.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def report_node(state: PipelineState) -> dict:
summary, source, calls, tokens = template_summary(state), "template", 0, 0
if state["verdict"] != "pass": # nothing to explain on a pass: do not pay a model to say "nothing"
...
text = (resp.choices[0].message.content or "").strip()
if text and not prose_problems(text, state):
summary, source = text, "llm"
else:
source = "template (llm prose rejected)"That check is narrow on purpose. It catches an invented id. It does not catch a note that overclaims. More on that in the results.
0.7 Edges: only where there is a real choice
Part 6 had eight edges for a three-node loop, because the model chose the path. This graph has six nodes and only two conditional edges, because only two places have a real choice.
notebooks/agentic-zero-to-advanced/pipeline_graph.py
def after_detect(state: PipelineState) -> str:
return "measure" if state["candidates"] else "report"
def after_classify(state: PipelineState) -> str:
return "rules" if state["classify_status"] == "ok" else "decide"
def build_pipeline(classifier: str = "llm", **compile_kwargs):
g = StateGraph(PipelineState)
g.add_node("detect", detect_node)
g.add_node("measure", measure_node)
g.add_node("classify", CLASSIFIERS[classifier])
g.add_node("rules", rules_node)
g.add_node("decide", decide_node)
g.add_node("report", report_node)
g.add_edge(START, "detect")
g.add_conditional_edges("detect", after_detect, ["measure", "report"])
g.add_edge("measure", "classify")
g.add_conditional_edges("classify", after_classify, ["rules", "decide"])
g.add_edge("rules", "decide")
g.add_edge("decide", "report")
g.add_edge("report", END)
return g.compile(**compile_kwargs)The rule of thumb I take from this: draw an edge only when something can actually go two ways. Every extra conditional edge is a path you have to test.
0.8 Run it
The graph as data first, then one real image with its trace.
notebooks/agentic-zero-to-advanced/pipeline_inspect_demo.py
from pipeline_graph import build_pipeline, run_pipeline
graph = build_pipeline("llm")
g = graph.get_graph()
print("nodes:", list(g.nodes))
for e in g.edges:
print(f" {e.source:>9} -> {e.target:<9} {'(conditional)' if e.conditional else ''}")
r = run_pipeline("test_00020", graph)
for t in r["trace"]:
print(" ", t)Show output
nodes: ['__start__', 'detect', 'measure', 'classify', 'rules', 'decide', 'report', '__end__']
__start__ -> detect
classify -> decide (conditional)
classify -> rules (conditional)
decide -> report
detect -> measure (conditional)
detect -> report (conditional)
measure -> classify
rules -> decide
report -> __end__
{'image': 'test_00020', 'n_candidates': 4, 'kept': 2, 'verdict': 'escalate', 'llm_calls': 2, 'tokens': 938, 'seconds': 11.3, ...}
{'node': 'detect', 'n': 4}
{'node': 'measure', 'n': 4}
{'node': 'classify', 'attempts': 1}
{'node': 'rules', 'kept': 2, 'rejected': 2}
{'node': 'decide', 'verdict': 'escalate'}
{'node': 'report', 'source': 'llm'}Nine edges, as drawn. Four boxes in, two dropped by the classifier, two kept, and the verdict is escalate because the model gave one of the survivors only 0.3 confidence. Two model calls in total. Part 5’s agent spent six calls on this same image. (Here one of the two kept boxes is a real void and one a false alarm, and the pipeline handed the call to a human.)
0.8.1 One real image, start to finish
Traces are good for code. For intuition, here is one real held-out image going through all six nodes: test_00204. I picked it by a rule I wrote down before running it: the first held-out image from index 200 on (outside the 200 scored in the table below) with five to seven candidates, at least one hit, at least one miss, and at least two boxes the rules would drop. That rule picks for variety, not for how well the pipeline does. Everything on screen is read from files a real run wrote (video_frame_state.py saves the boxes, the measurements, the model’s labels, the verdict and the note), nothing is typed in by hand. The answer key stays hidden until the very end, as it does in scoring.
The detector proposed five boxes, and measure turned each into three numbers. Zero model calls so far. Then the second half:
What happened, with the real numbers:
- classify (one call): boxes 1, 2 and 3 are false alarms (confidence 0.90, 0.95 and 0.70). Boxes 0 and 4 are
uncertain, at 0.3 and 0.4. - rules: nothing to do. The three boxes the rules would have dropped (1, 2 and 3 are all under 20 px) were already gone because the model rejected them first.
- decide: boxes 0 and 4 survive, both uncertain, so the verdict is
escalate: a human looks first. - report: the second call writes the note. The facts in it (ids, severities, confidences) come from the state, not from the model. Two calls, 1,006 tokens in total.
- the answer key: box 4 is a real void, a hit, and it is the one with the lowest measured contrast (-23.5, which reads as darker than its surroundings; I did not dig into why a box on a real void measures dark). The other real void was never boxed by the detector, so nothing downstream could ever have found it.
That is the whole results section in one picture: a pipeline that is unsure hands the case to a person, and it can only ever find what the detector proposed.
0.9 Test every branch
As in Part 6, a real run proves little about the rare paths. In the real runs below the classifier never returned invalid JSON (0 failed classifications in 870 model classifier calls across the six model runs, and only 2 retries, both in the unguided runs), so a real run proves nothing about that path. I test it with a scripted fake model: each call returns the next prepared string. The detector and the measuring tool are the real ones, on a real image (55 of the 200 held-out images have no candidates at all, so that path does run for real).
notebooks/agentic-zero-to-advanced/test_pipeline_paths.py
# 5. well-formed JSON that skips an id is NOT accepted: validating the shape is not enough
final, path, calls = run([json.dumps({"labels": json.loads(labels())["labels"][:-1]}), labels("false_alarm")])
# 6. invalid twice: the classify->decide edge skips the rules and the pipeline escalates, it does not guess
final, path, calls = run(["not json", "still not json", "A note."])
# 11. the model's prose cites an id that does not exist: it is thrown away and the template is used
final, path, calls = run([labels("false_alarm", over={5: "defect"}), "Candidate 99 looks severe."])Show output (all 13 checks)
no candidates ['detect', 'report'] | model calls 0 | verdict pass
all false alarms verdict pass | model calls 1 (classify only) | prose from template
one confident defect verdict review | kept [5] | model calls 2
bad JSON, retry works status ok | attempts 2 | verdict pass
missing id is rejected first answer covered 6/7 ids -> retried -> status ok
classifier fails twice ['detect', 'measure', 'classify', 'decide', 'report'] | verdict escalate
uncertain survivor verdict escalate | at least one surviving candidate is uncertain
low confidence verdict escalate | at least one surviving candidate is uncertain
too many survivors kept 4 > limit 1 -> escalate
rules beat the classifier rejected by rule: {0: 'R1 border', 2: 'R2 min area', 6: 'R2 min area'}
invented id in prose prose source: template (llm prose rejected)
band classifier model calls 1 (report only) | verdict review
each rule and severity band rejected {0: 'R2 min area', 1: 'R1 border'} | kept [(2, 'minor'), (3, 'major'), (4, 'critical')]Every branch behaves, including the two the real data never takes. Writing the first version of these also caught a bug in my own test helper (integer keys passed as keyword arguments). Cheap to find here, expensive to find in production.
0.10 The result
Same protocol as Part 5’s fixes: the first 200 held-out images (387 real voids), guidance and thresholds chosen on the dev split. Model: Qwen3-Coder-30B on local vLLM, temperature 0.2. Scored at IoU >= 0.2, as before. I counted every box a human would be shown (confident and uncertain) as “kept”. The two model variants ran three times each, because Part 6 did not and I said so.
Three variants, one question each:
- Rules only. The
bandclassifier, no model inclassify. Does the structure alone work? - LLM classifier, with the guidance. Does the model add anything over that rule?
- LLM classifier, without the guidance. What happens when it has to rely on its own prior?
Show output (one line per run)
band run 1: kept 160 tp 62 fp 98 P 0.388 R 0.16 verdicts {'pass': 104, 'review': 93, 'escalate': 3} tokens 139
llm run 1: kept 146 tp 60 fp 86 P 0.411 R 0.155 verdicts {'pass': 109, 'review': 55, 'escalate': 36} tokens 552
llm run 2: kept 147 tp 60 fp 87 P 0.408 R 0.155 verdicts {'pass': 109, 'review': 54, 'escalate': 37} tokens 552
llm run 3: kept 145 tp 60 fp 85 P 0.414 R 0.155 verdicts {'pass': 109, 'review': 57, 'escalate': 34} tokens 551
llm_noguide run 1: kept 138 tp 50 fp 88 P 0.362 R 0.129 verdicts {'pass': 118, 'review': 39, 'escalate': 43} tokens 460
llm_noguide run 2: kept 141 tp 50 fp 91 P 0.355 R 0.129 verdicts {'pass': 117, 'review': 42, 'escalate': 41} tokens 461
llm_noguide run 3: kept 140 tp 50 fp 90 P 0.357 R 0.129 verdicts {'pass': 118, 'review': 41, 'escalate': 41} tokens 453| Held-out (200 images, 387 real voids) | Real found (TP) | Precision | Recall | Tokens / image | Escalated | Gate |
|---|---|---|---|---|---|---|
| Plain detector | 81 | 15.8% | 20.9% | n/a | n/a | baseline |
| Part 5 agent + design fix (one run) | 66 | 31.3% | 17.1% | 4,901 | n/a | pass |
| Rules R1 + R2 only, no classifier (computed, no model) | 77 | 30.4% | 19.9% | 0 | n/a | pass |
Pipeline, band classifier |
62 | 38.8% | 16.0% | 139 | 3 | fail |
| Pipeline + LLM classifier (3 runs) | 60, 60, 60 | 40.8 to 41.4% | 15.5% | about 552 | 34 to 37 | fail x3 |
| Pipeline + LLM, no guidance (3 runs) | 50, 50, 50 | 35.5 to 36.2% | 12.9% | about 458 | 41 to 43 | fail x3 |
The “gate” is Part 5’s: precision at least the plain detector’s, recall at least 80% of the plain detector’s (here 16.7%). The 0.8 is still my choice, not a standard. The R1 + R2 row is not a pipeline run: it is the two business rules applied to the detector’s boxes (the widget’s “Just R1 and R2” button), so it costs no tokens and has no run-to-run variance.
What I read from this, in order of how much I trust it. This is where the conclusions changed from the first version of the post:
- Two plain rules beat everything with a model in it. R1 (drop boxes touching the image border) and R2 (drop boxes under 20 px) keep 77 of the detector’s 81 real voids, at 30.4% precision, and pass the gate comfortably (recall 95% of the detector’s). Every model-in-the-loop variant here, and every agent in Part 5, finds fewer real voids than that. The first version of this post said the pipeline “passes the gate all four Part 5 agents failed”. On this data that is not what happened: the pipeline variants fail the gate, and the thing that passes is a rule.
- The contrast rule is what fails the gate. The
bandfloor takes precision from 30.4% to 38.8% and recall from 19.9% to 16.0%, 0.7 points under the 16.7% line. That is within the noise of one run on 387 voids, so read it as “just misses”, not as “clearly worse”. It is the price of the extra precision, and whether that trade is worth it is a decision for the line owner, not for me. - The model adds nothing over the rule I told it, again. With guidance, the three runs find 60 real voids against the rule’s 62, precision 41% against 39%, at four times the tokens (552 against 139) and a lot more human work: it escalates 34 to 37 of 200 images (about 18%) to a person, against 3 for the rule. In the first guided run, 21 of the 36 escalated images held a real void among their kept boxes and 15 did not.
- Without the guidance it does not collapse this time. In the first version it did (precision 4 to 6%, because the model’s prior about contrast was backwards on that data). Here the prior is about right, so the unguided model reaches 35.5 to 36.2% precision, with 50 real voids instead of 60 and 41 to 43 escalations. The guidance buys 10 real voids and a few points of precision, not the difference between working and not working.
- The strict view agrees. Counting only confident boxes (not the uncertain ones), the guided model is at 43 to 44% precision and the unguided one at 43 to 46%, so the gap I just described is mostly about what the model is willing to commit to.
- Run to run, it is stable. Three guided runs: 60 real voids each time, precision within 0.6 points. Model nondeterminism at temperature 0.2 is not what moves these numbers. The small count of real boxes is.
0.10.1 The ceiling nobody mentions
Recall is stuck at 20.9% at best. Look at the plain detector’s row: 81 real voids out of 387. A pipeline that only filters the detector’s boxes can never find more than the detector proposed. Everything in Arc 2 improves the precision of the boxes, and every variant above gives up some recall to do it. None of it can fix the 306 real voids the detector never boxed in the first place. That needs different evidence, and it is why Part 17 puts a vision-language model on the actual image.
0.10.2 What the report note gets wrong
The id check did its job: across the six model runs the code never had to reject a note (prose_rejected is 0 everywhere). But it does not check wording. I scanned the 273 model-written notes of the three guided runs for a short list of confident-sounding phrases (“confirmed”, “definite”, “high confidence”, “clearly”, “obvious”, “certainly”, “severe”). It found “high confidence” in 8, and none of the other phrases. Reading those 8 against the stored confidences, I cannot call any of them a clear overclaim: the boxes they describe have confidences of 0.7 to 0.9, and in a few the note sits next to a 0.3 or 0.4 box it does not distinguish. That scan only looks for a few phrases, it is not an audit, and I did not read the other 265 notes by hand. The code-owns-the-facts rule keeps the numbers right. The tone is still up to the model.
0.11 Jargon, explained simply
Workflow. A fixed sequence of steps where code decides what runs next. Opposite of an agent, where the model decides.
Daily-life version: a recipe versus a chef improvising. Both can cook dinner. Only one needs a chef.
Pipeline. A workflow where each step’s output feeds the next, here as a LangGraph graph.
Daily-life version: an assembly line. Each station does one thing and passes the part on.
Business rule. A condition set by the people who own the process, applied the same way every time, no judgment involved.
Daily-life version: “no returns after 30 days”. The cashier does not decide, the policy does.
Override. A rule that wins over the model’s answer.
Daily-life version: the spell-checker says a word is fine, but the style guide says never use it. The style guide wins.
Escalate. Send the case to a human instead of deciding.
Daily-life version: the clerk who says “let me get my manager”.
Held-out data. Images you did not look at while writing the rules. The only fair test.
Daily-life version: a practice exam versus the real exam. If you saw the real questions, the score means nothing.
Ablation. Remove one part and measure what changes, to see what that part was worth.
Daily-life version: taking one ingredient out of a recipe to taste what it was doing.
0.12 What I did not do
- No real severity. The severity bands (minor, major, critical) are pixel-area placeholders at 60 and 250 px. This dataset has no millimetre scale, so they cannot be tied to a real spec. A real line would take them from the customer.
- No defect type. The dataset has one class (a void), so I classified real versus false alarm and scored it, and did not ask the model for a type it could not be checked on. Part 4 showed what a type label is worth in that situation.
- One dataset, 200 held-out images, 81 real boxes among the detector’s candidates. Every precision figure here moves by a point if one box changes. The direction is believable. The decimals are not. No confidence intervals.
- The thresholds came from the same dataset. The 20 px rule, the border rule and the contrast floor all came from the dev split of the same synthetic generator. The held-out images are a fair test of those images, not of a different part, a different detector or a real production line.
- The rules-only row is not a pipeline run. It is the two rules applied to the detector’s boxes, computed from the same candidate list the widget uses.
- The
bandfloor is a judgment call. Dev F1 was flat from 0 to 10, so a floor of 0 (rules R1 and R2 only) was equally defensible on dev, and would have passed the gate. I chose 8 because the rule needed to do something to be a classifier, and I am telling you that was a choice. - The Part 5 comparison is a single earlier run at the same temperature, on the same images. I did not rerun it.
- No cost in money, only tokens and seconds. Tokens are real. Seconds are wall time on a shared machine (3.4 s per image rules-only, about 17 s with the model classifying) and I did not control for load.
- The narrated videos (my warm cloned voice, images from the synthetic dataset) were checked by transcript and by looking at extracted frames, not by ear. The image walk is one fresh run on one image, not one of the three runs in the table.
0.13 Reproduce this yourself
Show reproduce commands
cd quarto_blog_hasan/notebooks/agentic-zero-to-advanced
uv sync
# build the dataset first (Part 2); local vLLM on :8000 serving qwen3-coder, as in Parts 3-6. .env as in Part 4.
uv run python test_pipeline_paths.py # every branch, scripted fake model, no GPU
uv run python pipeline_inspect_demo.py # nodes, edges, one real image with its trace
uv run python band_dev_sweep.py # the contrast floor, chosen on dev (no model)
uv run python video_frame_state.py # one real run on test_00204, saved for the video (needs vLLM)
uv run python video_frame_crops.py # zoom crops for the video, from the saved state
uv run python pipeline_graph.py # 3 variants on 200 held-out images, about 50 min, writes out/pipeline_*.json
uv run python widget_rows.py # the data behind the 'try the rules' widget
uv run python generate_arc2_diagrams.py # the hand-drawn pipeline diagram0.14 What’s next
Further reading: LangGraph’s documentation on conditional edges covers add_conditional_edges, the one call that makes the two branches in this post.
Part 8 looks hard at what we just did: some steps were deterministic tools, some were model calls. It adds a third kind in between, small “System One” models that return typed answers and calibrated probabilities instead of generated text, and compares two of them head to head on the narrow judgments in this pipeline. The probability would feed the same confidence threshold that decides review versus escalate.
Reuse
Citation
@online{goni2026,
author = {Goni, Hasan},
title = {Agentic {Developer:} {Zero} to {Advanced,} {Part} 7: {The}
{Inspection} {Pipeline}},
date = {2026-10-17},
langid = {en}
}