What L5 looks like
- States the invariant (each time unit credited to exactly one frame) and tests it.
- Validates malformed logs, parses names safely, and computes inclusive time without double counting recursion.
- Clean, single-pass code with O(L) time and O(depth) space.
Clarify the setup
A single-threaded program logs "<function>:start:<t>" when a call begins and "<function>:end:<t>" when it ends. Compute each function's exclusive time: time spent in its own body, not in functions it called. Ask:
-
Are timestamps points or units? In the classic version,
start:3means the call begins at the start of time unit 3 andend:5means it ends at the end of unit 5, so it ran for 3 units. Get this convention explicitly; it is the most common off-by-one. -
Identifiers. Integers
0..n-1with an answer list, or names with an answer dict. Use names; it covers both. - Recursion. A function can call itself; each call is a separate frame.
-
Bad logs. An
endthat does not match the top frame, or frames still open at the end. Raise, or report? Ask; raising is a fine default.
Approach
Calls on one thread nest perfectly, so a stack of open frames models the program.
Keep prev, the moment the timeline was last attributed. Every event closes a segment of time that belongs to whoever is on top of the stack:
-
start at t: the current top (if any) ran from
prevtot, exclusive oft: addt - prevto it. Push the new frame. Setprev = t. -
end at t: the top ran from
prevthrough the end of unitt: addt - prev + 1. Pop it (checking the name matches). Setprev = t + 1.
Every unit of time is credited to exactly one frame, so the totals add up to the span of the log. That is the invariant to state and to test.
Inclusive time (wall time of each call, children included) comes from the same pass: push t with the frame and, at its end, add t_end - t_start + 1 to an inclusive total, but only for the outermost active call of that function, or recursion double counts.
Part 2: several threads. Logs become "<thread>:<function>:start:<t>", interleaved by time. Frames only nest within a thread, so keep one stack and one prev per thread. With a single CPU you would also need to know which thread was scheduled; with one core per thread, per-thread stacks are enough.
Code
from collections import defaultdict
from typing import Dict, List, Tuple
def _parse(line: str) -> Tuple[str, str, int]:
name, kind, ts = line.rsplit(":", 2)
if kind not in ("start", "end"):
raise ValueError(f"bad event kind in {line!r}")
return name, kind, int(ts)
def exclusive_times(logs: List[str]) -> Dict[str, int]:
totals: Dict[str, int] = defaultdict(int)
stack: List[str] = []
prev = 0
for line in logs:
name, kind, t = _parse(line)
if kind == "start":
if stack:
totals[stack[-1]] += t - prev
stack.append(name)
prev = t
else:
if not stack or stack[-1] != name:
raise ValueError(f"end of {name!r} does not match open call {stack[-1:]}")
totals[stack.pop()] += t - prev + 1
prev = t + 1
if stack:
raise ValueError(f"calls still open at end of log: {stack}")
return dict(totals)
def inclusive_times(logs: List[str]) -> Dict[str, int]:
totals: Dict[str, int] = defaultdict(int)
stack: List[Tuple[str, int]] = []
depth: Dict[str, int] = defaultdict(int) # active frames per function
for line in logs:
name, kind, t = _parse(line)
if kind == "start":
stack.append((name, t))
depth[name] += 1
else:
top, began = stack.pop()
if top != name:
raise ValueError(f"end of {name!r} does not match open call {top!r}")
depth[name] -= 1
if depth[name] == 0: # outermost call only
totals[name] += t - began + 1
return dict(totals)
def exclusive_times_by_thread(logs: List[str]) -> Dict[str, int]:
"""Part 2: "<thread>:<function>:<start|end>:<t>", one core per thread."""
totals: Dict[str, int] = defaultdict(int)
stacks: Dict[str, List[str]] = defaultdict(list)
prev: Dict[str, int] = defaultdict(int)
for line in logs:
thread, rest = line.split(":", 1)
name, kind, t = _parse(rest)
stack = stacks[thread]
if kind == "start":
if stack:
totals[stack[-1]] += t - prev[thread]
stack.append(name)
prev[thread] = t
else:
if not stack or stack[-1] != name:
raise ValueError(f"thread {thread}: end of {name!r} does not match")
totals[stack.pop()] += t - prev[thread] + 1
prev[thread] = t + 1
if any(stacks.values()):
raise ValueError("calls still open at end of log")
return dict(totals)
Complexity
O(L) time for L log lines and O(d) extra space for the deepest call stack (plus one entry per distinct function in the result). Part 2 is the same with one stack per thread.
Test cases
def test_classic_example():
logs = ["a:start:0", "b:start:2", "b:end:5", "a:end:6"]
assert exclusive_times(logs) == {"a": 3, "b": 4}
def test_recursion_and_sum_invariant():
logs = ["f:start:0", "f:start:2", "f:start:3", "f:end:3", "f:end:5", "g:start:6", "g:end:6", "f:end:7"]
times = exclusive_times(logs)
assert times == {"f": 7, "g": 1}
assert sum(times.values()) == 7 - 0 + 1 # every unit credited once
def test_back_to_back_and_single_unit():
logs = ["main:start:0", "x:start:0", "x:end:0", "x:start:1", "x:end:1", "main:end:1"]
assert exclusive_times(logs) == {"main": 0, "x": 2}
assert exclusive_times(["a:start:4", "a:end:4"]) == {"a": 1}
def test_names_with_colons_and_bad_logs():
assert exclusive_times(["mod:fn:start:0", "mod:fn:end:1"]) == {"mod:fn": 2}
for bad in (["a:start:0", "b:end:1"], ["a:start:0"], ["a:end:0"]):
try:
exclusive_times(bad)
assert False
except ValueError:
pass
def test_inclusive_times_do_not_double_count_recursion():
logs = ["f:start:0", "f:start:2", "f:end:5", "g:start:6", "g:end:6", "f:end:7"]
assert inclusive_times(logs) == {"f": 8, "g": 1}
def test_threads_have_separate_stacks():
logs = [
"t1:a:start:0", "t2:b:start:1", "t1:c:start:2", "t2:b:end:3",
"t1:c:end:4", "t1:a:end:5",
]
assert exclusive_times_by_thread(logs) == {"a": 3, "b": 3, "c": 3}
Follow-ups
-
Real timestamps. With floating-point times in milliseconds, events are instants, not units: drop the
+ 1andprev = t + 1and uset - preveverywhere. Say which convention you are using in the code. - Streaming logs. The algorithm is already one pass with O(depth) state, so it runs on an unbounded stream. Emit a function's running total whenever a frame closes.
-
Flame graphs. Key totals by the whole stack path (
main;handle;parse) instead of the function name. That is the folded-stack format flame graph tools read. - Missing end events (a crash mid-call). Close the open frames at the last timestamp and mark them truncated rather than raising, so a partial log still gives useful numbers.
- Agent traces. For an AI agent, the same computation splits a conversation's latency into time in the model, in each tool and in the orchestrator itself, which is how you find what to speed up.
Interviewer expectations
Mid-level (L4)
- Uses a stack and gets the classic example right, including the inclusive end timestamp.
- Handles recursion and back-to-back calls.
- Walks through an example by hand before coding.
Senior (L5)
- States the invariant (each time unit credited to exactly one frame) and tests it.
- Validates malformed logs, parses names safely, and computes inclusive time without double counting recursion.
- Clean, single-pass code with O(L) time and O(depth) space.
Staff (L6+)
- Extends to threads with per-thread stacks and explains what single-core scheduling would add.
- Discusses instants versus units, streaming, truncated logs and flame-graph output.
- Connects it to profiling real systems, such as latency breakdowns of agent traces.
Make it yours
Write each chapter yourself, check it against this answer, then let the coach grill you. Practice it with the 2-level tests, run in your browser.

