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

BehaviorResult
WeakStores 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 left2 gates
MedianSame 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 43 gates
StrongAsks "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 change4 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:

TransformExpansion
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:

ApproachHow it worksCost
Leader election (Raft / lease in etcd)One leader dispatches; others stand byLeader is a throughput ceiling; failover gap is a latency spike
Partitioned ownershipHash job ID to a shard; each replica owns shardsRebalancing during membership change is the hard part; needs fencing
Optimistic claimUPDATE jobs SET claimed_by=?, claim_expiry=? WHERE id=? AND claimed_by IS NULLSimple 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

  1. 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.
  2. 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 SELECT followed by an UPDATE without a conditional predicate is a race.
  3. 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.
  4. 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.
  5. How do you know a job ran? The executions table is the answer: dispatched and completed are different states, and only the worker's own write moves it to completed. At-least-once promises "dispatched at least once," not "ran exactly once" — say that distinction explicitly.
  6. Why not just Postgres? Postgres with SELECT ... FOR UPDATE SKIP LOCKED is 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 on next_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.
  7. 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 awaitedasyncio.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:

ModelWins onLoses because
asyncioMany concurrent I/O waits — thousands of sockets, high fan-out RPCOne blocking call stalls everything; CPU work stalls everything; the whole call stack must be async
ThreadsBlocking I/O through libraries that are not async-aware; moderate concurrencyGIL means no CPU parallelism (GIL builds); ~8MB stack per thread; shared-state bugs
ProcessesCPU-bound workIPC 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