Diagnostic Answer Key
Do not read this before you have completed all four parts and scored yourself unaided.
Every output prediction below was verified against a live CPython 3.13 interpreter. Where the answer depends on version, that is stated.
Table of Contents
D1: Coding
The reference solution is d1-coding/solution.py. Read its module
docstring first — it explains the representation choice, which is the entire problem.
What Weak, Median, and Strong Look Like
| Behavior | Result | |
|---|---|---|
| Weak | Stores the emitted output count as the checkpoint. Passes gates 1 and 2 in ~15 min. Hits gate 3, discovers filter breaks the arithmetic, and rewrites from scratch with 10 minutes left | 2 gates |
| Median | Same start, but on hitting gate 3 recognizes that the checkpoint must describe source position and refactors rather than rewriting. Gets gate 3 near the buzzer, does not reach gate 4 | 3 gates |
| Strong | Asks "is the source replayable and deterministic?" in the first two minutes, hears yes, and chooses a source-position checkpoint at gate 1 because that is what makes it replay-safe. Gates 2 and 3 are additive. Gate 4 needs sub_index, which is a five-line change | 4 gates |
Note what separates strong from median: not more knowledge — an earlier question. The clarifying question about replay determinism is what makes "position in the source" the obvious representation. That is why narration is scored: the question that saves you twenty minutes is one you ask out loud in minute two.
The Key Insight
The naive checkpoint — "how many outputs have I emitted" — passes gates 1 and 2 and dies at gate 3. With a filter in the pipeline, output count does not determine source position without replaying the pipeline anyway.
The representation that survives all four gates is two integers:
(source_pos, sub_index)
source_pos -- index of the source item currently being emitted from
sub_index -- how many outputs of THAT item have already been emitted
Every transform then becomes the same thing — a function from one source item to a list of zero or more outputs:
| Transform | Expansion |
|---|---|
map(fn) | item -> [fn(item)] — exactly 1 |
filter(pred) | item -> [item] or [] — 0 or 1 |
flat_map(fn) | item -> list(fn(item)) — 0 or N |
Resume becomes uniform with no special cases: skip source_pos source items, expand the next
one, drop the first sub_index outputs, continue. Gate 4 is free.
This is the actual lesson of the progressive format, and it generalizes far beyond this problem: the gates are constructed so that a locally-reasonable representation passes the early ones and collapses at the third. You do not get to see gate 4 first. So the trainable skill is not "solve harder problems" — it is choosing a representation that survives requirements you have not been told yet, by asking what is invariant about the problem rather than what is sufficient for the stated requirement.
Concretely, the habit: before writing state-bearing code, ask "if the requirements got harder in the most obvious direction, what would break first?" Here, the obvious direction is "transforms" and the thing that breaks is the output-count assumption.
D2: System Design
The Two Hardest Components
If your deep dive was not on these two, the round is a hire at best regardless of how polished the rest was. Identifying the load-bearing parts is the senior signal.
1. Dispatch semantics under scheduler failure. Three or more scheduler replicas exist for availability. All of them can see that job J is due at 09:00:00. Exactly one must dispatch it. The options, with their real costs:
| Approach | How it works | Cost |
|---|---|---|
| Leader election (Raft / lease in etcd) | One leader dispatches; others stand by | Leader is a throughput ceiling; failover gap is a latency spike |
| Partitioned ownership | Hash job ID to a shard; each replica owns shards | Rebalancing during membership change is the hard part; needs fencing |
| Optimistic claim | UPDATE jobs SET claimed_by=?, claim_expiry=? WHERE id=? AND claimed_by IS NULL | Simple and correct; the store becomes the bottleneck and the SPOF |
The correct answer is any of them with the tradeoff named. The wrong answer is not noticing the problem exists.
2. Worker liveness, leases, and split brain. A worker claims a job with a lease. It partitions. The lease expires. The scheduler must decide: is the worker dead, or is it running the job on the other side of the partition?
You cannot tell these apart. That is not a gap in the design, it is the actual constraint, and saying so out loud is worth more than any mechanism you propose.
So you choose a failure mode:
- Re-dispatch on lease expiry → at-least-once, possible concurrent double execution.
- Do not re-dispatch until positively confirmed dead → at-most-once, possible silent drop.
The prompt says "we care a lot about not silently dropping one," which selects at-least-once — and therefore obligates you to say "so job handlers must be idempotent, and here is the idempotency key I give them."
The mechanism that makes at-least-once safe is a fencing token: a monotonically increasing number issued with each lease. The job's write path rejects writes carrying a token lower than the highest it has seen. Without fencing, the partitioned zombie worker's late write silently corrupts state after the replacement has already run. Naming fencing unprompted is a strong staff-level signal.
A Reference Answer Sketch
┌──────────────┐
submit ─────────────▶│ API tier │
└──────┬───────┘
│ write job + next_run_at
▼
┌───────────────────┐
│ Job store (SoR) │ partitioned by job_id
│ jobs, schedules, │ index on (shard, next_run_at)
│ executions │
└─────────┬─────────┘
│ poll due jobs per owned shard
▼
┌──────────────────────────────────────────┐
│ Scheduler replicas (own disjoint shards)│ lease + fencing token
│ tick -> claim -> enqueue │ membership via etcd/Raft
└───────────────────┬──────────────────────┘
│ enqueue (job_id, run_id, fence)
▼
┌─────────────────┐
│ Dispatch queue │ visibility timeout ≈ lease
└────────┬────────┘
▼
┌─────────────────┐ heartbeat / lease renew
│ Worker fleet │◀──────────────────────────────
└────────┬────────┘
│ execution record (run_id, status, fence)
▼
┌─────────────────┐
│ Executions │ the audit log: dispatched != completed
└─────────────────┘
Arithmetic worth doing out loud: 50k executions/minute is ~830/s. At 200 concurrent jobs per worker and a 10s mean duration, one worker sustains ~20/s, so ~42 workers plus headroom — call it 60. 10M scheduled jobs at ~1KB of metadata is ~10GB, which fits comfortably in a partitioned relational store; the executions table is the one that grows without bound and needs a retention policy. Any arithmetic beats none.
Answers to the Hidden Follow-Ups
- Partitioned worker, job still running. You cannot distinguish it from a dead worker. Choose at-least-once, re-dispatch on lease expiry, and make it safe with a fencing token checked on the job's write path, plus an idempotency key handed to the handler.
- Three replicas, one 09:00 job. Leader election, shard ownership, or an atomic claim —
any is acceptable with its cost named. Point out that the claim must be atomic with
respect to the store, and that a
SELECTfollowed by anUPDATEwithout a conditional predicate is a race. - Two-hour outage, 40k overdue jobs. This is a product decision the design must expose,
not silently make. Per-job catch-up policy:
run_all/run_latest_only/skip. Then rate-limit the catch-up drain so it does not brown out the fleet — a token bucket on dispatch, with overdue jobs at lower priority than newly-due ones. - One tenant pins workers for 6 hours. Per-tenant concurrency caps, weighted fair queuing on dispatch, and separate pools segmented by expected duration so a long-job tenant cannot starve short-job latency. Mention that duration is declared by the tenant and therefore must be enforced with a kill timer, not trusted.
- How do you know a job ran? The executions table is the answer:
dispatchedandcompletedare different states, and only the worker's own write moves it tocompleted. At-least-once promises "dispatched at least once," not "ran exactly once" — say that distinction explicitly. - Why not just Postgres? Postgres with
SELECT ... FOR UPDATE SKIP LOCKEDis genuinely the right answer up to a few thousand dispatches/second, and saying so is a strength — it shows you optimize for operability rather than for résumé. It stops working when the due-job index becomes a write hotspot and vacuum can't keep up with the churn onnext_run_at. The next step is partitioning by shard, then moving the hot dispatch path to a purpose-built store while keeping Postgres as the system of record. - Clock skew. Never compare wall-clock timestamps across nodes to decide lease expiry. Measure leases as elapsed time on the node that owns the decision, using a monotonic clock. Bound acceptable skew with NTP, and treat a node whose skew exceeds the bound as unhealthy. If you need cross-node ordering, that is what hybrid logical clocks are for.
D3: Python Internals
Q1. TypeError: can't send non-None value to a just-started generator. A freshly created
generator is suspended before its first yield, so there is no yield expression waiting
to receive a value. You must prime it — next(g) or g.send(None) — to advance to the first
yield, and only then can you send. (Verified.)
Q2. [1, 2, 'done']. yield from delegates iteration to inner, and the sub-generator's
return value becomes the value of the yield from expression (PEP 380). It is not
yielded by inner; it is handed to outer, which then yields it explicitly. (Verified.)
Q3. Yes, cleanup prints. del g drops the last reference, CPython's reference counting
finalizes the generator immediately, and generator finalization calls close(), which throws
GeneratorExit in at the suspended yield. The finally runs as the exception
propagates. Two subtleties worth stating: this is refcount-driven and therefore prompt in
CPython but not guaranteed on other implementations; and if the generator catches
GeneratorExit and yields again, Python raises RuntimeError: generator ignored GeneratorExit. (Verified.)
Q4. [3, 2, 1] []. The defect is that iteration state lives on the instance
(self.n) rather than in the iterator. __iter__ is a generator function, so each call
returns a fresh generator — but they all mutate and read the same self.n, which the first
pass drove to 0. The fix is to keep iteration state local: for i in range(self.n, 0, -1): yield i. This is the iterable/iterator confusion that makes a class silently
single-use. (Verified.)
Q5. next(a) is 4. zip pulled 1 and 2 from a and paired them, then pulled
3 from a, asked b for a third item, got StopIteration, and stopped — discarding the
3 it had already taken. Non-obvious because zip looks non-destructive but silently
consumes one extra item from every iterator before the one that ends first. This is the bug
behind "my chunked reader loses a record at the boundary." itertools.zip_longest, or
buffering the pulled item, avoids it. (Verified.)
Q6. An iterable implements __iter__ returning a new iterator. An iterator
implements both __iter__ (returning self) and __next__, and carries the position. So
iter(x) is x holds for iterators and not for iterables. Getting it wrong produces two
classic bugs: a "collection" that can only be looped over once (you returned an iterator from
__iter__ and reused it), and nested loops over the same object silently sharing a cursor.
Q7. g.throw(exc) raises the exception at the point of the suspended yield inside the
generator, as if that yield expression had raised. Three outcomes: (a) the generator does
not catch it — it propagates out of throw() to the caller and the generator is closed;
(b) the generator catches it and yields again — throw() returns that value; (c) the
generator catches it and returns — throw() raises StopIteration.
Q8. tee must buffer every item that one consumer has read and the other has not. If you
advance one branch to the end before touching the other, the internal deque holds the entire
stream — you have silently materialized the thing you used a generator to avoid. tee is only
safe when consumers advance roughly in lockstep. Independent full passes should re-create the
source instead.
Q9. slow() keeps running. gather with the default return_exceptions=False
propagates the first exception to the awaiter immediately, but it does not cancel the
sibling tasks — they are orphaned and continue in the background. (Verified: the raise
surfaces, then slow completes normally afterwards.) asyncio.TaskGroup (3.11+) fixes this
with structured concurrency: a failing child causes the remaining children to be cancelled and
the group's __aexit__ to raise an ExceptionGroup (caught with except*). Naming
gather's orphaning as a resource-leak bug, not a stylistic difference, is the senior answer.
Q10. Since Python 3.8, asyncio.CancelledError inherits from BaseException, not
Exception. (Verified: issubclass(CancelledError, Exception) is False.) Practical
consequence: a broad except Exception: will not swallow cancellation — which is the point,
because swallowing it makes a task uncancellable. The corollary: if you catch CancelledError
explicitly for cleanup, re-raise it, or you break the cancellation contract.
Q11. Two defects. (a) The tasks are not awaited — asyncio.sleep(5) is a guess, and
if the fetches take six seconds the loop exits with work unfinished and results discarded.
(b) No strong reference is kept to the tasks — the event loop holds only a weak reference,
so a task can be garbage-collected mid-execution and vanish silently. The fix for both:
async with asyncio.TaskGroup() as tg: for url in urls: tg.create_task(fetch(url)). Failing
that, collect the tasks into a set, await asyncio.gather(*tasks), and discard on completion.
Q12. time.sleep(2) blocks the entire event loop thread. The loop is a single thread
running a ready-callback queue; a blocking call means no other task runs, no I/O is polled, no
timers fire, and nothing is cancelled — for two full seconds. Every pending task's latency
grows by 2s. The escape hatch is await asyncio.to_thread(blocking_fn, ...) (or
loop.run_in_executor) for I/O-bound blocking work, and a ProcessPoolExecutor for CPU-bound
work. This is the most common async production bug: one synchronous library call in a request
handler, and the whole service's tail latency collapses.
Q13. The GIL guarantees that exactly one thread executes CPython bytecode at a time, so
individual bytecode instructions and operations implemented in C that never release it are
effectively atomic. It does not make multi-bytecode sequences atomic.
some_list.append(x) is thread-safe — one C-level call that does not release the GIL.
counter += 1 is not — it compiles to load, add, store, and the interpreter can switch
threads between them, losing updates. The GIL protects interpreter internals, not your
invariants.
The senior-level addendum, worth a point on its own. Since CPython 3.10 the eval breaker —
the flag that hands the GIL to another thread — is checked only at specific instructions,
mainly backward jumps and calls, not between every bytecode. So the textbook demonstration, a
bare counter += 1 in a tight loop, frequently loses nothing, because the check lands on
JUMP_BACKWARD, which is after the STORE. Put a call between the load and the store
(counter = add_one(counter), or an operand whose __add__ is written in Python) and updates
vanish immediately — ../tracks/python-internals/experiments/exp03_gil.py
measures 3% and 61% loss respectively on the same machine where the naive version loses zero.
The point: "I ran it and it didn't lose anything" is not evidence of atomicity. Whether a
read-modify-write is interrupted depends on interpreter version, code shape, and operand type,
none of which is a contract you can rely on.
Q14. PEP 703 designed the removal; PEP 779 defined the criteria for supported
status. The free-threaded build became officially supported (Phase II) in Python 3.14
(October 2025) — no longer experimental, but still not the default build; you opt in
(e.g. python3.14t). Reported costs at 3.14: single-threaded overhead ~5–10% (down from ~40%
in 3.13's experimental build), memory ~15–20% higher, multi-threaded CPU-bound speedups around
4x on suitable workloads. Phase III (free-threading as default) is not scheduled near-term.
Check at runtime with sys._is_gil_enabled().
Q15. Decision rule:
| Model | Wins on | Loses because |
|---|---|---|
| asyncio | Many concurrent I/O waits — thousands of sockets, high fan-out RPC | One blocking call stalls everything; CPU work stalls everything; the whole call stack must be async |
| Threads | Blocking I/O through libraries that are not async-aware; moderate concurrency | GIL means no CPU parallelism (GIL builds); ~8MB stack per thread; shared-state bugs |
| Processes | CPU-bound work | IPC serialization cost; memory duplication; slow startup; no shared objects without explicit shared memory |
The one-liner: async for waiting, processes for computing, threads for when the library gives you no choice.
Q16. Yes, __del__ runs, and the cycle is collected. (Verified: both __del__s ran and
gc.collect() returned 2.) Before Python 3.4 / PEP 442, objects with __del__ in a
reference cycle were considered uncollectable and dumped into gc.garbage — a genuine leak.
PEP 442 changed finalization order so cycles containing finalizers are collected. The
remaining hazards: finalization order within a cycle is undefined, so __del__ may see
partially-finalized peers; exceptions inside __del__ are swallowed and printed to stderr;
and objects reachable from a __del__ can be resurrected. Prefer weakref.finalize or a
context manager over __del__.
Q17. __slots__ removes the per-instance __dict__, replacing dict-based attribute
storage with fixed offsets in the object struct — typically a large memory saving on many
small objects, plus slightly faster attribute access. It breaks: adding attributes not in the
slots list, and weakref support unless you add '__weakref__' explicitly. A subclass that
does not declare its own __slots__ regains a __dict__, and the entire saving
evaporates — every class in the hierarchy must declare it. Also incompatible with multiple
inheritance from two classes with non-empty slots.
Q18. sys.getsizeof measures only the object's own footprint. For a list, that is the
list header plus the pointer array — not the strings the pointers point to. A list of
10,000 strings reports ~80KB while actually costing megabytes. Use tracemalloc to attribute
real allocations to source lines, or pympler.asizeof for a deep size. And remember it misses
allocator behavior entirely: CPython's pymalloc arenas mean freed objects often do not return
memory to the OS, so RSS and "live object bytes" are different questions.
Q19. Order: data descriptor on the type → instance __dict__ → non-data descriptor on
the type → class attributes up the MRO → __getattr__ (only if everything above raised
AttributeError). A data descriptor defines __set__ or __delete__ as well as
__get__; a non-data descriptor defines only __get__. @property is a data descriptor,
so it sits ahead of the instance dict and cannot be shadowed by instance.x = ... — which
is why assigning to a property without a setter raises. A plain function is a non-data
descriptor, so it sits behind the instance dict, which is exactly why instance.method = something_else works.
Q20. __getattribute__ is called for every attribute access, unconditionally.
__getattr__ is called only as a fallback, when normal lookup raises AttributeError.
__getattribute__ is the performance hazard because it intercepts every access including
self.anything inside your own methods. The classic bug is infinite recursion: writing
return self.__dict__[name] inside __getattribute__ re-enters __getattribute__ to fetch
__dict__. The fix is to delegate to the base implementation —
object.__getattribute__(self, name). Prefer __getattr__ for proxying and lazy attributes;
reach for __getattribute__ only when you genuinely must intercept everything.
D4: Behavioral
There is no key here — they are your stories. What follows is the grading standard, with worked contrasts.
Prompt 1 — graded contrast
Weak (no-hire at staff):
"We were having scaling problems with our search infrastructure, so I proposed we move to a microservices architecture. I worked with the team to design it and we migrated over six months. It was successful and latency improved a lot."
No decision (a category is not a decision), no alternatives, no disagreement, no numbers, no constraint, no mistake. This is a tour, not a story.
Strong (hire at staff):
"I decided to move our multilingual ranking pipeline from per-language index shards to a single shared-embedding index with a language-conditioned reranker. The constraint was that we had 40+ languages and the long-tail ones had too little training data to hold their own shard's quality — the bottom 25 languages were 15% of traffic and 60% of the complaints.
Two alternatives. Keep per-language shards and backfill training data — rejected because we priced the annotation at ~$400k and 9 months, and it would decay. Or a single multilingual model with no reranker — rejected because our offline evals showed a 4-point nDCG drop on our top three languages, which was 70% of revenue traffic; we weren't going to pay for the tail with the head.
The search infra team disagreed hard — the shared index roughly tripled their memory footprint per replica and they'd just finished a capacity plan. They weren't wrong. What settled it was building the index for six languages and measuring: memory was 2.2x, not 3x, and we found we could drop float32 to int8 on the tail languages with no measurable nDCG change, which brought it to 1.6x. They co-authored the rollout plan after that.
Result: tail-language nDCG up 11 points, head languages flat within noise, memory +60%, and we retired 40 index build pipelines for one. What I got wrong: I under-scoped the tokenization work badly. I assumed a shared vocabulary was a solved problem and it cost us six weeks on the two languages with the worst subword segmentation. I should have prototyped tokenization before committing to the architecture, not after."
Everything is there: decision first, quantified constraint, two alternatives with numeric reasons, a named opponent whose objection was legitimate, a prototype that resolved it with data rather than authority, measured outcome, and a specific self-critique with a generalizable lesson.
Prompt 2 — the test
Whether a reader who was on the other side would say "yes, that is what I thought and why." If your account of their position is a strawman, the story fails no matter how well the rest is written. Interviewers probe exactly here: "What was their strongest argument?" If you cannot produce one, you never engaged with it.
Prompt 3 — the test
Does it contain a falsifier? Answers without one are opinions; answers with one are positions. Second test: could this paragraph have been written by someone who has not built anything? If yes, add the thing only you know — a number you measured, a failure you watched.
Scoring rubric
Score each prompt 0–5. See RUBRIC.md → Part 4.
References
d1-coding/solution.py— reference implementation with the design rationaleRUBRIC.md— bands and level mapping- Kleppmann, M. Designing Data-Intensive Applications — Ch. 8 (fencing tokens, unreliable clocks), Ch. 9 (linearizability, leases)
- Ongaro & Ousterhout. In Search of an Understandable Consensus Algorithm (Raft). USENIX ATC 2014
- PEP 380 — Syntax for Delegating to a Subgenerator. https://peps.python.org/pep-0380/
- PEP 442 — Safe Object Finalization. https://peps.python.org/pep-0442/
- PEP 703 — Making the Global Interpreter Lock Optional. https://peps.python.org/pep-0703/
- PEP 779 — Criteria for supported status for free-threaded Python. https://peps.python.org/pep-0779/
- CPython docs — Descriptor HowTo Guide. https://docs.python.org/3/howto/descriptor.html
- CPython docs — Python support for free threading. https://docs.python.org/3/howto/free-threading-python.html
- Ramalho, L. Fluent Python, 2nd ed. — Ch. 17 (iterators/generators), Ch. 21 (async)