Stateful Class Builds
- Choose a Level 1 record that already holds what Levels 3 and 4 will ask for, so no level rewrites the one before it
- Make every refused operation leave the store exactly as it was, by checking all the guards before the first write
- Recognize which level of each build ends on concurrency, conflicts or collaboration, and leave room for it early
The config store and the credit ledger are the two OA builds that come up most, and they're worked in The Online Assessment (OA). The same format shows up in other domains too. There's one class, four levels, and a grader that unlocks each level only when the one before it passes. Replace the key-value store with a file store, a chat history, a package manager, a build system or a text editor and the mechanics stay the same. What changes is the shape of the state you keep.
Five of those builds follow, and each has its own last level. A file store ends on quotas and ownership, a chat history on restoring messages with their remaining time, and a package manager on refusing a conflicting install. For the build system, it's running independent targets at the same time. The editor covers its first two stages, editing and then undo and redo, and leaves the collaborative stage for a follow-up. All five reward the same choice at Level 1, a record wide enough that later levels only add a method.
Pick the record before Level 1
Before you write Level 1, ask what a later level could need from each record. A file that might be compressed later needs its original size stored beside its current one. A chat thread that might be restored from a backup needs each message's remaining lifetime saved in the snapshot, so its deadline can be rebased onto the moment of restore. An install that might be refused needs to run against a scratch copy, and an undo needs the exact characters an edit removed. Each one is free to add at Level 1 and costs a rebuild if you add it at Level 3 with the timer running.
Run every check before the first write. The file store and the package manager both end on a rule that a refused call changes nothing, whether it is a decompress that would break a quota or an install with a version conflict. Done in that order, the rule holds without any unwinding code. A later hidden test can read the same user's remaining space right after a refusal, which is where a half-applied write shows up.
Asked at Anthropic
Five builds, each scored against its tests level by level. Write Level 1 with the whole record in place, run it, and only then read the next level's spec. The follow-ups point at the kind of layer an interviewer adds once the four levels pass.
Cloud File Store: Per-User Quotas and Ownership Checks
Problem
CloudFileStore behind it.Sizes and capacities are integers, and every method returns a string. The class grows across four levels, every earlier method must keep working exactly as written once the later ones land, and a call that is rejected changes nothing.
Level 1, the core store.
add_file(name, size) stores a file of size bytes and returns "true", or "false" if the name is already in use. Nothing added this way has an owner or counts against a quota.get_file_size(name) returns the stored size, or "" when no such entry exists.delete_file(name) removes the entry and returns its size, or "" when it does not exist. If an owner holds it, those bytes go back to that owner's quota.Level 2, largest files under a prefix.
get_n_largest(prefix, n) returns up to n files whose names start with prefix, as "name(size)" entries sorted by size descending, ties broken by name ascending, joined by ", ". With no match it returns "".Level 3, users, quotas and ownership.
add_user(user_id, capacity) registers a user with a quota of capacity bytes and returns "true", or "false" if the user already exists.add_file_by(user_id, name, size) adds a file owned by user_id and returns that user's remaining capacity. It returns "" when the user is unknown, the name is taken, or the file would push the user past their quota.delete_file_by(user_id, name) deletes the file only when user_id owns it, returning its size, and returns "" otherwise.Level 4, compression.
compress_file(user_id, name) swaps a file the user owns for name + ".COMPRESSED" at half the size, rounded down, and returns the user's remaining capacity. It returns "" if the file is missing, belongs to someone else or to no one, came out of an earlier compression, or if that target already exists.decompress_file(user_id, name) reverses one compression, bringing the original back at its exact original size and returning the remaining capacity. It returns "" when the user does not own the file, when compress_file did not produce it, when the original name has been taken since, or when the restored size would exceed the quota.- CloudFileStore()
- add_file("ckpt/a", 300)→ "true"
- add_file("ckpt/b", 500)→ "true"
- add_file("ckpt/c", 300)→ "true"
- add_file("logs/x", 900)→ "true"
- get_n_largest("ckpt/", 2)→ "ckpt/b(500), ckpt/a(300)"
- get_n_largest("tok/", 3)→ ""
Levels 1 and 2. The prefix keeps the three checkpoints and drops logs/x. Sorted by size, ckpt/b leads, and the tie at 300 goes to ckpt/a by name. Nothing starts with "tok/", so that call returns the empty string.
- CloudFileStore()
- add_user("alice", 500)→ "true"
- add_user("bob", 500)→ "true"
- add_file_by("alice", "shared/a.bin", 300)→ "200"
- delete_file_by("bob", "shared/a.bin")→ ""
- add_file_by("bob", "shared/a.bin", 10)→ ""
- delete_file_by("alice", "shared/a.bin")→ "300"
Level 3. Alice uploads 300 of her 500 bytes and has 200 left. Bob cannot delete her file, and he cannot reuse the name while it exists. Alice can delete it, and the call returns the 300 bytes she gets back.
- CloudFileStore()
- add_user("serve", 1000)→ "true"
- add_file_by("serve", "model.bin", 801)→ "199"
- compress_file("serve", "model.bin")→ "600"
- decompress_file("serve", "model.bin.COMPRESSED")→ "199"
- get_file_size("model.bin")→ "801"
Level 4. An 801-byte file compresses to 400 bytes, so the quota frees up to 600. Decompressing restores the full 801 bytes, which doubling the 400 would miss by one.
One record per file, name -> (size, owner, original_size), plus two per-user maps for the quota and the bytes in use. Level 1 is guarded dict work, and the prefix ranking sorts matches by (-size, name). The quota calls check the user, the name and the space left before writing, and ownership is one comparison against the record. Compression reuses that record, keeping the full size beside the halved one so decompress can restore it exactly, and both run every guard before the first write.
Cloud File Store: Per-User Quotas and Ownership Checks
Click "Run" to execute your code
backup_user(user_id) that snapshots the files a user owns, and restore_user(user_id) that swaps their current files for that snapshot. The quota needs no new check, because the snapshot fit when it was taken and a restore can only drop files from it. What needs a decision is a name another user took in between. Leave it with them and skip that file. (Discussed in the solution.)Conversation History Store: Filter, Expire, Restore
Problem
user or assistant. Build the ConversationHistory class that holds those threads in memory. It grows across four levels, and every method from an earlier level must keep behaving exactly as written once the later ones land.Every method takes an integer
timestamp as its first argument, and timestamps strictly increase from one call to the next. Every method returns a string.Level 1 (store and read).
add_message(timestamp, thread_id, message_id, role, text) appends a new entry to the thread, creating the thread on first use, and returns "true". If the thread already holds that id, it returns "false" and changes nothing. Ids are scoped to their thread.get_message(timestamp, thread_id, message_id) returns the stored text, or "" if absent.delete_message(timestamp, thread_id, message_id) removes it and returns "true", or "false" if it was absent.Level 2 (history and filter).
get_history(timestamp, thread_id) lists the thread's messages in the order they were added, each formatted as "message_id(role)" and joined by ", ". An unknown or empty thread returns "".filter_by_role(timestamp, thread_id, role) returns the same listing restricted to messages with that role.Level 3 (messages that expire).
add_message_with_ttl(timestamp, thread_id, message_id, role, text, ttl) stores a message that is visible from timestamp up to but excluding timestamp + ttl, where ttl is positive.Once expired, the message is absent to every Level 1 and Level 2 method, and its id is free again. Adding that id later appends a new message at the end of the thread. A message added through
add_message never expires.Level 4 (backup and restore).
backup(timestamp) saves a snapshot of every message visible at timestamp, across all threads, and returns how many messages it saved. For an expiring message the snapshot keeps its remaining lifetime.restore(timestamp, timestamp_to_restore) replaces the entire store with the latest backup taken at or before timestamp_to_restore and returns "true". A restored message with remaining lifetime r now expires at timestamp + r. If no backup qualifies, it returns "false" and changes nothing.- ConversationHistory()
- add_message(1, "t", "q-9", "user", "What is RLHF?")→ "true"
- add_message(2, "t", "a-1", "assistant", "Reinforcement learning from human feedback.")→ "true"
- add_message(3, "t", "q-2", "user", "Give an example")→ "true"
- get_history(4, "t")→ "q-9(user), a-1(assistant), q-2(user)"
- filter_by_role(5, "t", "user")→ "q-9(user), q-2(user)"
History comes back in the order the turns arrived, so q-9 leads even though a-1 would sort first. The Level 2 role filter keeps the two user turns in that same order.
- ConversationHistory()
- add_message_with_ttl(10, "t", "m1", "user", "temporary", 5)→ "true"
- get_message(12, "t", "m1")→ "temporary"
- get_message(15, "t", "m1")→ ""
- add_message(16, "t", "m1", "assistant", "fresh")→ "true"
The Level 3 message expires at 15. A read at 12 still sees it; a read at 15 lands on the deadline and finds nothing. With the old message gone, the id is free, so the add at 16 succeeds.
- ConversationHistory()
- add_message_with_ttl(1, "t", "m1", "user", "x", 10)→ "true"
- backup(5)→ "1"
- restore(20, 5)→ "true"
- get_message(25, "t", "m1")→ "x"
- get_message(26, "t", "m1")→ ""
At t=5 the message has 6 ticks left, and the Level 4 backup records those 6. Restoring at t=20 hands the 6 ticks back, so the message now expires at 26. Its original deadline of 11 plays no part after the restore.
Map each thread id to an insertion-ordered map of message id -> (role, text, expires_at), with expires_at None for a permanent message. The first two levels are reads and writes over that map, and history walks it in the order messages were added. For expiry, store an absolute deadline and check it inside every read, so nothing sweeps the store. Backup copies the visible messages into a snapshot that records remaining lifetime, and restore rebuilds fresh maps from the latest qualifying snapshot with each expiry rebased onto the restore time.
Conversation History Store: Filter, Expire, Restore
Click "Run" to execute your code
backup into a string you could write to disk. The length-prefixed encoding from the config store's dump carries straight over, with the remaining lifetime as one more token per message and messages emitted in thread order. The second extension caps how many backups you keep. Timestamps only increase, so the backup list is already sorted and restore can binary-search for the latest qualifying snapshot. (Discussed in the solution.)Package Manager: Install, Resolve, Then Refuse the Conflict
Problem
PackageManager behind it. The registry holds published releases, each a name at a MAJOR.MINOR.PATCH version, and the machine holds at most one installed version per name. The class grows across four levels, and every method from an earlier level must keep its exact behavior after the later ones land.Level 1 sets up publish and install.
publish(name, version) adds a release and returns "true", or "false" if that exact release is already published.install(requirement) installs the highest published version of the requested package and returns what it newly installed as "name@version". Asking for a package that is already installed returns "". When nothing by that name was ever published, it returns "missing: " and the machine stays as it was.installed() lists every installed package as "name@version", sorted by name, joined by ", ".Versions compare numerically, part by part, so
1.10.0 is newer than 1.9.0.Level 2 adds dependencies.
depend(name, version, requirement) records that one release needs requirement. It returns "false" if that release is unknown or already lists a requirement on the same package, else "true". Requirements keep the order they were added.install now resolves depth first. It chooses a version, works through that release's requirements in order, and installs dependencies before the packages that need them. The return lists every newly installed package in that order, joined by ", ". Anything already installed, or already chosen earlier in the same install, is reused and does not install again.Two failures stop the walk. A requirement that leads back to a package still being resolved returns
"cycle: a -> b -> a", the loop written from the repeated package. When no release matches a dependency, the walk returns "missing: from @" as its report. Any failure refuses the whole install, so nothing chosen along the way stays installed, and the report names the first failure in resolution order.Level 3 adds version ranges.
A requirement is a bare name, or a name followed by comma-separated bounds, each
>=V (inclusive) or (exclusive), as in "torch>=2.0.0,<3.0.0". Every bound must hold. Install picks the highest published version inside the range, for the root and for every dependency. When no release fits, the missing report quotes the requirement in full, bounds included.
Level 4 refuses the conflict.
One version per name means a reused package can sit outside a new range. When it does, install returns "conflict: @ vs from @" with the same all-or-nothing refusal, dropping the from part when the root itself conflicts. - PackageManager()
- publish("numpy", "1.26.0")→ "true"
- publish("numpy", "1.9.0")→ "true"
- install("numpy")→ "numpy@1.26.0"
- install("numpy")→ ""
- install("torch")→ "missing: torch"
Level 1. Compared part by part, 26 beats 9, so 1.26.0 is the highest release. A second install finds numpy on disk and returns "". Nothing named torch was published.
- PackageManager()
- publish("app", "1.0.0")→ "true"
- publish("web", "1.0.0")→ "true"
- publish("db", "1.0.0")→ "true"
- publish("log", "1.0.0")→ "true"
- depend("app", "1.0.0", "web")→ "true"
- depend("app", "1.0.0", "db")→ "true"
- depend("web", "1.0.0", "log")→ "true"
- depend("db", "1.0.0", "log")→ "true"
- install("app")→ "log@1.0.0, web@1.0.0, db@1.0.0, app@1.0.0"
Level 2. The walk enters web, installs log, then web. When db asks for log, log is already picked in this install, so it is reused. db and app follow, each after its dependencies.
- PackageManager()
- publish("numpy", "1.26.0")→ "true"
- publish("numpy", "2.1.0")→ "true"
- publish("pandas", "2.2.0")→ "true"
- depend("pandas", "2.2.0", "numpy<2.0.0")→ "true"
- publish("jaxlib", "0.4.30")→ "true"
- depend("jaxlib", "0.4.30", "numpy>=2.0.0")→ "true"
- install("pandas")→ "numpy@1.26.0, pandas@2.2.0"
- install("jaxlib")→ "conflict: numpy@1.26.0 vs numpy>=2.0.0 from jaxlib@0.4.30"
Levels 3 and 4. pandas pulls the highest numpy under 2.0.0. jaxlib needs a numpy that is 2.0.0 or newer, the installed 1.26.0 fails that range, and jaxlib is refused with nothing changed.
Keep two maps: the registry (name -> version -> requirement list) and the installed set (name -> version). install copies the installed map, runs one recursive resolve against the copy, and commits the copy only if the resolve comes back clean. resolve parses the requirement into a name plus optional lower and upper bounds, checks the current path for a cycle, reuses a picked package if its version fits (or reports a conflict), and otherwise picks the highest fitting release, walks its requirements, and appends itself in post-order. Compare versions as integer tuples.
Package Manager: Install, Resolve, Then Refuse the Conflict
Click "Run" to execute your code
Build System: A DAG, A Cache, Then Parallel Workers
Problem
BuildSystem holds targets, each with a list of targets it depends on and a piece of source text. It grows across four levels, and every test from an earlier level must keep passing once the later levels land. Every method returns a string. Target names use letters, digits, dashes and slashes, so a name never contains a comma or a pipe.Level 1 (scheduling).
add(name, deps) defines a target with dependency list deps, replacing any earlier definition. A repeated entry counts once, and a dependency may be declared after whatever lists it. Returns "true" the first time and "false" on a redefinition.order(name) returns everything needed to build name, itself included, comma-joined, with each entry after all of its dependencies. When several are free to go next, the alphabetically smallest goes first. Returns "" when name is undefined, when anything it reaches depends on something undefined, or when the reachable set contains a cycle.Level 2 (execution).
write(name, text) replaces the source text of name (every target starts with ""). Returns "true", or "false" for an unknown target.build(name) runs the targets order(name) lists and returns the ones that ran, comma-joined, in that order. A target's inputs are its own text, its dependency list, and the inputs of each dependency, all the way down. A target runs when it has never run or when its inputs differ from the last time it ran. Anything that would make order return "" runs nothing and returns "".Level 3 (a content-hash cache).
Key each target by a hash of its inputs and keep every key that has ever been built.
build now skips a target whenever its current key is already in the cache, so writing the same text twice changes nothing, and putting a file back to text you built before is a cache hit.Level 4 (parallel workers).
parallel(name, workers) runs the same targets build(name) would run, under the same cache, in waves on workers workers. A target is ready once every dependency it waits on has finished; a dependency the cache skips counts as finished.Each wave takes up to
workers ready targets, alphabetically smallest first, and a target that becomes ready during a wave starts in a later one. Return each wave's targets comma-joined, with the waves joined by " | ", or "" when nothing runs. Everything that runs lands in the cache, exactly as with build.- BuildSystem()
- add("app", ["util","core"])→ "true"
- add("core", ["base"])→ "true"
- add("util", ["base"])→ "true"
- add("base", [])→ "true"
- order("app")→ "base,core,util,app"
Level 1. app names util and core before either exists, which is allowed. Only base has no dependencies, so it goes first; that frees core and util together, and core wins the alphabetical tie. app waits on both.
- BuildSystem()
- add("weights", [])→ "true"
- add("eval", ["weights"])→ "true"
- write("weights", "w1")→ "true"
- build("eval")→ "weights,eval"
- write("weights", "w2")→ "true"
- build("eval")→ "weights,eval"
- write("weights", "w1")→ "true"
- build("eval")→ ""
Levels 2 and 3. The first build runs both targets. New text on weights changes its key and, through it, the key of eval, so both run again. Writing w1 back brings both keys back to ones already in the cache, so the third build runs nothing.
- BuildSystem()
- add("base", [])→ "true"
- add("core", ["base"])→ "true"
- add("util", ["base"])→ "true"
- add("extra", ["base"])→ "true"
- add("app", ["core","util","extra"])→ "true"
- parallel("app", 2)→ "base | core,extra | util | app"
Level 4. base runs alone, which frees three targets, but only two workers exist, so core and extra take the second wave and util waits for the third. app goes last because it needs all three.
Keep target -> sorted dependency list and target -> text. Level 1 collects the targets reachable from name, then runs Kahn's algorithm over them with a min-heap so ties go alphabetically; a result shorter than the reachable set means a cycle. Levels 2 and 3 walk that order computing a key per target from its name, its text and its dependencies' keys, and skip a target whose key sits in a set of keys already built. Level 4 runs Kahn again over just the cache misses, popping at most workers names per wave.
Build System: A DAG, A Cache, Then Parallel Workers
Click "Run" to execute your code
Text Editor: Insert, Delete, Then Undo Everything
Problem
TextEditor behind a plain-text editing session. The document starts empty, positions are 0-based character offsets, and every method returns the whole document after it runs. It grows in two levels, and the level-one methods must keep passing once undo and redo land.Level 1 (editing)
insert_text(position, text) inserts text so that its first character lands at position. A position past the end appends.delete_text(position, length) removes up to length characters starting at position, stopping at the end of the document.get_text() returns the document.Level 2 (undo and redo)
undo() reverses the most recent edit that has not already been undone.redo() reapplies the most recently undone edit.Either call returns the document unchanged when it has nothing to act on. A new edit that changes the document clears every pending redo.
An edit that changes nothing (an empty insert, a zero-length delete, or a delete that starts at or past the end) is not recorded. Undo skips straight past it, and it leaves pending redos alone. Positions and lengths are never negative.
- TextEditor()
- insert_text(0, "hello")→ "hello"
- insert_text(5, " world")→ "hello world"
- delete_text(0, 6)→ "world"
- undo()→ "hello world"
- undo()→ "hello"
- redo()→ "hello world"
The delete removed "hello ", so the first undo puts those six characters back at position 0. The second undo removes " world". Redo reapplies that insert.
- TextEditor()
- insert_text(0, "abc")→ "abc"
- undo()→ ""
- insert_text(0, "x")→ "x"
- redo()→ "x"
- undo()→ ""
- undo()→ ""
Undo leaves "abc" waiting to be redone, but the fresh insert of "x" throws that redo away. Redo then has nothing to do, one undo removes "x", and the last undo finds an empty history.
Keep two stacks of edit records, each holding the kind, the position, and the exact characters inserted or removed. Undo pops the newest applied edit, applies its inverse (an insert becomes a delete of the same characters, a delete becomes an insert of them), and pushes it onto the redo stack. Redo pops and reapplies. Every recorded new edit empties the redo stack, and a no-op edit is never recorded.
Text Editor: Insert, Delete, Then Undo Everything
Click "Run" to execute your code
Key Takeaways
- ✓Every build in this OA keeps the same format: a single class, four levels unlocked in order, and Level 1 methods that must keep passing at Level 4. Design the first record for the last level.
- ✓Store what a later level will need before it asks. The file record keeps owner and original size, a chat message keeps its remaining lifetime for a restore, and an edit record keeps the exact characters it touched.
- ✓Refusals are all-or-nothing. Check ownership, quota, free names and version ranges before any write, or resolve against a scratch copy and commit it only when the whole operation succeeds.
- ✓Know where each build ends. The build system ends on parallel workers over a cached DAG, the package manager on conflicts, the editor on undo and later collaboration, and the file store on quotas meeting decompression.
Citing this page?How we research these guides
Anthropic interview guideKnow what Anthropic asks before you walk in.
33 coding · 5 system design · 14 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.