Module 2: Decisions and Validation
Explainable Matching Rules
Validating a record before judging it, and reporting the reason for a verdict alongside the verdict itself, so a decision can be checked rather than trusted.
Lesson 9 of 46 in the recommended order · About 30 min (estimate)
On this page
Outcome
By the end of this lesson you can write a rule that checks whether a record is complete before it judges the record, and that reports its verdict together with the reason for that verdict.
Why it matters
A matching rule that answers only yes or no is nearly useless to the person relying on it. When the assistant says an opportunity does not match, the reviewer's next question is always "why not", and if the program cannot answer it, the reviewer has to redo the work by hand, which is what the program was supposed to prevent.
There is a second, quieter problem. A rule that compares a field which is not there produces a confident answer built on nothing. None == "Total Small Business" is False, so a naive rule reports "no match" for a record whose category is simply unknown. Those two situations need different handling: one is a decision, the other is a data-quality problem someone should look at.
This is the module's project increment: the first version of the review assistant that a person could actually use.
Concept
Put validation first, and treat "we cannot judge this" as a real outcome rather than a failure to answer.
Three questions cover most of it:
- Is the field present at all?
Nonemeans absent. An empty string usually means absent too, but say so explicitly rather than relying on truthiness. - Is it the right shape? A value published as text where a number was expected is not a match failure; it is a normalisation step you have not written yet.
- Is it a value you recognise? An unfamiliar set-aside category is not automatically a mismatch. It may be a category you have not encoded.
Once the record is known to be judgeable, apply the rule and record why. The pattern is a verdict name and a reason name, assigned together in every branch:
if not present:
verdict = "Incomplete"
reason = "no set-aside published"
Assigning both in each branch is the discipline that makes the output trustworthy. If a branch sets a verdict without a reason, the reason left over from an earlier branch will be printed beside it, and a wrong explanation is worse than none.
For now a reason is a single string. Module 3 introduces lists, which is what lets a rule report several reasons at once, and Module 4 turns the whole thing into a function you can test.
Read the code
notice_id = "GSA-2026-0731"
set_aside = "Total Small Business"
estimated_value = 96500
minimum_value = 100000
if set_aside is None:
verdict = "Incomplete"
reason = "no set-aside category published"
elif set_aside != "Total Small Business":
verdict = "No match"
reason = f"set-aside is {set_aside}, not Total Small Business"
elif estimated_value < minimum_value:
verdict = "No match"
reason = f"value {estimated_value:,} is below the {minimum_value:,} minimum"
else:
verdict = "Match"
reason = "set-aside and value both qualify"
print(f"{notice_id}: {verdict} — {reason}")
Read the ordering. Absence is handled first, because every comparison below it would be meaningless on a missing value. Then the category, then the amount, then the only remaining case.
Note set_aside is None rather than set_aside == None. is asks whether this is literally the None object, which is the question being asked, and it is the conventional spelling. == would also work here, but is cannot be fooled by a value that merely compares equal to None.
Every branch assigns both names. That is not decoration; it is what makes the final print line safe to read.
Trace the verdict and its reason
Validate absence before choosing a match verdict
Follow the worked chain: Start at 1. Continue only when a condition is false.
Each successful branch assigns both verdict and reason, then skips the remaining branches. Incomplete is distinct from No match.
1 · Is the category absent?
Test- set_aside is None
True → Incomplete- Reason: no set-aside category published
False → 2- Check the category value next.
The True path does not examine estimated_value.
2 · Does the category differ?
Test- set_aside != "Total Small Business"
True → No match- Reason: set-aside is {set_aside}, not Total Small Business
False → 3- The category matches; compare the amount next.
This branch also receives empty or unfamiliar category strings. The worked code has no separate check for them.
3 · Is the amount below the minimum?
Test- estimated_value < minimum_value
True → No match- Reason: value {estimated_value:,} is below the {minimum_value:,} minimum
False → Match- Reason: set-aside and value both qualify
For numeric amounts, equality is not below the minimum, so it takes the Match path.
Predict the output
Predict the single line this prints, including the punctuation.
Check your prediction
GSA-2026-0731: No match — value 96,500 is below the 100,000 minimum
The set-aside matched, so the chain fell through to the value check, which failed. A reviewer reading this line knows immediately that the category was fine and the amount was the problem, without opening the record.
Modify the code
Change estimated_value to 250000 and predict the printed line. Then change set_aside to None and predict again.
What changes, and why
With the larger value: GSA-2026-0731: Match — set-aside and value both qualify.
With set_aside = None: GSA-2026-0731: Incomplete — no set-aside category published, and the value is never examined at all, because the chain stopped at the first branch.
That second result is the point of the whole lesson. The record might well have qualified on value, and the honest answer is still "we cannot tell", because the category is unknown. A rule that reported "No match" here would be stating a conclusion it has no evidence for.
Debug the bug
An assistant was asked to add a closing-window rule to the classifier. It produced this.
set_aside = None
days_until_close = 2
if set_aside != "Total Small Business":
verdict = "No match"
reason = "wrong set-aside category"
if days_until_close <= 3:
verdict = "Urgent"
print(f"{verdict} — {reason}")
What's actually wrong
It prints Urgent — wrong set-aside category, which is two separate defects arriving in one line.
First, the two if statements are independent, not a chain. Both run. The second overwrites verdict and leaves reason holding the text from the first, so the printed explanation belongs to a verdict that was discarded. This is exactly what "assign both names in every branch" prevents; the second block assigns one of the pair.
Second, the missing-value case was dropped. None != "Total Small Business" is True, so a record with no published category is reported as having the wrong category. That is a confident claim about data that does not exist.
The repair is to make it one chain, handle absence first, and assign both names in every branch:
if set_aside is None:
verdict = "Incomplete"
reason = "no set-aside category published"
elif days_until_close <= 3:
verdict = "Urgent"
reason = f"closes in {days_until_close} days"
elif set_aside != "Total Small Business":
verdict = "No match"
reason = f"set-aside is {set_aside}"
else:
verdict = "Match"
reason = "meets all criteria"
Whether urgency should outrank the category check is a policy question, not a coding one. Put it where your reviewer needs it, and write down why.
Try it yourself
One record with a genuinely absent field. Validate before judging, and print a verdict together with a reason that names the field.
Loading this exercise…
Practical challenge (optional)
Optional: add a second data-quality check to your rule. If estimated_value is None, or is published as text rather than a number, report "Incomplete" with a reason naming that field instead. Then write down, in two lines of plain English, the difference between "this record does not match" and "this record cannot be judged". That distinction is the one you will carry into the capstone's validation phase.
Sign in to track your progress on this exercise.
AI collaboration
Checkpoint
A reviewer receives this line from the assistant:
SPE-2026-0412: No match — value 0 is below the 100,000 minimum
The published record actually has no estimated value at all.
- What did the program most likely do wrong?
- Which of the two failure kinds from Module 0 is this?
- What should the line have said?
Answers
- It treated a missing value as zero, probably by converting
Noneor an empty string to0before comparing, so an absent field became a very small number instead of an unknown one. - Wrong output. The program ran to completion and produced a confident, well-formatted, incorrect claim, which is the kind nothing warns you about.
- Something like
SPE-2026-0412: Incomplete — no estimated value published. The record cannot be judged on value, and saying so sends a reviewer to fix the data rather than to dismiss the opportunity.
Sign in to track your progress on this exercise.
Summary and next step
Validate before you judge, treat "cannot be judged" as a real verdict, assign the verdict and its reason together in every branch, and keep absence distinct from mismatch. The assistant now decides about one record and explains itself. Module 3 gives it many records at once: lists, dictionaries, loops, and the first real shortlist.