# Agent Rating System — decision log

The Phase 5 answer key. Every entry is a question an interviewer actually asks ("why is that a
separate class?") and the answer that earns the mark: **what change the seam anticipates, and what
the change would cost without it.**

## The axis this problem owns, and the one it deliberately does not

Atlassian's bank has a near-duplicate of this problem, `customer-satisfaction`: customers rate an
agent 1-5, an average is kept, and a report is produced. The two are split deliberately along
different axes so meeting the shape twice is useful practice rather than repetition:

- **This problem owns storage and ordering** — an incrementally maintained average versus a
  recompute, where the ordering policy lives, and top-N.
- `customer-satisfaction` owns **reporting and format** — month bucketing behind an injected
  clock, and the export-format seam. Its own decision log names this problem back explicitly:
  *"No running-average field, no incremental maintenance… that decision belongs to `rating-system`,
  which owns the storage-and-ordering axis"* and *"No top-N… it's `rating-system`'s stated seam, not
  this problem's."* This log reciprocates: `rating-system` must not grow an exporter or a clock, and
  it does not have one. Nothing here reads wall-clock time, buckets a rating into a calendar period,
  or renders anything as text — an agent's standing is a number and a count, never a report.

## The measurement, first, because it is the argument

Three requirement changes, each absorbed with the reference's own diff measured by
`server/lib/diff.mjs`:

| Curveball | Where it landed | Measured cost |
|---|---|---|
| ratings age — a 50/50 recency-weighted average | two brand-new files, nothing existing opened | **0 lines** |
| top-N per category | one field and two delegating overrides in `RatingSystem`, plus one new file | **11 lines** |
| a minimum-ratings threshold | two delegating overrides appended to `RatingSystem` | **11 lines** |

`AgentRatingBook`, `InMemoryAgentRatingBook`, `RankingOrder`, `StandardRankingOrder`, `Rankings`,
`AveragingStrategy`, `SimpleMeanStrategy`, `RatingSystemFactory` and `Validation` were **not
opened** by any of the three curveballs — only `RatingSystem` itself, and only to add delegating
methods. That is the claim this design makes, and it is measurable rather than aesthetic.

---

## Seam 1 · `AveragingStrategy` — the incremental-vs-recompute decision, defended

**This is the central design choice the axis-split hands this problem, so it gets said plainly:
the reference maintains each agent's average incrementally. It never stores a raw rating history
and never recomputes.**

`AgentRatingBook.record()` returns a brand-new `AgentRating` built from only the agent's *previous*
`AgentRating` and the one new star value — `AveragingStrategy.nextRating(current, stars)`. Nothing
downstream of the first rating ever looks at a list of past ratings; there isn't one. The standard
rule, `SimpleMeanStrategy`, uses Welford's running-mean update —
`average + (newValue - average) / newCount` — rather than reconstructing a sum via
`(average * count + newValue) / newCount`. Both are the same real number; Welford's form is the
numerically steadier one because it never multiplies an already-rounded average back out to fake a
sum, which is exactly the kind of thing that drifts after enough ratings. The state carried per
agent is two numbers, `(average, count)`, for the whole lifetime of the system.

**Why incremental, and not recompute, here.** Three reasons, in order of how much they matter to
this problem specifically:

1. **The contract's own shape asks for it.** `RatingSystemApi`'s javadoc states the average is
   "kept up to date as ratings arrive" — a running quantity, not a report generated on request.
2. **It is what makes curveball 1 cost zero.** A recency-weighted average is a *pure function of
   the previous standing and the new rating* — no different in shape from the plain mean. A design
   that stored raw ratings and recomputed on every read would have had to decide, for "ratings
   age", how much history to keep, how to weight each entry by its position, and re-derive the
   answer over the whole list on every `leaderboard()` call. The incremental shape sidesteps all of
   that: `RecencyWeightedStrategy.nextRating` is exactly as small as `SimpleMeanStrategy.nextRating`,
   because both do the same shape of work over the same two numbers.
3. **It is the opposite of `customer-satisfaction`'s choice, correctly.** That problem's desk
   recomputes an agent's monthly averages from stored ratings on every `exportMonthly()` call,
   and its own decision log defends that as right *for it* — the recompute there is
   `O(ratings for one agent)`, the desk is in-memory, and per-month bucketing needs the full
   history anyway to group by calendar month. This problem has no month, no export and no need to
   ever re-derive a number it already has; keeping a raw history here would be paying a storage and
   iteration cost for a capability — reproducing history-dependent reports — nothing in this
   problem's contract ever asks for. Recompute is `customer-satisfaction`'s correct answer to a
   different question; incremental is this problem's correct answer to this one.

**What incremental costs, honestly.** An incrementally maintained average cannot answer "what was
this agent's average as of last Tuesday" or "recompute under a different weighting after the fact"
without replaying history it never kept — because it never kept any. Nothing in this problem's
contract asks that question, but a candidate should be able to name the trade being made, not just
its winning side.

## Seam 2 · `AgentRatingBook` — the repository seam curveball 2 proves

**Anticipates:** any later requirement that scopes ratings to something narrower than the whole
system — a skill, a category, a team.

The interface is deliberately thin: `record(agentId, stars)` and `all()`. It knows nothing about
ranking, truncation, or which averaging rule produced the numbers it stores. The tempting shape for
"rate an agent within a category" is a second parameter threaded onto `record`, or a second
implementation that is "the same book but keyed by category too" — either would open
`InMemoryAgentRatingBook` and make every future category-shaped question a second edit to the
storage interface, forever. The reference instead reuses the one existing interface and
implementation completely unchanged: `CategoryBooks` (curveball 2's new file) is nothing but a
`Map<String, AgentRatingBook>`, one `InMemoryAgentRatingBook` per category, created lazily on that
category's first rating. A category's ranking is **a second book, not a second kind of book**, and
`RatingSystem.rateInCategory` / `topNInCategory` are one-line delegations. Measured cost: 11 lines,
one file touched (`RatingSystem`), zero removals, and the repository interface itself was never
opened.

## Seam 3 · `RankingOrder` and `Rankings` — one comparator, and a sort chosen over a heap

**`RankingOrder`'s anticipation:** any later ranked view needs the *same* tie-break rule as the
first one, and only one class should be able to get that rule wrong. `StandardRankingOrder` defines
`Comparator<AgentRating>` exactly once — average descending, then rating count descending, then
agent id ascending — and every ranked view (`leaderboard()`, `topN()`, curveball 2's
`topNInCategory()`, curveball 3's filtered `leaderboard(int)`) asks this one object for it rather
than writing its own `Comparator.comparing` chain. A second view that inlined its own chain would
risk agreeing with this one by coincidence, which is exactly the kind of drift a tie-break rule
cannot afford — and across three curveballs, none of them needed to.

**`Rankings`' anticipation:** a ranked, truncated view over *some* agents, whoever "some" turns out
to mean — the whole book, one category, or a filtered subset. `ordered()` and `topN()` are two pure
functions over a `List<AgentRating>`, not methods on the book, so curveball 3's minimum-ratings
threshold is just a differently filtered list handed to the same two functions — `RatingSystem`
filters `leaderboard()` by `AgentRating::ratingCount` *before* calling `Rankings.topN`, which is why
the tie-break rule still holds among survivors and a `topN(n, minimumRatings)` request never comes
back shorter than `n` when enough qualifying agents exist.

**Why `topN` sorts everything rather than using a bounded heap.** A top-K selection is
`O(m log n)` with a bounded priority queue against `O(m log m)` for a full sort — a real technique,
and a fair follow-up question. The honest answer is that it is not worth building here: this
problem's own contract never claims a scale where `m` (rated agents) is large enough for the
difference to matter, a bounded heap does not preserve `RankingOrder`'s stability for free the way
`List.sort` does, and reaching for it now would be exactly the speculative-interface mistake
STANDARD v1.0 marks down at D3/D4 level 3. If this system ever needed to rank millions of agents,
`Rankings.topN` is the one function that would change — nothing else in this design would need to
know.

## The tie-break rule chosen, and why

Three candidates were on the table, and the contract states the choice rather than leaving it
implicit:

1. **Chosen: more ratings first, then agent id.** More ratings is more evidence behind the same
   score — an agent averaging 4.0 from 200 ratings has earned that number in a way one averaging 4.0
   from 2 has not — and agent id is a total, meaningless tie-break that only exists to make the
   order deterministic. Curveball 3's incident report ("a brand-new agent got a single lucky 5-star
   rating and sat at the very top") is this exact failure mode one step further — not a tie, but the
   same underlying complaint that a thin track record should not outrank a deep one — and it is why
   a minimum-ratings threshold was a natural, cheap addition rather than a fight against the
   design.
2. **Rejected: most-recent-rating wins.** Defensible, but it rewards recency over volume of
   evidence, and it is a different, equally arbitrary story — the contract names it explicitly as
   the alternative not taken, so a candidate who reasonably picked it can see why the suite
   disagrees. (Curveball 1's recency-weighted *average* is not this: it changes how a rating is
   folded into the number being ranked, never how a tie in that number is broken.)
3. **Rejected: registration or arrival order.** Not stated as a serious option; it carries no
   information about either agent's performance and would be indistinguishable from an unspecified
   order.

## Every other ambiguity pinned, and where

- **The 1-5 scale is inclusive; 0, negative or 6+ throws `IllegalArgumentException`**, and so does
  a blank or null `agentId` — both checked once, in `Validation`, so `RatingSystem.rate` cannot
  drift from what the contract states.
- **An agent with no ratings never appears in any listing** — not because unrated agents sort last,
  but because the design rules the case out entirely: there is no roster and no "add an agent"
  call, so the question of where a zero-rating agent sorts cannot arise. Stated on the interface's
  class javadoc as a deliberate answer, not a gap.
- **The average is exact everywhere; there is no rounding in this problem at all** — not for
  ranking, not for display. `AgentRating.averageRating()` is a `double` carried straight through
  from whichever `AveragingStrategy` produced it. A caller that wants a rounded number for a report
  is solving `customer-satisfaction`'s problem, not this one.
- **`topN`'s edge cases are explicit, not incidental**: `n < 1` throws; `n` larger than the number
  of rated agents returns a short list, never padded; zero rated agents is an empty list, not an
  error. Curveball 3 restates the same three shapes for `leaderboard(int minimumRatings)` and
  `topN(int, int)`, and a threshold of exactly zero is defined to behave identically to the
  unqualified method — the plain methods do not change meaning the day a threshold exists.
- **A category behaves like an agent id**: it cannot be blank, it is case-sensitive, and there is no
  "register a category" call — a category comes into existence on its first rating, exactly the way
  an agent does, so the same "does an unrated/uncategorised thing appear anywhere" answer applies
  twice by construction instead of by two separate decisions that happened to agree.

## What was deliberately not built

- **No export formats, no rendering of any number as text.** That is the seam
  `customer-satisfaction` stakes its claim on; building it here would duplicate the other
  problem's centre rather than exercise this one's.
- **No injected clock, no month or calendar bucketing.** Nothing in this problem's contract is
  time-based — an agent's standing has no "when" attached to it beyond the order ratings arrived in,
  which the incremental average does not even need to record. Contrast `customer-satisfaction`,
  where an injected `TimeSource` is unambiguously required because month bucketing cannot be tested
  without one.
- **No agent roster, no "register an agent" call.** Considered and rejected in favour of ruling the
  unrated-agent question out entirely — see "every other ambiguity pinned" above.
- **No bounded-heap top-K.** Considered and rejected as premature — see Seam 3.
- **No raw rating history anywhere.** The entire point of the incremental-average decision is that
  one is never needed; keeping one "just in case" would have been the natural-looking
  over-engineering this corpus's curveball ladder is built to catch, and curveball 1 is the proof
  that it was never missed.

## An honest note on the weakest tag

**C3 (dependency inversion and injection, including injecting the clock)** is tagged on the
strength of `RatingSystem` depending on `AgentRatingBook` and `RankingOrder` through its
constructor rather than constructing either itself — real dependency injection, and the mechanism
that lets `RatingSystemFactory` (and curveball 1's `AgingRatingSystemFactory`) assemble a different
system without editing `RatingSystem` at all. But this problem has no clock and nothing
time-dependent, so it never gets to make the sharper version of the C3 argument other corpus
problems make — injecting a `Clock` because "now" cannot be a hidden global, the way
`customer-satisfaction` does for month bucketing. It is also worth naming that neither injected
collaborator ever gets a *second* production implementation of its own across the base problem or
any of the three curveballs: `AgentRatingBook` has exactly one (`InMemoryAgentRatingBook`, reused
by `CategoryBooks` rather than replaced), and `RankingOrder` has exactly one
(`StandardRankingOrder`) from the base problem through all three curveballs. `AveragingStrategy` is
the seam that earns a real B1 tag on two implementations proven by a measured curveball; constructor
injection of the other two collaborators is a real but more modest instance of C3, and it is named
as such here rather than inflated.
