What L5 looks like
- Matches forbidden phrases on whole words and explains why substring matching is wrong.
- Gets the "at least half" rule right, including rounding and a name made only of ignore words.
- Structures the rules so each new part is a small addition, and calls out the
split(",")quoting limit.
Approach
Each data row is one merchant. Run a fixed list of rules against it, stop at the first rule that fails, and report VERIFIED or NOT VERIFIED: <reason>. The interview is usually staged: part 1 is "all fields present", and each later part adds one rule. So the real test is whether the code stays easy to extend, not whether any single rule is hard.
Parse once, by header name. Read the header, lowercase and trim its names, and zip each row into a dict. Rules then say rec["statement_descriptor"], not cells[2]. The code keeps working if the columns come in another order, and a row with the wrong number of cells is caught before any rule runs.
Tokenize once, the same way everywhere. words() lowercases and splits on anything that is not a letter or digit. Every word-level rule uses it, so "Juniper-Bakery", "juniper bakery" and "JUNIPER BAKERY," all compare equal. Most bugs in this question come from mixing split(" ") in one rule with in substring checks in another.
Rules, in order:
- Completeness. All six required fields are non-blank after trimming.
-
Length.
statement_descriptoris 5–22 characters.short_descriptoris 2–10. -
Forbidden words.
business_name,statement_descriptorandshort_descriptorcontain no forbidden entry. Entries can be phrases ("online store"), so match a run of whole tokens, not a substring. A substring check would reject"Testa Foods"for containing"test". -
Name match. Drop ignore words (
llc,inc,the, ...) from both the business name and the descriptor. At least half of the name's remaining distinct words must appear in the descriptor. If nothing is left of the name, the row fails, because there is nothing to match against.
Brute force vs. better. The obvious version is fine. Forbidden-phrase matching is O(tokens × phrases × phrase length) per field, which is tiny for real descriptors. Say so, then mention the upgrade (index the phrases by first token, or use Aho–Corasick over tokens) only if the forbidden list is huge.
Invariants:
- Rules run in a fixed order and the first failure wins, so each row gets exactly one deterministic reason.
- Ignore words matter only in rule 4. A forbidden word is still forbidden even if it is also an ignore word.
- The header is never validated as data, and an input with only a header returns
[].
Code
import re
from typing import Iterable, Optional
REQUIRED = ("business_name", "legal_name", "statement_descriptor",
"short_descriptor", "url", "product_description")
_WORD = re.compile(r"[a-z0-9]+")
def words(text: str) -> list[str]:
"""Lowercase alphanumeric tokens. Punctuation separates words."""
return _WORD.findall(text.lower())
def contains_phrase(tokens: list[str], phrase: list[str]) -> bool:
"""True if `phrase` appears as consecutive whole words in `tokens`."""
k = len(phrase)
return k > 0 and any(tokens[i:i + k] == phrase
for i in range(len(tokens) - k + 1))
class RecordValidator:
def __init__(self, forbidden_words: Iterable[str],
ignore_words: Iterable[str]) -> None:
self.forbidden = [p for p in (words(w) for w in forbidden_words) if p]
self.ignore = {t for w in ignore_words for t in words(w)}
def _check(self, rec: dict[str, str]) -> Optional[str]:
"""Return the first failed rule's reason, or None if all pass."""
# Rule 1: every required field is present and non-blank.
for col in REQUIRED:
if not rec.get(col, "").strip():
return f"missing {col}"
# Rule 2: descriptor lengths.
if not 5 <= len(rec["statement_descriptor"].strip()) <= 22:
return "statement_descriptor length"
if not 2 <= len(rec["short_descriptor"].strip()) <= 10:
return "short_descriptor length"
# Rule 3: no forbidden word or phrase in the customer-facing fields.
for col in ("business_name", "statement_descriptor", "short_descriptor"):
toks = words(rec[col])
for phrase in self.forbidden:
if contains_phrase(toks, phrase):
return f"forbidden word in {col}"
# Rule 4: the descriptor must recognisably name the business.
name = {t for t in words(rec["business_name"]) if t not in self.ignore}
desc = {t for t in words(rec["statement_descriptor"]) if t not in self.ignore}
if not name or 2 * len(name & desc) < len(name):
return "descriptor does not match business_name"
return None
def validate(self, data: list[str]) -> list[str]:
if not data:
return []
header = [h.strip().lower() for h in data[0].split(",")]
out = []
for line in data[1:]:
cells = line.split(",")
if len(cells) != len(header):
out.append("NOT VERIFIED: malformed row")
continue
rec = dict(zip(header, (c.strip() for c in cells)))
reason = self._check(rec)
out.append("VERIFIED" if reason is None else f"NOT VERIFIED: {reason}")
return out
def validate_records(data: list[str], forbidden_words: list[str],
ignore_words: list[str]) -> list[str]:
return RecordValidator(forbidden_words, ignore_words).validate(data)
Complexity
Let n be the number of rows, L the length of a row, F the total number of tokens across the forbidden phrases, and I the number of ignore words.
- Constructor: O(F + I) to tokenize the lists once.
-
validate: O(n · L) to split and tokenize. Rule 3 is O(t · F) per field forttokens in the field, and t ≤ 22 for descriptors. Rule 4 is O(t) with sets. Overall about O(n · (L + F)). - Space: O(F + I) for the lists, O(L) per row being checked, and O(n) for the output.
Test cases
Forbidden = ["online store", "test"], ignore = ["the", "llc", "inc", "co", "and"], header business_name,legal_name,statement_descriptor,short_descriptor,url,product_description.
-
Juniper Bakery,Juniper Bakery LLC,JUNIPER BAKERY,JUNIPER,juniper.example,Bread and pastries→VERIFIED. - Same row with an empty
statement_descriptor→NOT VERIFIED: missing statement_descriptor. - Descriptor
JB(2 chars) →NOT VERIFIED: statement_descriptor length. - Descriptor
ACME WIDGETS 1234567890123(26 chars) →NOT VERIFIED: statement_descriptor length. - Business name
Best Online Store→NOT VERIFIED: forbidden word in business_name(a two-word phrase match). - Business name
Testa Foods, descriptorTESTA FOODS→VERIFIED.testis matched as a whole word, not a substring. -
Northwind Trading Cowith descriptorNORTHWIND PAYMENTS→VERIFIED.cois ignored, and 1 of the 2 remaining words matches, which is exactly half. -
Blue Harbor Fish Marketwith descriptorHARBOR GOODS→NOT VERIFIED: descriptor does not match business_name(1 of 4 words). -
The Inc LLC→ name match fails, because every word is an ignore word. - Row
a,b,cunder a six-column header →NOT VERIFIED: malformed row. - Columns in a different order in the header → still validated correctly, by name.
-
[]or a header-only input →[].
Follow-ups
-
Report every failure, not the first one. Have each rule return a reason or
None, collect all of them, and outputNOT VERIFIED: a; b. With the rule list kept as data (self.rules = [check_complete, check_length, ...]), this is a one-line change. -
Real CSV quoting. Business names contain commas (
"Smith, Jones & Co"). Replaceline.split(",")withcsv.reader, which handles quotes and escaped quotes. Say this out loud early. Interviewers like hearing thatsplit(",")is a known simplification. -
Configurable rules per country or product. Load the length bounds, the required columns and the forbidden list from config, keyed by
country. Rules become small objects with acheck(rec) -> Optional[str]method, and the validator just runs a list of them. -
Fuzzy name matching. The descriptor
JNPR BAKERYshould probably still match. Compare normalized tokens with a prefix rule, or with edit distance below a threshold per token. Keep the "at least half" rule, but count a fuzzy hit as a match. Discuss false positives. - Streaming large files. Read the input with a generator, write results as you go, and keep only one row in memory. If the forbidden list is large, precompile it into a token trie (or Aho–Corasick) once, then scan each field in O(t).
Interviewer expectations
Mid-level (L4)
- Gets parts 1–3 working with clear helper functions and a header-based record.
- Trims fields and compares case-insensitively without being told.
- Writes a few tests, including an empty field and a length boundary.
Senior (L5)
- Matches forbidden phrases on whole words and explains why substring matching is wrong.
- Gets the "at least half" rule right, including rounding and a name made only of ignore words.
- Structures the rules so each new part is a small addition, and calls out the
split(",")quoting limit.
Staff (L6+)
- Moves the rules to data or config and talks about per-country variants, versioning and explainable reasons for support teams.
- Discusses fuzzy matching trade-offs (false approvals vs. manual-review load) and how to measure them.
- Covers scale: streaming, a precompiled phrase matcher, and where this sits in an onboarding pipeline (sync check at signup, async re-check when the lists change).
Make it yours
Write each chapter yourself, check it against this answer, then let the coach grill you.
