Interview guides · Ampirical Solutions
Ampirical Solutions Software Engineer Interview Questions & Guide 2026
Take-home style practice for Ampirical Solutions software engineer candidates: a small service, a fixed budget, a written tradeoff.
The 60-second brief
At Ampirical Solutions, the Software Engineer role supports the company's delivery of complex infrastructure and power utility projects. Unlike software roles focused solely on consumer applications, the company's engineering teams connect software development with the physical systems that power communities. You will build scalable solutions that support the company's wider engineering operations.
Prepare in one language you know well enough to debug in rather than the one you think reads best. Under a clock an unfamiliar language costs you standard-library lookups and iteration mechanics, and that time comes out of your thinking budget, not your typing budget.
Focus areas
- Bound credential lifetime by the engagement end date
- Diagnose failures inside environments you cannot instrument
- Version an API you cannot force clients to upgrade
Overview
This position demands a unique blend of technical precision and an appreciation for the physical world of substations and civil infrastructure. You will work alongside cross-functional teams of civil, electrical, and project engineers to ensure that technical deliverables are functional and aligned with the safety and reliability standards of the power industry. It is a role for those who enjoy solving complex, real-world problems where the impact of your code is felt in the stability and efficiency of critical utility infrastructure.
Ampirical Solutions values engineers who demonstrate a clear understanding of the intersection between software utility and physical power systems. Be prepared to discuss how your technical skills can solve industry-specific bottlenecks.
The Ampirical Solutions interview process
Ampirical Solutions candidates report 2 rounds · ≈ 2-4 weeks. The stages below are what candidates describe, not a published process.
Recruiter Screen
Half of this call is the part candidates treat as small talk: start date, notice period, work authorisation and its timing, location and time zone, on-call, and the number. Those are what kill offers late, after several engineers have each spent a day. Surfacing a hard constraint now costs you nothing and occasionally buys you something, since a loop compressed to fit a competing deadline can usually only be arranged if it is asked for early. The common failure is deflecting the compensation question twice, then discovering at offer stage that the band never reached your number.
What to demonstrate
- Whether your hard constraints are compatible with the role before a loop gets booked: earliest start, notice period, what authorisation you hold and when it needs action, days on site, willingness to carry a pager
- Whether you give a compensation range with something behind it, such as current total compensation or a competing timeline, rather than leaving the band untested
- Whether your stated timeline is real, since a competing deadline raised now is something scheduling can sometimes work around and the same deadline raised at offer stage usually is not
How to prepare
- Write each constraint down in one line before the call and state them as facts rather than negotiating them live under a question you were not expecting
- Set your range from two or three current data points for that level and location, and name the structure you are quoting in, so the number is comparable to the one they are holding
- If another process is running, say where it stands and by when, and ask directly whether this loop can be scheduled inside that window
Technical/Team Interview
Most of the time lost in this format is not lost to thinking. It goes to a standard-library call you half-remember, an off-by-one in a loop bound, and a debugging loop that mutates code at random until something passes. When output is wrong, stop re-reading the whole function: take the smallest input that reproduces it and walk the state through by hand, printing intermediates if the environment allows. Guessing at a fix without a failing case you understand is how a five-minute bug becomes twenty, and the clock does not pause while you do it.
What to demonstrate
- Whether you reach the right structure without a detour, and can write it from memory rather than only recall that one exists
- Whether overflow is considered where the language has fixed-width integers, since a signed 32-bit value stops at 2,147,483,647 and then wraps in Java, is undefined behaviour in C++, and does not arise in Python, whose integers grow instead
- Whether recursion depth is treated as a constraint on large inputs, given that CPython's default limit is 1000 frames and a deep recursion can exhaust the stack in any language where an iterative version would not
- Whether a failing case is isolated and explained before any edit is made to the code
How to prepare
- From an empty file and with no references open, implement the pieces you lean on most: a heap push and pop, an iterative DFS with an explicit stack, and a binary search whose midpoint is written lo + (hi - lo) / 2, which avoids the overflow that (lo + hi) / 2 can hit in a fixed-width integer type
- Time yourself on the ten library calls you look up most, such as sorting with a custom comparator, splitting and joining strings, and finding the next key at or above a value in an ordered map, until the lookup is gone
- Take a solution you know is broken and, before touching it, write one sentence naming the input, the expected value and the actual value. Repeat until you do it without deciding to.
Where candidates lose points
Depending on a client field because it is always populated
It is populated by their operational process, not by a constraint in their database, and their process changes without telling you. When it stops being populated, a permissive pipeline writes nulls or defaults into derived data and the damage spreads for weeks before anyone notices. Validate at the boundary and fail the run with failure_class='schema_mismatch' plus a redacted sample, so the failure is loud, attributable to the client's change, and cheap to diagnose. A run that fails clearly is far cheaper than a backfill that has to reconstruct which rows were silently wrong.
Validating against a client sandbox and assuming production parity
Sandboxes typically carry smaller data, looser or absent rate limits, a schema version behind production, and sometimes synchronous behaviour where production is asynchronous. Code that passes there fails first in the client's production, during a change window you do not control and often cannot get a second one of. The mitigations are specific: assert the observed schema version at the boundary of every run, measure the production rate limit empirically rather than reading it from a document, and design the first production run to be a bounded, reversible slice rather than a full backfill.
Going silent while thinking
Narrate the candidates and why you are discarding them, even in fragments: sorting first would make this a two-pointer scan, but it destroys the original indices, which the output needs. From the other side of the table, a candidate thinking hard and a candidate stuck are indistinguishable until one of them speaks.
Listing technologies instead of trade-offs
Name the property the design needs first, such as ordered range scans, multi-entity transactions, cheap appends, or a predictable p99, then pick something that provides it and say what it gives up in exchange. Almost any component is defensible once you state the requirement it satisfies and the one it sacrifices.
Questions & practice
Find peak window utilisation and the first rejected request
Medium · sliding window · two pointers · rate limiting
One connector binding's requests against a client endpoint arrive as timestamps t[0..n-1] in milliseconds, sorted non-decreasing, n up to 1,000,000. The endpoint's budget is R requests per rolling window of W milliseconds, and a request at time t is admitted only if strictly fewer than R already-admitted requests fall in the half-open window (t-W, t]. Return (a) the maximum number of requests falling in any W-millisecond window, counting every request regardless of admission, and (b) the index of the first request the limiter would reject. Both parts in O(n) time.
Approach
- Part (a) is a two-pointer scan: for each right index, advance left while t[right] - t[left] >= W, which leaves the window exactly (t[right]-W, t[right]], and track max(right-left+1). Left is monotone non-decreasing, so total work is O(n) amortised with O(1) extra space. The maximum over all windows of width W is attained at a window whose right edge is a request timestamp, so scanning right edges is sufficient.
- Part (b) is not the same scan. A rejected request consumes no budget, so the window must count admitted requests only. Keep a FIFO deque of admitted timestamps: pop from the front while front <= t - W, admit if the deque holds fewer than R, and return the first index where it does not. O(n) total pushes and pops, O(R) memory.
- Pin the boundary convention before writing either loop. With a half-open (t-W, t] window a request exactly W milliseconds after an earlier one does not see it; switching to a closed window changes the answer at every boundary and is where most disagreements with the client's own limiter come from.
- Note what fixed buckets of width W buy and cost: O(1) memory, but they admit up to 2R inside a span shorter than W, R at the end of one bucket and R at the start of the next. If buckets are unavoidable, halve the bucket width or use a weighted estimate across two adjacent buckets.
- If part (a) already exceeds R, the binding cannot run at this concurrency under any limiter shape. That is a scheduling problem, not a retry-policy problem, and no backoff tuning fixes it.
Worked solution (25 min)
- Take W = 10,000 ms, R = 3, and timestamps in milliseconds: [0, 1000, 2000, 3000, 11000, 11500, 12000, 12500].
- Part (a): at right=3 (t=3000) the window (-7000, 3000] holds 4 requests. At right=7 (t=12500) advance left past 0, 1000 and 2000 to index 3, since 12500-3000 = 9500 < 10000; the window holds indices 3..7, which is 5 requests.
- Confirm no other right edge beats 5 by inspecting each, then record the peak.
- Part (b): admit index 0, 1 and 2 (deque grows to 3 entries, none expiring since t-W is negative). At index 3, t=3000: nothing to pop, deque size is 3, which is not strictly fewer than R=3, so reject.
- Check what happens after the rejection if rejected timestamps were wrongly enqueued: indices 4 onwards would also be refused despite the deque having expired entries by t=11000.
Expected result: (a) peak = 5 requests in the window (2500, 12500]. (b) first rejection at index 3, t = 3000 ms. Indices 0, 1 and 2 are admitted; the requests at 11000 onwards are admissible again because the first three have left the rolling window.
Verify it yourself
- The left pointer in part (a) never decreases across the whole scan.
- Brute force over all 8 right edges reproduces peak = 5.
- Peak (5) > R (3) confirms the budget is exceeded at this arrival rate, independent of the limiter's shape.
- Every admitted timestamp remaining in the deque satisfies front > t - W at the moment of the check.
Follow-up
- Three bindings and four workers share this one endpoint's quota. Where does the counter live, and what is the correct behaviour when that store is unreachable?
- Add full-jitter backoff to the retry path and state precisely what it prevents that plain exponential backoff does not.
- The client documents the limit as 'about 100 per minute'. How would you measure the real one without tripping it in their production?
Compare release versions and report the live estate spread
Easy · parsing · version skew · ordering
Each environment row carries reported_release as TEXT: either NULL, or a version of the form MAJOR.MINOR.PATCH with an optional -rc.N suffix. Up to 400 environments. Ordering rule: compare major, then minor, then patch numerically; a -rc.N build precedes the same MAJOR.MINOR.PATCH with no suffix, and rc numbers compare numerically. Return the lowest and highest reported versions, the minor-version spread between them, and the count of environments whose reported_release is NULL. NULL means the environment's version is unknown, not that it converged.
Approach
- Parse each string once into a comparable tuple: (major, minor, patch, is_final, rc), where is_final is 1 for a plain release and 0 for a pre-release, and rc is 0 when absent. Tuple comparison then yields the stated order for free, including 3.10.0-rc.1 < 3.10.0. O(L) per string, O(n*L) total.
- Track min and max in one linear scan rather than sorting: O(n) time, O(1) extra space, and n is only a few hundred so the parse dominates either way.
- Fail loudly on a string that does not match the grammar instead of defaulting it to 0.0.0 or to the newest release. An unparseable reported_release is a reconciliation-ingest bug, and either default hides it in the direction that looks safe.
- Compute the minor spread only when min and max share a major version. Across majors the difference of minor numbers is meaningless, so return the pair of versions and flag the condition rather than a number.
- Report the NULL count as its own output line. Folding unknowns into 'converged' is how an install ends up running a release nobody knew was still live.
Follow-up
- Two majors are live at once. What does 'spread' mean now, and what single number, if any, would you still publish?
- reported_release is a claim from the environment, and last_heartbeat_at may be days old for a client-managed install. At what staleness does this figure stop being usable?
- How would you enforce the supported window when the rollout plan is built, instead of reporting a violation after it already exists?
Resolve effective-dated engagement versions for two million access checks
Hard · binary search · effective dating · complexity budget
The engagement table holds 500,000 effective-dated rows: (engagement_id, version, effective_from, effective_to which is NULL for the open version). A database exclusion constraint guarantees that per engagement_id the tstzrange(effective_from, effective_to) values never overlap, and the ranges are half-open. You must answer 2,000,000 access checks of the form (engagement_id, at_time) in one process with no database, returning the version open at that instant or none. Also report, per engagement, any coverage gap between adjacent versions. Give the complexity of the scan-per-check approach and say why it is not an option.
Approach
- State the naive cost first: scanning the table per check is 2e6 x 5e5 = 1e12 range comparisons. Even at 1e9 simple comparisons per second that is roughly fifteen minutes of pure compare work, against a budget measured in seconds. It is correct, just unaffordable, and the fix is an index built once rather than a faster comparison.
- Build a hash map engagement_id -> array of versions sorted by effective_from, once. O(V log V) to sort, O(V) memory. The build is amortised over 2e6 lookups, which is what makes it worth doing at all.
- Answer each check by binary-searching for the last effective_from <= at_time, then confirming effective_to IS NULL OR at_time < effective_to. If the confirmation fails, the instant lies in a gap and the answer is none, not the neighbouring version. O(log v) per check, O(V log V + Q log v) total.
- Name the precondition: predecessor search is only valid because the ranges per engagement do not overlap, which the exclusion constraint enforces with btree_gist for the equality operator. Without that guarantee you need an interval tree returning all stabbed intervals, at O(log n + k). Use the same half-open convention as the database, where at_time equal to effective_to belongs to the next version.
- Find gaps with one O(V) pass over each sorted array comparing prev.effective_to against next.effective_from. The exclusion constraint forbids overlaps, not holes, so a hole is entirely possible and is where an access check fails closed for a reason nobody can see in the contract.
- If the checks can be buffered, sort them by (engagement_id, at_time) and merge against the version arrays: O(Q log Q + V log V), usually a large constant-factor win over 2e6 random binary searches because the merge walks memory in order.
Follow-up
- The open version has effective_to NULL. How do you keep both the sort and the comparison total without a sentinel that overflows your timestamp type?
- A gap exists for one engagement and a check lands in it. What should the access path return, and what should it emit so someone fixes the amendment?
- Amendments are being written while you answer checks. What read model keeps an answer self-consistent, and what does a check mean if the snapshot is a second stale?
Add a non-null column to a live work-record table
Hard · online migration · lock contention · expand-contract
work_record holds roughly nine million rows and absorbs a month-end write burst. You must add currency CHAR(3) NOT NULL, backfill it from the engagement, add a foreign key to rate_card, and eventually drop the superseded hours_decimal column. Several releases are live, including client-managed installs running two-quarter-old code that still writes hours_decimal. Give the ordered plan, the lock each step takes and for roughly how long, how the backfill is chunked and restarted, and the signal that makes the drop safe.
Approach
- Sequence it expand, dual-write, backfill, switch reads, contract, and be explicit that only the last step is dangerous. Adding is cheap because an old release ignores a column it does not know about; removing breaks a writer you cannot upgrade and whose failure you may not see for weeks.
- ALTER TABLE work_record ADD COLUMN currency CHAR(3) — nullable, no default. That is a catalog-only change on every supported version and takes milliseconds. Refuse NOT NULL DEFAULT 'XXX': PostgreSQL 11 and later store a non-volatile default in the catalog and materialise it on read, so it is just as fast, but it hands all nine million rows a value that is already non-null and already wrong. That destroys the rest of the plan — the NOT NULL you add later is satisfied from the first second, so it can never find a row the backfill missed, and every consumer now has to distinguish a real currency from a sentinel forever. A NULL is the only marker that survives a crashed backfill.
- The statement is instant; the lock queue is not. An ACCESS EXCLUSIVE request waits behind every open transaction holding a conflicting lock on the table, and every request arriving after it queues behind that, so one long analytics SELECT turns instant DDL into a total stall. Run it with SET lock_timeout = '2s' inside a retry loop that catches 55P03 and backs off with jitter.
- Decide up front what an unconverged writer inserts. Two-quarter-old code omits currency, so it writes NULL, which means SET NOT NULL would start rejecting its inserts outright. Either fill the gap in the database with a BEFORE INSERT trigger that resolves currency from the engagement when NEW.currency IS NULL — one indexed lookup per insert, which you should time against the month-end burst before committing to it — or leave the column nullable and gate SET NOT NULL on the same release-convergence signal that gates the drop. What you must not do is paper over it with a default, which turns a visible NULL from an unconverged writer into an invisible 'XXX' that reconciliation will never flag.
- Backfill on a keyset cursor — WHERE work_record_id > $last AND currency IS NULL ORDER BY work_record_id LIMIT 5000 — one transaction per batch, advancing $last to the last work_record_id the batch wrote so filled rows are never revisited. Set statement_timeout, pause between batches so autovacuum and any replicas keep up, and persist the cursor so a crash resumes from the last committed batch. OFFSET n costs O(n) per page because the server scans and discards those rows, which makes the whole backfill quadratic in the table size.
- Take NOT NULL without a blocking scan, and only once the backfill reports zero rows remaining: ADD CONSTRAINT work_record_currency_nn CHECK (currency IS NOT NULL) NOT VALID takes a brief ACCESS EXCLUSIVE and scans nothing; VALIDATE CONSTRAINT runs under SHARE UPDATE EXCLUSIVE so reads and writes continue throughout, and raises check_violation, SQLSTATE 23514, naming a row if the backfill missed one — that failure is the entire reason the step exists, and it only exists because the column was added without a default; then SET NOT NULL, which on PostgreSQL 12 and later proves itself from the validated CHECK and skips its own full scan.
- Use the same shape for the foreign key — ADD CONSTRAINT ... REFERENCES rate_card(...) NOT VALID, then VALIDATE CONSTRAINT — and build any supporting index with CREATE INDEX CONCURRENTLY, which cannot run inside a transaction block, scans the table twice, and on failure leaves an INVALID index that must be dropped concurrently before you retry. Note what the FK does not prove: a single-column reference is MATCH SIMPLE, so rows with NULL currency pass validation untouched, and validating it before the backfill finishes certifies only the rows that were already correct.
- Gate the contract step on observed convergence rather than elapsed time: every non-destroyed environment must report a reported_release_id at or past the release that stopped writing hours_decimal, and reported_release_id IS NULL counts as unknown and therefore blocks. DROP COLUMN itself is a catalog-only update, instant under ACCESS EXCLUSIVE, reclaiming no space until a rewrite — the cost is entirely in the writer you did not know was still running.
Follow-up
- A client-managed install reports a release you no longer support and has not converged for two quarters. Do you drop, and what do you owe that client first?
- During the dual-write window, which column does the month-end reconciliation read, and what does a mismatch between the two mean?
- The backfill dies at batch 900 of 1,800. What exactly does the restart re-read, and how do you know it did not skip a range?
Fix a billing report that double-counts through a join
Medium · join fan-out · aggregation · reporting
A monthly report joins engagement to work_record (engagement_id, principal_id, work_date, minutes, is_billable, status) and to invoice_line (engagement_id, period_start, period_end, amount_minor, line_type, status) in a single FROM clause, then aggregates sum(minutes), count(DISTINCT principal_id) and sum(amount_minor) grouped by engagement. Finance reports the minutes are roughly triple the timesheets while the headcount column looks correct. Explain the arithmetic, then write the corrected query for one client and one period, returning zero rather than NULL for engagements with no work.
Approach
- Name the arithmetic before touching SQL: joining two independent one-to-many children of the same parent produces the Cartesian product per engagement, |W| x |L| rows. sum(minutes) is therefore multiplied by the invoice-line count and sum(amount_minor) by the work-record count. Three lines per engagement — fees, expense, credit note — is exactly the factor of three finance reported.
- Explain why the headcount column looked fine: count(DISTINCT principal_id) collapses the duplication, so it is correct by accident. That is precisely why the bug survived review, and it is the reason a column agreeing with expectations is not evidence that a join is at the right grain.
- Pre-aggregate each branch to the join grain first: one subquery per fact table, grouped by engagement_id, each producing at most one row per engagement. Join those results, which are now one-to-one, so no multiplication is possible by construction rather than by care.
- Drive the outer query from engagement with LEFT JOINs and wrap each aggregate in COALESCE, because sum over zero rows is NULL rather than 0. For counts, count the key column and not count(*), which returns 1 for a non-matching LEFT JOIN row.
- Keep credit notes inside the amount sum deliberately: amount_minor is negative for line_type = 'credit_note', so filtering them out overstates what was billed. Exclude status = 'void' instead, and state that choice in the query as a comment because the next reader will assume the opposite.
- Compare cost: the fan-out plan materialises |W| x |L| intermediate rows before aggregating, while pre-aggregation reads each table once under its own index and hash-joins two small results. The base-table I/O is identical; the intermediate work differs by orders of magnitude on a large engagement.
Worked solution (25 min)
- Build a fixture: one engagement with two work records of 60 and 90 minutes and three invoice lines.
- Run the broken join and confirm sum(minutes) returns 450 rather than 150, and that count(DISTINCT principal_id) is unaffected.
- Rewrite with one aggregating CTE per fact table and LEFT JOIN both onto engagement.
- Add an engagement with invoice lines but no work records and confirm it returns 0 rather than NULL or a missing row.
Expected result: WITH w AS (SELECT engagement_id, sum(minutes) AS billable_minutes, count(DISTINCT principal_id) AS people FROM work_record WHERE work_date >= $2 AND work_date < $3 AND is_billable AND status IN ('approved','invoiced') GROUP BY engagement_id), l AS (SELECT engagement_id, sum(amount_minor) AS billed_minor FROM invoice_line WHERE period_start >= $2 AND period_end <= $3 AND status <> 'void' GROUP BY engagement_id) SELECT e.engagement_id, COALESCE(w.billable_minutes, 0) AS billable_minutes, COALESCE(w.people, 0) AS people, COALESCE(l.billed_minor, 0) AS billed_minor FROM engagement e LEFT JOIN w USING (engagement_id) LEFT JOIN l USING (engagement_id) WHERE e.client_id = $1 AND e.effective_to IS NULL ORDER BY e.engagement_id;
Verify it yourself
- On the fixture the corrected query returns 150 minutes where the broken one returned 450.
- Totals reconcile against SELECT sum(minutes) FROM work_record with the same predicates run on its own.
- An engagement with no work records appears once with zeros, not absent and not NULL.
- Adding a fourth invoice line changes billed_minor and leaves billable_minutes untouched.
Follow-up
- The report now needs a per-principal breakdown alongside the per-engagement totals. Does your shape survive, or do you need a different grain entirely?
- How do you stop the next analyst reintroducing this — a view, a naming convention, or a test that fails on a fixture with two children?
- Why does sum(DISTINCT minutes) not fix it, and what does it actually compute?
What do you know about Ampirical Solutions and our role in the industry?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- Work from the requirement backwards to the design.
- State your assumptions explicitly before working the problem.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
How do you approach the testing and commissioning phase of a project?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- Clarify what is being asked and what a complete answer contains.
- Work from the requirement backwards to the design.
Follow-up
- How would you know your answer was wrong?
- What assumption would you test first?
Are you familiar with CAD software or similar design/modeling tools used in engineering projects?
Medium
Approach
- Say what you would check first and why it is the highest-information step.
- Work from the requirement backwards to the design.
- Clarify what is being asked and what a complete answer contains.
Follow-up
- What assumption would you test first?
- How would you know your answer was wrong?
Split a field in an API twelve live versions cannot upgrade
Hard · versioning · backward-compatibility · deprecation · expand-contract
Your public API returns a work-record representation carrying a single decimal hours field. You need to move to integer minutes, add an activity field older responses do not carry, and add two values to the status enum. A dozen release versions are live at once: shared-pool tenants upgrade weekly, dedicated deployments quarterly, and client-managed installs on their own schedule, currently two quarters behind. The callers are client-written integrations you cannot see or compel. Design the evolution: what you version, the sequence, the signal that removal is safe, and what breaks a client that does nothing.
Approach
- Separate the three changes, because their compatibility properties are different. Adding
minutesbesidehoursis additive and safe for any reader that ignores unknown fields. Addingactivityis additive. Adding enum values is not: a client that switches exhaustively onstatusand throws on the default branch breaks, so whether this is safe depends entirely on whether 'unknown values map to a documented fallback' was published before those clients were written. If it was not, the addition belongs behind a new major version, not in this one. - Choose the versioning unit deliberately. A major version in the path is coarse and drags every endpoint along at once; a version header or per-representation media types are finer but multiply the test matrix by the number of live combinations. With a two-quarter tail, the cheapest correct answer is additive-only evolution inside the current major, with a new major held in reserve for changes that cannot be expressed additively — and removing
hoursis exactly one of those. - Sequence it expand, dual-write, backfill, move reads, contract. Emit both fields for the whole window with
hoursderived fromminutes, so there is one source of truth and the two cannot disagree. The mistake is storing both: some path then writes one and not the other, and a client reading the stale one produces a billing discrepancy nobody can attribute. On the write path accept either field, and reject a request carrying both with inconsistent values rather than quietly preferring one. - Respect the precision problem that motivates the change, because it also constrains the transition. A value like 7.4 hours is not exactly representable in binary floating point, so
hoursround-trips inexactly and rounding to minute granularity can differ between your renderer and a client's parser. Keep integerminutesas the value of record, derivehoursfor display, and document the rounding rule so a client reconciling the two fields arrives at your number rather than its own. - Gate removal on observed convergence rather than on a date. Record the release version and environment_id against every request and expose per-field usage counts, so you can name the environments still reading
hours. Publish a Sunset header (RFC 8594) with its Deprecation companion and an end-of-life date, then be prepared to move that date: a client-managed install two quarters behind is a fact about the estate, not a communications failure. - Write down what a client that does nothing experiences at each step, because that is the contract's real specification. Additive steps: nothing. The enum addition: a failure if it switches exhaustively, which is why the fallback rule must pre-date it. Removal: a hard break, which is why it happens only behind a major version or after the usage count for that field has been zero across every environment you can see.
Worked solution (40 min)
- Write the current and target representations side by side, labelling each delta additive, behavioural or breaking with one line of reasoning.
- Write the transitional representation showing
hoursderived fromminutes, with the rounding rule stated explicitly. - Write the write path's rule for a request carrying both fields, including the inconsistent case and which field wins if you accept both.
- Design the usage telemetry: what you record per request to attribute a field read to an environment_id and release, and what you will still not be able to see.
- Draft the deprecation dates and headers, then write the single observation that authorises removing
hours. - For each step, write one sentence describing what a client that does nothing experiences.
Expected result: Additive introduction of minutes and activity inside the current major, hours derived from minutes with a documented rounding rule, a write path accepting either and rejecting inconsistent duplicates, enum additions withheld unless an unknown-value fallback was already published, per-environment field-usage telemetry, Sunset and Deprecation headers with a movable date, and removal of hours only behind a major version or against observed zero usage.
Verify it yourself
- A client compiled against the old representation and left untouched keeps working through every step before removal.
hoursandminutescannot disagree, because only one of them is stored.- You can name, today, the environments that would break if
hourswere removed tonight.
Follow-up
- Telemetry says one client-managed environment still reads
hoursand its contact has not replied in eight weeks. What do you do, and what do you refuse to do? - A client insists on pinning to a version for three years. What do you actually commit to supporting, and how does that appear in the contract?
Revenue dashboard and issued invoices disagree for one region
Medium · timezones · billing · reconciliation
Finance reports that the monthly revenue dashboard runs 0.3 to 1.2 percent below the invoiced total for one region every month, and that the gap is largest when the month ends on a Friday or Saturday. The invoice generator selects approved work_record rows with work_date between period_start and period_end. The dashboard aggregates the same table with date_trunc('month', entered_at) inside a separate reporting job. Say which figure is correct, name both defects, and give the fix.
Approach
- Reconcile rows, not totals. For one engagement and one month, take the set difference between the work_record_ids the generator selects and the ids the dashboard aggregates. A percentage gap is unarguable; a list of discrepant rows names the defect in a minute.
- Read the columns on the discrepant rows. They will share one pattern: work_date inside the period, entered_at after it. That identifies the first defect as column choice. entered_at is when somebody filled in the timesheet, routinely days later by design, so end-of-month work lands in the following bucket. The weekday correlation is the confirmation, because a month ending Friday or Saturday pushes more entries into Monday.
- Fix the column and reconcile again, expecting a residual for one region. date_trunc('month', x) on a timestamptz truncates in the session TimeZone setting; the reporting job runs with TimeZone = UTC while that region's teams work at UTC+10, so entries made in their local morning on the first carry a UTC instant on the previous day. The same applies to any timestamptz cast to date.
- Demonstrate rather than assert: run the same date_trunc over the same row with SET TimeZone = 'UTC' and then with the region's zone, and show the two buckets differ. That is the discriminating observation, and it takes one query.
- Fix by removing the ambiguity, not by compensating for it. Aggregate on work_date, which is a DATE and has no zone to be wrong about, and make it the only period key for both billing and reporting. Where a timestamptz genuinely must be bucketed, such as entry latency or an SLA timer, state the zone explicitly via date_trunc's three-argument form on PostgreSQL 16 or later, or an explicit AT TIME ZONE, and never rely on the session default.
- State the answer plainly: the invoice is correct and the dashboard is wrong. Do not reconcile by adjusting invoiced rows, which are immutable once locked.
Follow-up
- Finance asks for a restatement of the last twelve months. What is safe to restate, given that invoiced work is immutable and corrections are credit notes?
- Someone proposes storing work_date as a timestamptz for consistency with the rest of the schema. Argue the case either way.
- What test fails the build if a future reporting job reintroduces session-dependent bucketing?
Suggested practice checklist
Roughly ninety minutes on weeknights with one longer weekend block. The plan cuts scope rather than compressing everything, on the assumption that one thing finished per night beats four half-started.
Day 1 — Fix the scope and take a cold baseline
- Read the role description and write the three things the loop will almost certainly test, then write an explicit not-doing list and keep it visible all week.
- Take one twenty-five-minute coding problem and one fifteen-minute design prompt cold, and write the single sentence naming what blocked each, because those two sentences decide where the remaining evenings go.
- Set the week's rule: one thing finished every night, including the night you only have forty minutes.
Deliverable: A one-page scope with a not-doing list and two cold attempts, each carrying one sentence on what blocked it.
Day 2 — One pattern, written three times from blank
- Choose the single pattern most likely to appear in your loop and write it three times from an empty file rather than editing the previous attempt.
- On the third pass, write the invariant as a comment before the loop body and the complexity before the first line of code.
- Stop at ninety minutes even if the third version is imperfect, and write the one thing you would fix given another hour.
Deliverable: Three independent implementations of the same pattern plus a note on what changed between them.
Day 3 — One design, only to the depth you can defend
- Take one system shape and go only as far as requirements, interface and data model, refusing to draw a box you could not survive a follow-up about.
- Attach one number to each non-functional requirement, deriving it rather than asserting it, and write the assumption the number rests on.
- Write the one tradeoff you are choosing against and the observation that would make you reverse it.
Deliverable: One design at interface-and-schema depth with derived numbers and one written reversible tradeoff.
Day 4 — Only the fundamentals you will have to defend
- Write, in under two hundred words each, the answers to the two questions that follow almost any implementation: why this structure and not the obvious alternative, and what happens to this code at a hundred times the input.
- Write what an index actually costs: faster lookups on the indexed columns against a write that now maintains a second structure, plus the cases where the planner declines to use it anyway, low selectivity, or a predicate wrapping the column in a function.
- Delete any answer you cannot deliver aloud in under a minute, since an answer that needs reading is not an answer you have.
Deliverable: Three written answers, each under two hundred words and each timed aloud.
Day 5 — Your own work, timed
- Write a ninety-second and a four-minute version of your main project and time both aloud rather than reading them.
- Prepare the two follow-ups that always come: what you would do differently, and how you knew it worked.
- Put one number in the first sentence and be ready to say exactly where it came from and what it excludes.
Deliverable: Two timed narratives with one defensible number in the opening line.
Day 6 — The one full rehearsal, in the weekend block
- Run a sixty-minute mock covering a coding round and a design round in one sitting with no break, because sustained attention is the thing evenings have not trained.
- Immediately afterwards, and before hearing any feedback, write the three moments you lost the thread.
- Spend the rest of the block only on those three moments, and on nothing you merely feel shaky about.
Deliverable: Mock notes naming three failure moments with a specific fix written under each.
Day 7 — Taper
- Write the twenty-minute warm-up you will actually do on the morning: one problem you can already solve from a blank file, one design you can narrate, and nothing you have never seen.
- Re-read only your own notes from this week and open no new material.
- Write the logistics down: the editor or shared document you will be working in, whether execution and lookups are permitted, and the sentence you will use when you do not know something.
Deliverable: A one-page card holding the design structure, the project numbers, and the logistics.
The failure that was yours
Keep one story where the bad call was yours rather than a dependency's or a manager's. Name the check that would have caught it, whether you added that check afterwards, and whether it has fired since. Answers that route blame outward end the conversation early; answers that end in a guardrail someone still relies on tend to open it up.
What motivated you to pursue a career in engineering?
Medium · behavioural and engineering judgement
Approach
- Name the disagreement and how you resolved it with evidence.
- Give the blast radius: what could have broken, and what you measured.
- State the situation in two sentences and spend the rest on the reasoning.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?
Can you describe a time you had to explain a complex technical concept to a non-technical stakeholder?
Medium · behavioural and engineering judgement
Approach
- Name the disagreement and how you resolved it with evidence.
- State the situation in two sentences and spend the rest on the reasoning.
- Give the blast radius: what could have broken, and what you measured.
Follow-up
- What did you decide not to do, and why?
- How did you know your change caused the improvement?
How do you handle having multiple deadlines and determining what gets priority?
Medium · behavioural and engineering judgement
Approach
- Name the disagreement and how you resolved it with evidence.
- Pick a story where you made the decision, not one where you watched it.
- State the situation in two sentences and spend the rest on the reasoning.
Follow-up
- How did you know your change caused the improvement?
- What would you do differently if you ran that again?