Gists
A synced, readable copy of my gists from gist.github.com/bl9. Each page is the gist content pasted verbatim; the table links to the local page and to the original gist.
21 gists, newest first. Last synced 2026-08-07.
| Updated | Gist | File(s) | Size |
|---|---|---|---|
| 2026-08-07 | opensearch_knn_filter_pushdown 🔒 | opensearch_knn_filter_pushdown.md | 54 KB |
| 2026-08-07 | kafka 🔒 | kafka.md | 131 KB |
| 2026-08-06 | CS7641 Machine Learning Prep 🔒 | cs7641-Machine-Learning-Prep.md | 72 KB |
| 2026-08-03 | Team Lead & Staff Engineer 🔒 | TeamLeadStaffEngineer.md | 487 KB |
| 2026-08-02 | graph 🔒 | graph.md | 59 KB |
| 2026-08-02 | bmad_e2e 🔒 | bmad_e2e.md | 121 KB |
| 2026-07-28 | kimi 🔒 | kimi.md | 78 KB |
| 2026-07-25 | bmad-rcon-howto 🔒 | bmad-rcon-howto.md | 42 KB |
| 2026-07-25 | llm 🔒 | llm.md | 70 KB |
| 2026-07-25 | BMAD Recon 🔒 | bmad-recon.md | 99 KB |
| 2026-07-25 | bmad-rcon-foundation 🔒 | bmad-rcon-foundation.md | 43 KB |
| 2026-07-23 | bmad_Research 🔒 | bmad_research.md | 65 KB |
| 2026-07-23 | Similarity-Calculations 🔒 | Similarity-Calculations.md | 33 KB |
| 2026-07-10 | vector_plot 🔒 | vector_plot.md | 4 KB |
| 2026-06-22 | ios_sensors 🔒 | ios_sensors.md | 86 KB |
| 2026-06-11 | autocmds 🔒 | autocmds.lua | 4 KB |
| 2026-06-03 | skull 🔒 | skull.md | 9 KB |
| 2026-06-03 | tez 🔒 | prompt.txt, tez.txt | 73 KB |
| 2026-05-29 | apache_tez 🔒 | apache_tez.md | 36 KB |
| 2026-05-19 | AI Tools 🔒 | ai_tools.md | 8 KB |
| 2024-08-19 | c_linked_list | c_linked_list.c | 872 B |
🔒 = secret gist (unlisted on GitHub).
Two additional gists (
codes,gistfile1.txt) hold private recovery phrases and are intentionally kept out of this published section.
Source: opensearch_knn_filter_pushdown ·
opensearch_knn_filter_pushdown.md· updated 2026-08-07 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Common Filter Push-Down in OpenSearch — RFC, Design, and the k-NN filter() Implementation
Deep dive on:
- RFC: neural-search#1135 — Common Filter Support for Hybrid Query Sub-Queries
- Implementation: k-NN#2599 — Add filter function to KNNQueryBuilder
- Prerequisite: OpenSearch#17409 — Add filter function for AbstractQueryBuilder, BoolQueryBuilder, ConstantScoreQueryBuilder
- Consumer: neural-search#1206 — Add filter function for NeuralQueryBuilder and HybridQueryBuilder
Shipped in OpenSearch 3.0.
Table of Contents
- TL;DR
- Background concepts you need first
- The RFC (neural-search#1135)
- Architecture: three repos, one contract
- k-NN PR #2599, line by line
- End-to-end request trace
- Semantics, edge cases, and real bugs
- Build it yourself
- Testing
- Limitations and follow-ups
- Reference index
1. TL;DR
- User-facing feature:
hybridquery gets a top-levelfilterfield. One filter, applied to every sub-query. - Mechanism: a new method
QueryBuilder#filter(QueryBuilder)on the core query-builder interface. It means "combine this filter into me and give me back the resulting builder." - Default behaviour:
AbstractQueryBuilderwrapsthisin abool { must: this, filter: <f> }. Query types that natively understand filters override it to do something smarter. - k-NN's job (PR #2599): override
filter()onKNNQueryBuilderso the filter lands inside the k-NN query rather than around it. That's the whole point — a filter inside a k-NN query participates in ANN traversal; a filter outside it is post-filtering and silently destroys recall. - Size: 40 lines of production code, 105 lines of tests, 1 CHANGELOG line. The design work is 95% of the value.
2. Background concepts you need first
2.1 The OpenSearch query lifecycle
A _search request goes through roughly this pipeline:
- Route — REST layer resolves the handler, request lands on a coordinating node.
- Parse — the JSON query DSL is parsed into a tree of
QueryBuilderobjects (fromXContent). - Search request processors — if a search pipeline defines any, they mutate the
SearchRequesthere. - Rewrite —
QueryBuilder#rewrite()runs, possibly several rounds, possibly async (this is wheretermslookups,neural→knnmodel inference, etc. resolve). - Serialize + fan out — the rewritten builder tree is written to
StreamOutputand shipped to every data node holding a shard. toQuery()— on each shard,QueryBuilder#toQuery(QueryShardContext)produces a LuceneQuery.- Execute — Lucene collects hits per segment.
- Post-process — search phase results processors (e.g. normalization/combination for hybrid), then response.
Steps 2 and 3 are the only two places where you can still restructure the query as a builder. That matters enormously, and is exactly the argument the RFC makes.
2.2 QueryBuilder vs Lucene Query
Two different objects, often confused:
QueryBuilder | Lucene Query | |
|---|---|---|
| Layer | OpenSearch DSL | Lucene |
| Lifetime | Coordinator → wire → shard | Shard-local, per-search |
| Serializable | Yes (Writeable) | No |
| Mutable | Nominally yes, conventionally no | No |
| Knows about mappings | No (until toQuery) | N/A |
KNNQueryBuilder holds a QueryBuilder filter field. At toQuery() time that filter is handed to KNNQueryFactory, which converts it to a Lucene Query, runs it to get a filtered doc-ID bitset, and feeds that bitset into the ANN search. The filter must be inside the builder before toQuery() runs, or the engine never sees it.
2.3 Hybrid search and why it exists
hybrid (from the neural-search plugin) runs N sub-queries in parallel over the same shards, keeps their score lists separate, then a search-phase processor normalizes each list (min-max, L2, z-score) and combines them (arithmetic/geometric/harmonic mean, RRF). This is how you fuse BM25 lexical relevance with dense-vector semantic relevance without one score scale swamping the other.
Canonical shape:
{
"hybrid": {
"queries": [
{ "match": { "text": "wireless earbuds" } },
{ "neural": { "embedding": { "query_text": "wireless earbuds", "model_id": "abc", "k": 50 } } }
]
}
}
Because sub-queries are independent, a filter has to be applied to each of them independently.
2.4 Filtering in vector search: pre, post, and efficient
Three strategies, with very different cost/recall profiles:
- Pre-filtering (scoring script / exact): compute the filtered doc set first, then brute-force distance over exactly those docs. Perfect recall, O(P) distance computations. Great when P is tiny, terrible when P is large.
- Post-filtering (bool filter or
post_filter): run ANN over the whole graph, get k results, then drop the ones that fail the filter. Cheap, but if the filter is at all restrictive you get back far fewer than k results — sometimes zero. - Efficient / "during-search" filtering: hand the filter's doc-ID bitset to the ANN algorithm so it only accepts in-filter candidates while traversing. This is what
knn.filterdoes.
Engine support, per the k-NN docs: efficient k-NN filtering applies filtering during the vector search, which ensures k results are returned if at least k results exist in total; it is supported by the Lucene engine with HNSW (OpenSearch 2.4+) and the Faiss engine with HNSW (2.9+) or IVF (2.10+). From OpenSearch 3.1, Faiss + HNSW additionally uses the Lucene ACORN filtering optimization during graph traversal when memory-optimized search is enabled.
Both engines adaptively pick a strategy. The algorithm weighs N (docs in the index), P (docs surviving the filter), k, R (results returned by filtered ANN), FT (the knn.advanced.filtered_exact_search_threshold index setting), and MDC (max distance computations allowed in exact search when FT is unset). Practically: if the filtered doc-ID list is at or below k, it does exact search, because returning k neighbors requires at least k distance computations anyway.
2.5 Why "tail relevant results" get dropped
This phrase appears throughout the RFC. Concretely:
- Index has 1M docs. Filter
category = "medical"matches 2,000 of them. - Filter outside (
bool { must: knn(k=10), filter: category=medical }): ANN returns the global top 10 by vector distance. If none of those 10 are medical, you get 0 results. If 2 are, you get 2. - Filter inside (
knn { ..., filter: category=medical }): the engine restricts traversal (or switches to exact search over the 2,000) and returns the top 10 medical docs. You get 10.
The "tail" is the relevant-but-not-globally-top documents. Post-filtering throws them away before you ever see them. This is the single technical reason the RFC rejected the simpler designs.
3. The RFC (neural-search#1135)
Authored by bzhangam, opened 22 Jan 2025, labelled v3.0.0, now closed/implemented.
3.1 Problem statement
To apply one filter across a hybrid query today, you repeat it in every sub-query. The neural/knn sub-queries take a filter field; the rest need wrapping in a bool. Verbose, error-prone, and the wrapping form is semantically different from the inline form.
Before:
{
"hybrid": {
"queries": [
{ "neural": { "embedding": { "query_text": "...", "model_id": "m1", "k": 50,
"filter": { "term": { "status": "active" } } } } },
{ "bool": { "must": [ { "match": { "title": "..." } } ],
"filter": [ { "term": { "status": "active" } } ] } },
{ "bool": { "must": [ { "match": { "body": "..." } } ],
"filter": [ { "term": { "status": "active" } } ] } }
]
}
}
After:
{
"hybrid": {
"queries": [
{ "neural": { "embedding": { "query_text": "...", "model_id": "m1", "k": 50 } } },
{ "match": { "title": "..." } },
{ "match": { "body": "..." } }
],
"filter": { "term": { "status": "active" } }
}
}
3.2 Scope and non-goals
Explicitly out of scope in v1:
- Metrics — no per-feature metric API; deferred to a separate project.
- Configurability — one filter, AND-combined, always. No per-sub-query opt-out.
- No change to filter semantics — push-down only relocates the filter; how
neural/knn/booleach interpret a filter is untouched. - No recursive push-down — the filter reaches the top-level sub-query and stops. It does not descend into a sub-query's own inner queries. Deliberate: keeps the transform predictable and cheap.
3.3 High-level design: two options
Option 1 (chosen): push down during parsing. Add a filter field to the hybrid query; apply the push-down in HybridQueryBuilder.fromXContent.
Option 2 (deferred): a new SearchRequestProcessor. Model it on core's FilterQueryRequestProcessor, register it in the search pipeline alongside the normalization processor.
| Option 1 | Option 2 | |
|---|---|---|
| Effort | Modify one existing class | New processor class + same push-down logic |
| Ergonomics | Filter travels with the query | Filter lives in pipeline config |
| Reuse | Repeat per request | Define once; becomes an index default if set as the default search pipeline |
| Verdict | Recommended | Future work |
The deciding constraint: KNNQueryBuilder optimizes its filter before Lucene Query conversion, so push-down must happen at step 2 or 3 of the lifecycle. Both options satisfy that; Option 1 is cheaper and friendlier.
3.4 Low-level design: three options
Option 1 (chosen): copy the filter into sub-queries when the type supports it. Types that natively hold a filter (neural, knn, bool, constant_score, hybrid) get special handling; everything else gets bool-wrapped.
Option 2: always bool-wrap. Trivial to implement, but for neural/knn it degrades to post-filtering and loses the tail. Rejected.
Option 3: use the existing post_filter. Zero effort, but it runs after hybrid normalization and combination, and it is post-filtering by definition. Rejected.
| Pros | Cons | |
|---|---|---|
| Option 1 | Best recall (filter is pushed into ANN); fewer wrapper queries → slightly faster | Most implementation complexity |
| Option 2 | Simple | Loses tail results for vector sub-queries |
| Option 3 | Free | Loses tail results; applies after normalization |
3.5 Per-query-type push-down semantics
| Sub-query type | Behaviour |
|---|---|
knn / neural | Set/merge the builder's own filter field → participates in ANN traversal |
bool | Append to filterClauses — no extra wrapper needed, since bool filters are already applied to the whole clause set |
constant_score | Recurse into its wrapped filter query and apply filter() there |
hybrid | Nested hybrid is not a supported shape; error out |
| everything else | Wrap: bool { must: <sub-query>, filter: <f> } |
Worked example — bool sub-query:
// input
{ "hybrid": {
"queries": [
{ "bool": { "must": [ <q1> ], "filter": [ <f1> ] } },
<q2>
],
"filter": <F> } }
// after parsing
{ "hybrid": {
"queries": [
{ "bool": { "must": [ <q1> ], "filter": [ <f1>, <F> ] } },
{ "bool": { "must": [ <q2> ], "filter": [ <F> ] } }
] } }
Worked example — constant_score wrapping a neural:
// input
{ "hybrid": {
"queries": [
{ "constant_score": { "filter": { "neural": { ... } }, "boost": 1.2 } },
<q1>
],
"filter": <F> } }
// after parsing — F goes INSIDE the neural query, not around the constant_score
{ "hybrid": {
"queries": [
{ "constant_score": { "filter": { "neural": { ..., "filter": <F> } }, "boost": 1.2 } },
{ "bool": { "must": [ <q1> ], "filter": [ <F> ] } }
] } }
3.6 The FilterCombinationMode that didn't ship
The RFC proposed a two-argument signature:
public QueryBuilder filter(QueryBuilder filter, FilterCombinationMode mode);
public enum FilterCombinationMode {
AND, // v1: the only mode
IGNORE_IF_EXISTS, // future: skip if the sub-query already has a filter
OR // future
}
What actually merged in core is the one-argument form, AND hard-coded. Reasonable call — the enum was speculative, and adding a parameter later is an additive change. Worth knowing if you read the RFC and then the code and wonder where the mode went.
3.7 Score filters are blocked
Hybrid normalizes scores after sub-queries run. A user writing a score-based filter at the hybrid level would reasonably expect it to apply to normalized scores — but push-down applies it pre-normalization, per sub-query, on raw scores. The numbers would be meaningless. The RFC's answer: reject score-based filters at parse time rather than document a footgun.
4. Architecture: three repos, one contract
┌─────────────────────────────────────────────────────────────┐
│ OpenSearch core (PR #17409) │
│ │
│ interface QueryBuilder │
│ QueryBuilder filter(QueryBuilder filter); ← contract│
│ │
│ AbstractQueryBuilder → bool{must:this, filter:f} │
│ BoolQueryBuilder → filterClauses.add(f) │
│ ConstantScoreQueryBuilder → recurse into wrapped filter │
└───────────────┬─────────────────────────┬───────────────────┘
│ extends │ extends
┌───────────▼──────────┐ ┌──────────▼──────────────────┐
│ k-NN (PR #2599) │ │ neural-search (PR #1206) │
│ KNNQueryBuilder │ │ NeuralQueryBuilder │
│ → rebuild with │ │ → set/merge queryfilter │
│ merged filter │ │ HybridQueryBuilder │
└──────────────────────┘ │ → fan out to sub-queries │
└─────────────────────────────┘
Merge order was forced: core (3 Mar 2025) → k-NN (14 Mar 2025) → neural-search. neural-search's HybridQueryBuilder.filter() calls query.filter(f) polymorphically, so KNNQueryBuilder must already implement it or you get the base-class bool wrapping and the whole point is lost.
4.1 The contract in core
server/src/main/java/org/opensearch/index/query/QueryBuilder.java:
QueryBuilder filter(QueryBuilder filter);
Documented semantics: combine the filter with this builder. If the builder already carries a filter, merge and return the builder itself. Otherwise use a bool query to combine builder and filter, and return the bool. A null filter is a no-op.
Note this is an interface method with no default — every implementor must supply one. That's why AbstractQueryBuilder provides the fallback: all ~60 core query types extend it, so they all inherit sane behaviour for free.
4.2 Core implementations
AbstractQueryBuilder — the universal fallback:
public static boolean validateFilterParams(QueryBuilder filter) {
return filter != null;
}
public QueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
final BoolQueryBuilder modifiedQB = new BoolQueryBuilder();
modifiedQB.must(this);
modifiedQB.filter(filter);
return modifiedQB;
}
validateFilterParams is public static on purpose: plugin code in other JARs calls it. It is the shared null-guard for every implementation across all three repos.
BoolQueryBuilder — no wrapper needed:
public BoolQueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
filterClauses.add(filter);
return this;
}
This is a pre-existing method, not a new one — BoolQueryBuilder.filter(QueryBuilder) has always meant "add a filter clause." PR #17409 only added the null-guard so the same method could satisfy the new interface contract. The signature covariance (BoolQueryBuilder return type) is legal and keeps the existing fluent API source-compatible. Nice bit of API design: zero breakage, new semantics for free.
ConstantScoreQueryBuilder — recurse:
public ConstantScoreQueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
QueryBuilder filteredFilterBuilder = filterBuilder.filter(filter);
if (filteredFilterBuilder != filterBuilder) {
return new ConstantScoreQueryBuilder(filteredFilterBuilder);
}
return this;
}
The != identity check is the interesting line: if the inner builder mutated itself in place (bool, neural), return this and allocate nothing; if it returned a different object (knn, or a bool-wrapped leaf), rebuild the wrapper around the new object. This "returns-self-or-new" convention runs through the entire feature.
4.3 Plugin implementations
HybridQueryBuilder (neural-search) — the fan-out:
public QueryBuilder filter(QueryBuilder filter) {
if (validateFilterParams(filter) == false) {
return this;
}
ListIterator<QueryBuilder> iterator = queries.listIterator();
while (iterator.hasNext()) {
QueryBuilder query = iterator.next();
// set the query again because query.filter(filter) can return new query.
iterator.set(query.filter(filter));
}
return this;
}
The ListIterator.set() is required, not stylistic. query.filter(f) may return a different object; a plain for-each would compute the new builder and throw it away.
And in fromXContent, the parse-time application:
} else if (FILTER_FIELD.match(currentFieldName, parser.getDeprecationHandler())) {
filter = parseInnerQueryBuilder(parser);
}
...
for (QueryBuilder query : queries) {
if (filter == null) {
compoundQueryBuilder.add(query);
} else {
compoundQueryBuilder.add(query.filter(filter));
}
...
}
Two details worth noting in current main:
filteris only accepted as aSTART_OBJECT. An array ("filter": [...]) or a scalar hitsthrowUnsupportedFilterParsingException→ "[hybrid] query's [filter] field must be a query object". To combine several filters, wrap them in aboolyourself.- Usage is instrumented:
EventStatsManager.increment(EventStatName.HYBRID_QUERY_FILTER_REQUESTS). The RFC listed metrics as out of scope; they were added later.
NeuralQueryBuilder (neural-search) — mutate in place:
public QueryBuilder filter(QueryBuilder filterToBeAdded) {
if (validateFilterParams(filterToBeAdded) == false) {
return this;
}
if (this.queryfilter == null) {
this.queryfilter = filterToBeAdded;
} else {
this.queryfilter = this.queryfilter.filter(filterToBeAdded);
}
return this;
}
Contrast this with the k-NN version below — same problem, opposite style. Same feature, two plugins, two conventions.
4.4 Why the plugins duplicate instead of share
NeuralQueryBuilder and KNNQueryBuilder want nearly identical logic, but they live in different plugins with no shared dependency. The RFC's call: accept the duplication rather than invent a shared library for ~15 lines. Pragmatic, and correct for the size.
5. k-NN PR #2599, line by line
Author: chloewqg · Merged by: junqiu-lei, 14 Mar 2025 · Commit: 89b6883 (single commit, branch chloewqg:local2)
5.1 Files changed
| File | Δ |
|---|---|
CHANGELOG.md | +1 |
src/main/java/org/opensearch/knn/index/query/KNNQueryBuilder.java | +40 |
src/test/java/org/opensearch/knn/index/FaissIT.java | +50 |
src/test/java/org/opensearch/knn/index/query/KNNQueryBuilderTests.java | +55 |
5.2 CHANGELOG.md
One line under ## [Unreleased 3.0] → ### Features. Mandatory in this repo; CI enforces it. Note the merged line is missing its leading * bullet marker — a cosmetic slip that got through review.
5.3 KNNQueryBuilder.filter() — the 40 lines
Inserted right after doXContent, at what was line 381:
/**
* Add a filter to Neural Query Builder // (a)
* @param filterToBeAdded fiXlter to be added // (b)
* @return return itself with underlying filter combined with passed in filter
*/
@Override // (c)
public QueryBuilder filter(QueryBuilder filterToBeAdded) { // (d)
if (validateFilterParams(filterToBeAdded) == false) { // (e)
return this;
}
if (this.filter == null) { // (f)
return KNNQueryBuilder.builder() // (g)
.fieldName(fieldName)
.vector(vector)
.k(k)
.maxDistance(maxDistance)
.minScore(minScore)
.methodParameters(methodParameters)
.filter(filterToBeAdded) // (h)
.ignoreUnmapped(ignoreUnmapped)
.rescoreContext(rescoreContext)
.expandNested(expandNested)
.build(); // (i)
}
return KNNQueryBuilder.builder() // (j)
.fieldName(fieldName)
.vector(vector)
.k(k)
.maxDistance(maxDistance)
.minScore(minScore)
.methodParameters(methodParameters)
.filter(filter.filter(filterToBeAdded)) // (k) ← the whole trick
.ignoreUnmapped(ignoreUnmapped)
.rescoreContext(rescoreContext)
.expandNested(expandNested)
.build();
}
Annotations:
-
(a) Javadoc says "Neural Query Builder" — copy-paste from
NeuralQueryBuilder. bzhangam flagged it in review; chloewqg said a follow-up PR would fix it. It is still wrong inmaintoday. -
(b)
fiXlter— typo, also still present. Two cosmetic defects that survived review and a year ofmain. Instructive about what code review actually catches. -
(c)
@Overrideis what binds this to the new core interface method. Without core PR #17409 merged first, this line is a compile error. This is the hard dependency between the two repos. -
(d) Parameter deliberately named
filterToBeAdded, notfilter, becausefilteris already a field on the class. Avoidsthis.filtershadowing noise in the body. -
(e) The shared null-guard from
AbstractQueryBuilder. Note the== falsestyle — OpenSearch convention, avoids the easily-missed!. Returningthis(notnull, not a copy) makesfilter(null)a true no-op and lets callers likeConstantScoreQueryBuilderuse identity comparison to detect "nothing changed." -
(f) Branch on whether a filter already exists.
-
(g) No existing filter → rebuild the whole builder rather than assigning to the field. See §5.6.
-
(h) The new filter becomes the filter, verbatim. No wrapping. This is what makes it a real push-down:
KNNQueryBuilder.filteris handed toKNNQueryFactoryattoQuery()time and drives efficient filtering. -
(i)
build()runsBuilder.validate()— checks fieldName non-empty, vector non-null/non-empty, exactly one ofk/minScore/maxDistance,0 < k <= K_MAX,minScore > 0. This validation-on-construction is the stated justification for the rebuild. -
(j) Existing filter → same rebuild, one field different.
-
(k) The recursive delegation.
filter.filter(filterToBeAdded)dispatches polymorphically on the existing filter's type:- existing filter is a
BoolQueryBuilder→ appends to itsfilterClauses, returns the same instance. Result:bool { ..., filter: [old..., new] }. Flat, no extra nesting. - existing filter is a
TermQueryBuilder(or any leaf) →AbstractQueryBuilder.filterwraps it:bool { must: term, filter: newFilter }. - existing filter is a
ConstantScoreQueryBuilder→ recurses into its inner query.
Fifteen lines get correct AND-composition for every query type in the system, present and future. That's the payoff of putting the contract in core.
- existing filter is a
What this method does NOT copy: boost and queryName. The Builder has both fields (queryName(String), boost(float), default DEFAULT_BOOST), and build() applies them via .boost(boost).queryName(queryName). filter() sets neither, so a pushed-down filter silently resets boost to 1.0 and drops the _name. Since AbstractQueryBuilder.equals compares both, this is observable. The same omission exists in doRewrite(), which predates this PR — so it's a pre-existing pattern that was copied, not a new mistake. Still a real bug. See §7.
5.4 The unit test
KNNQueryBuilderTests.testFilter(), +55 lines, three cases:
@SneakyThrows
public void testFilter() {
// Case 1 — null is a no-op
KNNQueryBuilder knnQueryBuilder = new KNNQueryBuilder(FIELD_NAME, QUERY_VECTOR, K);
KNNQueryBuilder updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(null);
assertEquals(knnQueryBuilder, updatedKnnQueryBuilder);
// Case 2 — existing filter, AND-combined
knnQueryBuilder = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).filter(TERM_QUERY).k(K).build();
updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(TERM_QUERY);
BoolQueryBuilder expectedUpdatedQueryFilter = new BoolQueryBuilder();
expectedUpdatedQueryFilter.must(TERM_QUERY);
expectedUpdatedQueryFilter.filter(TERM_QUERY);
assertEquals(knnQueryBuilder.fieldName(), updatedKnnQueryBuilder.fieldName());
assertEquals(knnQueryBuilder.vector(), updatedKnnQueryBuilder.vector());
assertEquals(knnQueryBuilder.getK(), updatedKnnQueryBuilder.getK());
assertEquals(knnQueryBuilder.getMaxDistance(), updatedKnnQueryBuilder.getMaxDistance());
assertEquals(knnQueryBuilder.getMinScore(), updatedKnnQueryBuilder.getMinScore());
assertEquals(knnQueryBuilder.getMethodParameters(), updatedKnnQueryBuilder.getMethodParameters());
assertEquals(knnQueryBuilder.isIgnoreUnmapped(), updatedKnnQueryBuilder.isIgnoreUnmapped());
assertEquals(knnQueryBuilder.getRescoreContext(), updatedKnnQueryBuilder.getRescoreContext());
assertEquals(knnQueryBuilder.getExpandNested(), updatedKnnQueryBuilder.getExpandNested());
assertEquals(expectedUpdatedQueryFilter, updatedKnnQueryBuilder.getFilter());
// Case 3 — no existing filter, direct assignment
knnQueryBuilder = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K).build();
updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(TERM_QUERY);
// ... same field-by-field assertions ...
assertEquals(TERM_QUERY, updatedKnnQueryBuilder.getFilter());
}
Reading it:
- Case 1 passes precisely because
filter(null)returnsthis— reference equality would also pass, butassertEqualsdocuments intent. - Case 2 is the important one.
TERM_QUERYis a leaf, sofilter.filter(TERM_QUERY)hitsAbstractQueryBuilder.filterand producesbool { must: TERM_QUERY, filter: TERM_QUERY }. The test asserts that exact structure. It also confirms every other field survives the rebuild — which is the real risk of the rebuild-vs-mutate approach. - Case 3 confirms the
filter == nullbranch assigns without wrapping. - Gap: because the assertions are field-by-field rather than
assertEquals(expectedBuilder, actual), the droppedboost/queryNamenever gets caught. A whole-object comparison on a builder with a non-default boost would fail today. - The
(KNNQueryBuilder)casts are needed because the interface returnsQueryBuilder. Safe here only because the implementation is known to return aKNNQueryBuilder; in general callers must not assume this (the base implementation returns aBoolQueryBuilder). - The PR also left a commented-out copy of the production code inside the test as an explanatory note. Dead comment; would not survive a stricter review.
New import: org.opensearch.index.query.BoolQueryBuilder.
5.5 The integration test
FaissIT.testQueryWithFilterFunctionAppliedMultipleShards(), +50 lines. New imports: MatchNoneQueryBuilder, RangeQueryBuilder.
@SneakyThrows
public void testQueryWithFilterFunctionAppliedMultipleShards() {
XContentBuilder builder = XContentFactory.jsonBuilder()
.startObject()
.startObject(PROPERTIES_FIELD_NAME)
.startObject(FIELD_NAME)
.field(TYPE_FIELD_NAME, KNN_VECTOR_TYPE)
.field(DIMENSION_FIELD_NAME, "3")
.startObject(KNNConstants.KNN_METHOD)
.field(KNNConstants.NAME, METHOD_HNSW)
.field(KNNConstants.METHOD_PARAMETER_SPACE_TYPE, SpaceType.L2.getValue())
.field(KNNConstants.KNN_ENGINE, KNNEngine.FAISS.getName())
.endObject()
.endObject()
.startObject(INTEGER_FIELD_NAME)
.field(TYPE_FIELD_NAME, FILED_TYPE_INTEGER)
.endObject()
.endObject()
.endObject();
String mapping = builder.toString();
createIndex(INDEX_NAME, Settings.builder()
.put("number_of_shards", 10)
.put("number_of_replicas", 0)
.put("index.knn", true).build());
putMappingRequest(INDEX_NAME, mapping);
addKnnDocWithAttributes("doc1", new float[] { 7.0f, 7.0f, 3.0f },
ImmutableMap.of("dateReceived", "2024-10-01"));
refreshIndex(INDEX_NAME);
final float[] searchVector = { 6.0f, 7.0f, 3.0f };
// (1) matching filter → 1 hit
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("dateReceived").gte("2023-11-01");
KNNQueryBuilder knnQueryBuilder = new KNNQueryBuilder(FIELD_NAME, searchVector, 1, null);
KNNQueryBuilder updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(rangeQueryBuilder);
Response response = searchKNNIndex(INDEX_NAME, updatedKnnQueryBuilder, 10);
List<KNNResult> knnResults = parseSearchResponse(EntityUtils.toString(response.getEntity()), FIELD_NAME);
assertEquals(1, knnResults.size());
// (2) impossible filter → 0 hits
updatedKnnQueryBuilder = (KNNQueryBuilder) knnQueryBuilder.filter(new MatchNoneQueryBuilder());
response = searchKNNIndex(INDEX_NAME, updatedKnnQueryBuilder, 10);
knnResults = parseSearchResponse(EntityUtils.toString(response.getEntity()), FIELD_NAME);
assertEquals(0, knnResults.size());
}
Reading it:
- Faiss + HNSW + L2,
index.knn: true— chosen because Faiss is the engine with the richest efficient-filtering path. The test lives inFaissIT, which extendsKNNRestTestCaseand drives a real cluster over REST. - 10 shards, 1 document. Deliberate: the doc lands on one shard, the other nine have empty segments. This exercises the serialize→fan-out→
toQuerypath on shards where the filter matches nothing, catching NPEs and empty-segment bugs. That's why "MultipleShards" is in the name. new KNNQueryBuilder(FIELD_NAME, searchVector, 1, null)— the four-arg constructor with an explicitnullfilter, so the test starts on thefilter == nullbranch.knnQueryBuilder.filter(...)twice on the same object — the second call at (2) reuses the original builder, not the updated one. This only works becausefilter()returns a new instance and leaves the receiver untouched. A mutating implementation would have made the second queryrange AND match_none. The immutability contract is load-bearing here, and this test would catch a regression to mutation.k = 1in the query,size = 10in the search — separates ANNkfrom result size.MatchNoneQueryBuilderis the cleanest way to prove the filter is actually reaching the engine: the doc is a perfect vector match, so a nonzero result would mean the filter was ignored.- Weakness: with one document, this proves the filter is applied, not that it is applied efficiently. It cannot distinguish push-down from post-filtering. A recall test — 10k docs, restrictive filter, assert k results come back — would be the real proof, and isn't here.
5.6 The review debate: mutate or rebuild?
Worth reading in full on the PR; it's the most substantive part.
bzhangam: why construct a new KNNQueryBuilder instead of assigning this.filter and returning this?
heemin32: no behavioural difference today, but validation lives in build(). If a future filter-related validation is added there, direct field assignment bypasses it.
bzhangam: that's speculative — building for a use case that doesn't exist. If the concern is bypass, mark the field final and force mutation through the builder. Two-way door.
heemin32: final on a QueryBuilder reference still permits mutating the referenced object; it only prevents swapping the instance. If we'd adopt the pattern later anyway, adopt it now.
chloewqg closed it by citing a review on k-NN#2585: query builders are intended to be effectively immutable, with all parameters validated together at construction rather than individually.
Rebuild won. And as §5.5 shows, the integration test then depends on that choice. Note the inconsistency though: NeuralQueryBuilder.filter() in the sibling plugin mutates in place and returns this. Both satisfy the interface — the contract only says "return the resulting builder" — but a caller that assumes one style breaks against the other. HybridQueryBuilder handles this correctly with iterator.set(...); hand-written client code often won't.
6. End-to-end request trace
Request:
POST /products/_search?search_pipeline=nlp-pipeline
{
"query": {
"hybrid": {
"queries": [
{ "match": { "title": "running shoes" } },
{ "knn": { "embedding": { "vector": [0.1, 0.2, 0.3], "k": 50 } } }
],
"filter": { "term": { "in_stock": true } }
}
}
}
- REST → parse.
HybridQueryBuilder.fromXContentwalks the JSON.queries→ two builders.filter→ aTermQueryBuilder. - Push-down loop. For each sub-query,
query.filter(termQuery):MatchQueryBuilderhas no override →AbstractQueryBuilder.filter→ returnsbool { must: match, filter: term }.KNNQueryBuilderoverrides →this.filter == null→ returns a newKNNQueryBuilderwithfilter = term.- Both results are added to the new
HybridQueryBuilder. The top-levelfilterfield is not retained — it has been fully distributed.
- Stats.
HYBRID_QUERY_FILTER_REQUESTSincremented. - Rewrite.
KNNQueryBuilder.doRewriterewrites the inner filter; if the rewrite produced a different object it rebuilds the k-NN builder around it. (Same missingboost/queryNamecopy asfilter().) - Serialize + fan out. No wire-format change was needed anywhere in this feature:
KNNQueryBuilderalready serialized itsfilterfield. That's why there's no BWC work in PR #2599. toQueryper shard. InKNNQueryBuilder.doToQuery:- Guard: if the engine creates custom segment files and doesn't support filters (nmslib), and
filter != null→IllegalArgumentException("Engine [x] does not support filters"). KNNQueryFactory.CreateQueryRequest.builder()...filter(this.filter)...build()→KNNQueryFactory.create(...).
- Guard: if the engine creates custom segment files and doesn't support filters (nmslib), and
- Engine execution. The factory converts the filter builder to a Lucene
Query, runs it to get a doc-ID bitset, and picks pre-filter exact search vs filtered ANN based on N/P/k/FT/MDC. Thematchsub-query'sboolruns conventionally. - Normalization + combination. The hybrid search-phase processor normalizes each sub-query's score list and combines them.
- Response.
The key structural fact: the transform happens once, at parse time, on the coordinating node. By the time anything is serialized or executed, the query is indistinguishable from one where the user typed the filters out longhand. That's the RFC's performance argument, and it holds — there's no per-shard or per-segment cost to the feature itself.
7. Semantics, edge cases, and real bugs
Combination is always AND, always. No opt-out. If a sub-query already filters category=books and you push in_stock=true, you get both. If you want OR or per-sub-query behaviour, don't use the common filter — write the filters inline.
Push-down is one level deep. hybrid.filter reaches each sub-query. It does not descend into a sub-query's inner queries — except via the specific overrides (constant_score recursing into its wrapped query, bool appending to its own filter clauses).
boost and queryName are lost. KNNQueryBuilder.filter() and doRewrite() rebuild via KNNQueryBuilder.builder() without .boost(...) or .queryName(...). A knn sub-query with "boost": 2.0 or "_name": "vec" silently loses both when a hybrid filter is pushed into it. AbstractQueryBuilder.equals compares both, so this is observable and testable. Reproduce: build a KNNQueryBuilder with a non-default boost, call filter(termQuery), assert getBoost(). This is a good first-contribution PR if you want one — the fix is two chained calls plus a test.
Filter type matters for the resulting shape.
Existing knn.filter | After pushing F |
|---|---|
null | F |
bool { filter: [A] } | bool { filter: [A, F] } — flat |
term { x: 1 } | bool { must: term, filter: F } — one level of nesting |
constant_score { filter: Q } | constant_score { filter: Q.filter(F) } |
Repeated push-downs on a leaf filter nest linearly. Not a correctness problem — Lucene flattens most of this at rewrite — but deeply nested bools show up in _validate/query?explain output and can be confusing when debugging.
nmslib rejects filters entirely. A hybrid filter pushed into a knn sub-query on an nmslib index throws at toQuery time, per-shard, not at parse time. The error surfaces as a shard failure, not a 400. Faiss and Lucene engines are fine.
Score-based filters are rejected at parse time by design (§3.7) — the pre/post normalization mismatch would produce nonsense.
Nested hybrid is not supported; pushing a filter into a nested hybrid errors.
filter must be an object. "filter": [ {...}, {...} ] is rejected. Combine with bool instead.
Mutation-style vs rebuild-style implementations coexist. NeuralQueryBuilder mutates and returns this; KNNQueryBuilder rebuilds and returns a new object; BoolQueryBuilder mutates; AbstractQueryBuilder returns a different type entirely. Always use the return value. Never call q.filter(f) for side effects.
8. Build it yourself
8.1 Dev environment
The k-NN plugin has native (JNI) dependencies, so first build is slow.
# JDK 21 is what current main targets; check build.gradle for the exact version
java -version
git clone https://github.com/opensearch-project/k-NN.git
cd k-NN
git submodule update --init -- jni/external/nmslib
git submodule update --init -- jni/external/faiss
# full build, including native libs — expect 20-40 min cold
./gradlew build
# start a single-node cluster with the plugin installed
./gradlew run
# → http://localhost:9200
If you only need the Java side compiled (no native libs, no ITs), ./gradlew compileJava compileTestJava is much faster for iterating.
For the hybrid-query half you also need the neural-search plugin, which depends on ml-commons. Easiest path for end-to-end manual testing is a Docker OpenSearch 3.x image, which bundles all three.
8.2 Implementing the change from scratch
Assume you're on a main that predates the feature.
Step 1 — verify the core contract exists.
grep -n "QueryBuilder filter(QueryBuilder" \
~/OpenSearch/server/src/main/java/org/opensearch/index/query/QueryBuilder.java
If it's absent, your k-NN @Override won't compile and you need OpenSearch ≥ 3.0 (or a locally published snapshot) as your dependency. This is the ordering constraint that forced core → k-NN → neural-search.
Step 2 — add the override to src/main/java/org/opensearch/knn/index/query/KNNQueryBuilder.java, after doXContent. Use the code in §5.3 — but fix the javadoc, and add the two lines the original missed:
/**
* Combine a filter into this KNN query builder.
* @param filterToBeAdded filter to be added
* @return a builder whose filter is the AND-combination of the existing filter and the new one
*/
@Override
public QueryBuilder filter(QueryBuilder filterToBeAdded) {
if (validateFilterParams(filterToBeAdded) == false) {
return this;
}
final QueryBuilder combined = (this.filter == null)
? filterToBeAdded
: this.filter.filter(filterToBeAdded);
return KNNQueryBuilder.builder()
.fieldName(fieldName)
.vector(vector)
.k(k)
.maxDistance(maxDistance)
.minScore(minScore)
.methodParameters(methodParameters)
.filter(combined)
.ignoreUnmapped(ignoreUnmapped)
.rescoreContext(rescoreContext)
.expandNested(expandNested)
.boost(boost) // ← not in the merged PR
.queryName(queryName) // ← not in the merged PR
.build();
}
Collapsing the two branches into one combined expression also removes the duplicated 11-line builder chain. Behaviourally identical to the merged version except for the boost/name fix.
Step 3 — imports. org.opensearch.index.query.QueryBuilder is almost certainly already imported. validateFilterParams is inherited from AbstractQueryBuilder, no import needed.
Step 4 — CHANGELOG. One bullet under ### Features in the unreleased section. CI fails without it.
Step 5 — spotless. ./gradlew spotlessApply before committing; the repo enforces formatting.
Step 6 — sign your commits. DCO is enforced: git commit --signoff.
8.3 The same feature for a query type that doesn't have it
If you're adding filter() to some other query builder, the checklist is:
- Does the builder have a field that already means "filter" (or an inner query you should recurse into)? If not, don't override — the
AbstractQueryBuilderbool-wrap is the right answer and you get it for free. - If it does, decide mutate-vs-rebuild and be consistent with the rest of your plugin.
- Start with
if (validateFilterParams(f) == false) return this;— the identity return is relied on byConstantScoreQueryBuilder's!=check. - Combine an existing filter via
existing.filter(f), never by hand-rolling aBoolQueryBuilder. Delegation is what makes the feature compose. - If you rebuild, copy every field including
boostandqueryName. - Test: null case, no-existing-filter case, existing-filter case, and a whole-object
assertEqualsagainst an expected builder with non-default boost/name.
9. Testing
9.1 Unit tests
# just this test
./gradlew test --tests "org.opensearch.knn.index.query.KNNQueryBuilderTests.testFilter"
# whole class
./gradlew test --tests "*KNNQueryBuilderTests"
Cases to cover, beyond what the PR shipped:
public void testFilter_whenBoostAndNameSet_thenPreserved() {
KNNQueryBuilder qb = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K)
.boost(2.0f).queryName("vec").build();
KNNQueryBuilder updated = (KNNQueryBuilder) qb.filter(TERM_QUERY);
assertEquals(2.0f, updated.boost(), 0.0f); // fails on merged code
assertEquals("vec", updated.queryName()); // fails on merged code
}
public void testFilter_whenExistingFilterIsBool_thenFlatCombination() {
BoolQueryBuilder existing = new BoolQueryBuilder().filter(TERM_QUERY);
KNNQueryBuilder qb = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K).filter(existing).build();
KNNQueryBuilder updated = (KNNQueryBuilder) qb.filter(RANGE_QUERY);
BoolQueryBuilder actual = (BoolQueryBuilder) updated.getFilter();
assertEquals(2, actual.filter().size()); // flat, not nested
}
public void testFilter_whenCalledTwiceOnSameBuilder_thenOriginalUnchanged() {
KNNQueryBuilder qb = KNNQueryBuilder.builder()
.fieldName(FIELD_NAME).vector(QUERY_VECTOR).k(K).build();
qb.filter(TERM_QUERY);
assertNull(qb.getFilter()); // immutability contract
}
9.2 Integration tests
# single IT
./gradlew integTest --tests "org.opensearch.knn.index.FaissIT.testQueryWithFilterFunctionAppliedMultipleShards"
# whole IT class
./gradlew integTest --tests "*FaissIT"
ITs need the native libs built, so run a full ./gradlew build at least once first.
Worth adding — a recall test that actually proves push-down rather than mere application:
// index 10_000 docs; only ~50 have category="rare"
// query with k=10 and filter category=rare
// assert 10 results, NOT ~0
// post-filtering would return close to zero; efficient filtering returns 10
That is the assertion that distinguishes this feature from the naive implementation, and it isn't in the repo.
9.3 Manual REST testing on a live cluster
# 1. index with faiss/hnsw
curl -XPUT "localhost:9200/products" -H 'Content-Type: application/json' -d'
{
"settings": { "index": { "knn": true, "number_of_shards": 2, "number_of_replicas": 0 } },
"mappings": { "properties": {
"embedding": { "type": "knn_vector", "dimension": 3,
"method": { "name": "hnsw", "space_type": "l2", "engine": "faiss" } },
"title": { "type": "text" },
"in_stock": { "type": "boolean" }
} }
}'
# 2. docs
curl -XPOST "localhost:9200/products/_bulk?refresh" -H 'Content-Type: application/x-ndjson' -d'
{"index":{"_id":"1"}}
{"embedding":[1.0,1.0,1.0],"title":"running shoes","in_stock":true}
{"index":{"_id":"2"}}
{"embedding":[1.1,1.0,1.0],"title":"running socks","in_stock":false}
{"index":{"_id":"3"}}
{"embedding":[9.0,9.0,9.0],"title":"hiking boots","in_stock":true}
'
# 3. plain knn with an inline filter — the pre-existing capability
curl -XPOST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d'
{ "query": { "knn": { "embedding": {
"vector": [1.0,1.0,1.0], "k": 2,
"filter": { "term": { "in_stock": true } } } } } }'
# expect docs 1 and 3 (doc 2 is nearer than 3 but out of stock)
# 4. search pipeline for hybrid
curl -XPUT "localhost:9200/_search/pipeline/nlp-pipeline" -H 'Content-Type: application/json' -d'
{
"description": "hybrid normalization",
"phase_results_processors": [
{ "normalization-processor": {
"normalization": { "technique": "min_max" },
"combination": { "technique": "arithmetic_mean", "parameters": { "weights": [0.3, 0.7] } } } }
]
}'
# 5. THE FEATURE — one top-level filter, both sub-queries
curl -XPOST "localhost:9200/products/_search?search_pipeline=nlp-pipeline" \
-H 'Content-Type: application/json' -d'
{
"query": {
"hybrid": {
"queries": [
{ "match": { "title": "running" } },
{ "knn": { "embedding": { "vector": [1.0,1.0,1.0], "k": 2 } } }
],
"filter": { "term": { "in_stock": true } }
}
}
}'
# doc 2 ("running socks") matches both sub-queries but is out of stock → excluded
9.4 Verifying push-down actually happened
Three ways, in increasing order of reliability:
1. _validate/query?explain — shows the Lucene query the builders produced.
curl -XPOST "localhost:9200/products/_validate/query?explain=true" \
-H 'Content-Type: application/json' -d'
{ "query": { "hybrid": { "queries": [ { "match": { "title": "running" } } ],
"filter": { "term": { "in_stock": true } } } } }'
You should see the match clause wrapped in a boolean with a filter clause — proof the push-down ran at parse time.
2. Profile the query with "profile": true and look for the k-NN query implementation in the breakdown. A KnnFloatVectorQuery/Faiss query carrying a filter looks different from a BooleanQuery containing an unfiltered k-NN clause.
3. The recall test. Index enough documents that a restrictive filter would starve post-filtering, then count results. If you get k back, the filter went in. If you get near zero, it didn't. This is the only test that can't be fooled.
10. Limitations and follow-ups
Still open or unresolved as of the current main:
- Javadoc typos in
KNNQueryBuilder.filter()— "Neural Query Builder" and "fiXlter". Flagged in review, promised as a follow-up, never fixed. boost/queryNamedropped on rebuild, in bothfilter()anddoRewrite().- No
FilterCombinationMode. AND only. OR and IGNORE_IF_EXISTS remain speculative. - No
SearchRequestProcessorvariant (RFC high-level Option 2), so you can't set an index-default common filter via a default search pipeline. - No deep push-down into a sub-query's inner queries.
- No performance/recall benchmark in the repo demonstrating the push-down advantage — the ITs prove correctness, not efficiency.
- Two conflicting conventions for
filter()across plugins (mutate vs rebuild), with nothing in the interface javadoc that forces one. - Duplicated logic between
NeuralQueryBuilderandKNNQueryBuilder, accepted deliberately.
11. Reference index
Pull requests and issues
| Ref | Repo | What |
|---|---|---|
| #1135 | neural-search | The RFC |
| #17409 | OpenSearch | filter() on QueryBuilder, AbstractQueryBuilder, BoolQueryBuilder, ConstantScoreQueryBuilder |
| #2599 | k-NN | filter() on KNNQueryBuilder — this document's subject |
| #1206 | neural-search | filter() on NeuralQueryBuilder and HybridQueryBuilder |
| #2585 | k-NN | The immutability review that settled the rebuild-vs-mutate debate |
| #1108 | neural-search | Validation for nested hybrid queries |
| #903 | k-NN | META: efficient filtering for Faiss |
| #1049 | k-NN | Restrictive-filter enhancements, exact-search fallback |
Source files
| File | Repo |
|---|---|
server/src/main/java/org/opensearch/index/query/QueryBuilder.java | OpenSearch |
server/src/main/java/org/opensearch/index/query/AbstractQueryBuilder.java | OpenSearch |
server/src/main/java/org/opensearch/index/query/BoolQueryBuilder.java | OpenSearch |
server/src/main/java/org/opensearch/index/query/ConstantScoreQueryBuilder.java | OpenSearch |
src/main/java/org/opensearch/knn/index/query/KNNQueryBuilder.java | k-NN |
src/main/java/org/opensearch/knn/index/query/KNNQueryFactory.java | k-NN |
src/main/java/org/opensearch/neuralsearch/query/HybridQueryBuilder.java | neural-search |
src/main/java/org/opensearch/neuralsearch/query/NeuralQueryBuilder.java | neural-search |
Docs
- Filtering vector search results
- Efficient k-NN filtering
- Hybrid search
- Efficient filtering in the OpenSearch vector engine (blog)
Source: kafka ·
kafka.md· updated 2026-08-07 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Apache Kafka Internals — A Maintainer's Deep Dive
Source tree: github.com/apache/kafka @ 930ebc5608bb0ac938085321d09b402b850ca87b
Version: 4.4.0-SNAPSHOT · Scala 2.13.18 · LATEST_PRODUCTION = IBP_4_3_IV0
Fetched: 2026-08-06
Every file path, class name, and line-level behaviour below was read out of that tree. Where a design decision has a rationale that only exists in a code comment, the comment is the citation — Kafka's best documentation is in-source and most of it never made it into the website docs.
Table of Contents
- How to read this codebase
- Module topology
- The log: on-disk format
- The write path, end to end
- 4.1 Acceptor / Processor / RequestChannel
- 4.2 Request handler pool and callback re-entry
- 4.3 ReplicaManager → Partition → UnifiedLog
- 4.4 Anatomy of
UnifiedLog.append - 4.5 LogValidator: offset assignment and recompression
- 4.6 Segment roll
- 4.7 The fsync question
- 4.8 Idempotence: ProducerStateManager
- 4.9 acks=all and the purgatory
- The read path
- Hierarchical timing wheels and the purgatory
- Replication
- KRaft
- Coordinators
- Log compaction
- Producer client internals
- Consumer client internals
- Quotas, throttling, and backpressure
- Comparison with other streaming systems
- Design lessons: building your own
- Maintainer's appendix
1. How to read this codebase
A few navigational facts that will save you a week.
- Scala is nearly gone from the hot paths. The log layer, the indexes, the compactor, the
coordinators, the Raft implementation, the purgatory, and the timing wheel are all Java now. What
remains in Scala is essentially:
KafkaApis,ReplicaManager,Partition,SocketServer, the fetcher threads,KafkaConfig, and the transaction coordinator. The migration is mechanical and ongoing; don't assume a class is where a 2021 blog post says it is. - The module boundary is a dependency boundary, enforced.
clientscannot depend onserver.storagecannot depend oncore. This is why you see interfaces likeRemoteStorageManagerandAlterPartitionManagersitting in low modules with the implementation incore. When you add a class, the module you pick determines what you're allowed to call. internalspackages are the real API.org.apache.kafka.storage.internals.logis where the log lives.org.apache.kafka.common.record.internalis where the record format lives (it moved underinternalrecently — a lot of stale documentation points atorg.apache.kafka.common.record.DefaultRecordBatch, which no longer exists).- Comments are load-bearing.
AbstractIndexhas a 40-line comment explaining a page-cache argument that is the entire reason the class is not a plain binary search.DelayedOperationPurgatoryhas a comment enumerating a specific 7-step deadlock. These are not decoration; they encode post-mortems. - The generator module is real code.
generator/compiles the JSON schemas inclients/src/main/resources/common/message/*.jsoninto request/response classes. If you're adding an RPC field, you edit JSON, not Java../gradlew processMessagesregenerates.
2. Module topology
clients/ protocol, record format, producer, consumer, admin, serde, network
(the only artifact most users depend on)
raft/ KafkaRaftClient — the KRaft consensus implementation
metadata/ controller (QuorumController), metadata records, image/delta, loader
server-common/ purgatory, timing wheel, MetadataVersion, shared utils
server/ broker-side pieces that don't need core's Scala: quotas, fetch sessions
storage/ UnifiedLog, LogSegment, indexes, compaction, tiered storage
coordinator-common/ the generic coordinator runtime (event loop + replicated state machine)
group-coordinator/ consumer groups, share groups, streams groups
share-coordinator/ durable share-group acknowledgement state
transaction-coordinator/ transaction metadata types (state machine still partly Scala in core)
core/ KafkaApis, ReplicaManager, Partition, SocketServer, BrokerServer,
ControllerServer, fetcher threads
streams/ Kafka Streams
connect/ Kafka Connect
tools/, shell/, trogdor/ CLI, metadata shell, fault injection harness
jmh-benchmarks/ microbenchmarks — read these, they document the hot paths
The dependency direction is roughly clients ← server-common ← {raft, storage, server} ← {metadata, coordinator-common} ← core. core is the only module that can see everything, which is
why it's the residual Scala pile.
3. The log: on-disk format
3.1 Directory and file layout
A log.dirs entry contains one directory per partition, named <topic>-<partition>:
/var/lib/kafka/data/
├── meta.properties # cluster id, node id, directory id
├── orders-0/
│ ├── partition.metadata # topic id (written before any data — see below)
│ ├── leader-epoch-checkpoint # (epoch, startOffset) pairs
│ ├── 00000000000000000000.log # segment: the actual records
│ ├── 00000000000000000000.index # offset index, mmapped, sparse
│ ├── 00000000000000000000.timeindex
│ ├── 00000000000000000000.snapshot # producer state snapshot at this base offset
│ ├── 00000000000000368142.log
│ ├── 00000000000000368142.index
│ ├── 00000000000000368142.timeindex
│ ├── 00000000000000368142.txnindex # only if aborted txns exist in this segment
│ └── 00000000000000368142.snapshot
└── __cluster_metadata-0/ # the KRaft log, on controllers and brokers alike
├── 00000000000000000000.log
└── ...-0000000000-0000000000.checkpoint # KRaft snapshots
The filename is the base offset, zero-padded to 20 digits, which makes lexical sort equal numeric
sort. Everything is derivable from that number, which is why LogSegments can be a
ConcurrentNavigableMap<Long, LogSegment> and segment lookup is a floorEntry.
One subtlety that costs people data: UnifiedLog.append opens with
// We want to ensure the partition metadata file is written to the log dir before any log data is written to disk.
// This will ensure that any log data can be recovered with the correct topic ID in the case of failure.
maybeFlushMetadataFile();
partition.metadata must hit disk before the first record. Without it, a crash leaves a directory
of records that can't be attributed to a topic ID, and topic IDs are how KRaft distinguishes a
recreated topic from the original.
Transient files you will see and should recognize:
| Suffix | Meaning |
|---|---|
.deleted | segment logically deleted, awaiting file.delete.delay.ms |
.cleaned | compaction output in progress; deleted on recovery |
.swap | compaction output complete, mid-rename; completed on recovery |
.tmp | index being rebuilt |
.cleaned vs .swap is the crash-consistency protocol for compaction: the presence of .swap
means the new segment is fully written and the rename is idempotent to redo.
3.2 RecordBatch v2, byte by byte
From clients/src/main/java/org/apache/kafka/common/record/internal/DefaultRecordBatch.java:
Offset Size Field
------ ---- -----------------------------------------------------
0 8 BaseOffset int64
8 4 Length int32 (bytes after this field)
12 4 PartitionLeaderEpoch int32 ← NOT covered by CRC
16 1 Magic int8 (= 2)
17 4 CRC uint32 CRC-32C over bytes 21..end
21 2 Attributes int16
23 4 LastOffsetDelta int32 (also = LastSequenceDelta)
27 8 BaseTimestamp int64
35 8 MaxTimestamp int64
43 8 ProducerId int64
51 2 ProducerEpoch int16
53 4 BaseSequence int32
57 4 RecordsCount int32
61 - Records [Record] (compressed as one blob if enabled)
RECORD_BATCH_OVERHEAD = 61 bytes. LOG_OVERHEAD = 12 (BaseOffset + Length) — that's the framing
prefix that lets you skip a batch without parsing it.
Attributes bitfield:
bit 0-2 compression type (0 none, 1 gzip, 2 snappy, 3 lz4, 4 zstd)
bit 3 timestamp type (0 CreateTime, 1 LogAppendTime)
bit 4 transactional
bit 5 control batch (contains markers, not user data)
bit 6 delete horizon set (compaction tombstone retention)
bit 7-15 unused
Three properties of this layout matter enormously and are worth stealing:
- Compression is per-batch, not per-record. The compressed blob starts right after
RecordsCount. This means the broker can route, index, and replicate a batch without decompressing it. Systems that compress per-message (or per-request-with-per-message framing) give up an order of magnitude on wire efficiency for high-cardinality small records. - Offsets and timestamps are deltas. A record stores
OffsetDeltaandTimestampDeltaas varints relative to the batch header. A batch of 10,000 sequential records with clock-adjacent timestamps costs ~1–2 bytes per record for both fields combined. - The batch is the unit of everything: the unit of compression, of CRC, of idempotence
(
BaseSequence/LastOffsetDelta), of transactional membership, and of replication. The entire system's constant factors come from having chosen the batch as the atom.
3.3 The inner Record
From DefaultRecord.java:
Record =>
Length Varint
Attributes Int8 (currently all bits unused)
TimestampDelta Varlong
OffsetDelta Varint
KeyLength Varint (-1 = null)
Key Bytes
ValueLength Varint (-1 = null)
Value Bytes
HeadersCount Varint
Headers [HeaderKeyLength Varint, HeaderKey String,
HeaderValueLength Varint, HeaderValue Bytes]
MAX_RECORD_OVERHEAD = 21 — "5 bytes length + 10 bytes timestamp + 5 bytes offset + 1 byte
attributes", the worst case. In practice a record in a dense batch costs 6–8 bytes of framing.
Varints are ZigZag-encoded (Protobuf style), in ByteUtils. sizeOfVarint(-1) is precomputed as
NULL_VARINT_SIZE_BYTES because null keys/values are extremely common (tombstones) and the size
estimate is on the producer hot path.
A null value is a tombstone — semantically meaningful only in compacted topics, where it instructs the cleaner to remove the key. This is the one place Kafka's storage layer knows anything about record semantics.
3.4 Why the CRC is where it is
This is the single most interesting layout decision in the format, and the comment explains it:
The CRC covers the data from the attributes to the end of the batch [...] It is located after the magic byte, which means that clients must parse the magic byte before deciding how to interpret the bytes between the batch length and the magic byte. The partition leader epoch field is not included in the CRC computation to avoid the need to recompute the CRC when this field is assigned for every batch that is received by the broker.
Read that again. PartitionLeaderEpoch is written by the broker into a batch the producer
CRC'd. If it were inside the CRC, every single produced batch would require a full CRC recomputation
on the broker — on the hottest path in the system, over the full batch payload. By placing the field
outside the checksummed range, the broker does a 4-byte in-place putInt and moves on.
The cost is that the magic byte sits after a field that isn't checksummed, so parsers must read magic first, then decide how to interpret preceding bytes. That's a genuinely awkward format. They took the awkwardness to save the CPU.
CRC-32C (Castagnoli) is used, not CRC-32, because it has a hardware instruction (SSE4.2 CRC32,
ARMv8 CRC32C) and the JDK intrinsifies it. On a modern core that's ~1 byte/cycle vs. ~1 byte/8
cycles for a table-driven CRC-32.
Steal this: if you're designing a replicated log format, decide up front which fields are client-authored and which are server-authored, and put a checksum boundary between them.
3.5 The offset index
OffsetIndex is an mmapped array of 8-byte entries: (relativeOffset: int32, physicalPosition: int32).
Relative to the segment base offset — which is why a segment can only span Integer.MAX_VALUE
offsets, and why AbstractIndex.toRelative returns OptionalInt.empty() on overflow, and why
LogSegmentOffsetOverflowException exists.
It is sparse: an entry is appended only every log.index.interval.bytes (default 4096) of log
written. So a lookup is: binary search the index for the largest entry ≤ target, then linear scan
the log from that physical position. FileRecords.searchForOffsetFromPosition does the scan, and
note how carefully it avoids lastOffset():
// The following logic is intentionally designed to minimize memory usage by avoiding
// unnecessary calls to lastOffset() for every batch.
// Instead, we use baseOffset() comparisons when possible, and only check lastOffset() when absolutely necessary.
baseOffset() is a field read from the batch header. lastOffset() is baseOffset + lastOffsetDelta
— also a header read, but on a FileChannelRecordBatch it may force a header materialization. The
loop is written to touch as few batch headers as possible.
The whole index for a 1 GB segment at default settings is 1 GB / 4 KB × 8 B = 2 MB. That's the entire index cost of Kafka's storage engine. Compare to an LSM tree's block index plus bloom filters plus manifest.
3.6 The warm-section binary search
This is the best-documented micro-optimization in the codebase and it's worth reproducing the argument, because it generalizes to any mmapped append-only index.
The setup: index files are mmapped, so reads go through the page cache. Appends always land at the end. Lookups (from in-sync followers and caught-up consumers) are also almost always near the end. So the pages near the end are hot and the rest are cold.
Now consider a standard binary search on a 13-page index, looking for something in page 12:
page number: |0|1|2|3|4|5|6|7|8|9|10|11|12 |
steps: |1| | | | | |3| | |4| |5 |2/6|
Pages 0, 6, 9, 11, 12 get touched every lookup, so they stay warm. Fine. Now the index grows to 14 pages:
page number: |0|1|2|3|4|5|6|7|8|9|10|11|12|13 |
steps: |1| | | | | | |3| | | 4|5 | 6|2/7|
The probe set shifts to 0, 7, 10, 12, 13. Pages 7 and 10 have not been touched in a long time. The very next lookup after the index crosses a page boundary takes two cold page faults.
In our test, this can cause the at-least-once produce latency to jump to about 1 second from a few ms.
A one-second p99 spike, periodic, caused by binary search probe-set drift. The fix:
int firstHotEntry = Math.max(0, entries - 1 - warmEntries());
if (compareIndexEntry(parseEntry(idx, firstHotEntry), target, searchEntity) < 0) {
return binarySearch(idx, target, searchEntity, searchResultType, firstHotEntry, entries - 1);
}
// ... else search [0, firstHotEntry]
warmEntries() = 8192 / entrySize() — i.e., the last 8 KB of the index. Two pages on a 4 KB-page
machine. The comment justifies the constant on both sides:
- Not smaller: 8 KB of offset index ≈ 4 MB of log, so with default settings essentially all in-sync lookups land in the warm section.
- Not larger: a warm-section lookup touches exactly three entries —
end,end-N, and(end*2-N)/2. With N = 8192 bytes and a ≥4 KB page, those three probes touch all ≤3 pages of the warm section on every lookup, so the entire warm section stays genuinely warm. Make N bigger and you'd have warm-section pages that aren't touched every time, which defeats the point.
The TODO at the end is honest about the remaining hole: low-QPS partitions have cold warm sections too, and the real fix would be a background thread that touches them.
Generalizable rule: when your data structure lives in the page cache and has a skewed access
pattern, the algorithm's probe set stability matters as much as its asymptotic complexity. A
O(log n) search whose probe set drifts is worse than an O(log n) search whose probe set is pinned.
3.7 Time index, txn index, epoch cache, producer snapshots
.timeindex — 12-byte entries (timestamp: int64, relativeOffset: int32), monotonically
increasing in both. Backs ListOffsets by timestamp and time-based retention. Same warm-section
search (warmEntries() = 8192/12 ≈ 682 entries).
.txnindex — TransactionIndex, a list of AbortedTxn records (producerId, firstOffset, lastOffset, lastStableOffset). Written on segment.updateTxnIndex(completedTxn, lastStableOffset)
when an abort marker is appended. On a READ_COMMITTED fetch, the broker returns this list alongside
the records and the consumer does the filtering. The broker never decompresses or filters user
data for transactions — a deliberate choice to keep the fetch path zero-copy.
leader-epoch-checkpoint — a plain-text list of (epoch, startOffset). Maintained by
LeaderEpochFileCache. This is the substrate of log reconciliation (§7.4). Note in UnifiedLog.append:
validRecords.batches().forEach(batch -> {
if (batch.magic() >= RecordBatch.MAGIC_VALUE_V2) {
assignEpochStartOffset(batch.partitionLeaderEpoch(), batch.baseOffset());
} else {
// In partial upgrade scenarios, we may get a temporary regression to the message format. In
// order to ensure the safety of leader election, we clear the epoch cache so that we revert
// to truncation by high watermark after the next leader election.
if (leaderEpochCache.nonEmpty()) {
logger.warn("Clearing leader epoch cache after unexpected append with message format v{}", batch.magic());
leaderEpochCache.clearAndFlush();
}
}
});
The fallback path is the pre-KIP-101 "truncate to HW" behaviour, which is known to be able to lose data. It's preserved as a correctness-degrading-but-not-crashing escape hatch for mixed-format logs.
.snapshot — serialized ProducerStateManager state as of the segment base offset. On
recovery, LogLoader finds the newest snapshot ≤ recovery point and replays forward, rather than
scanning the whole log to rebuild producer sequence state. Retained for
producer.id.expiration.ms worth of segments.
4. The write path, end to end
Let's trace a single ProduceRequest from the socket to fsync.
4.1 Acceptor / Processor / RequestChannel
core/src/main/scala/kafka/network/SocketServer.scala.
The threading model is a two-stage reactor:
[Acceptor thread] (1 per listener)
ServerSocketChannel.accept()
→ round-robin assign to a Processor
→ processor.newConnections.offer(socketChannel) [ArrayBlockingQueue, size 20]
[Processor threads] (num.network.threads per listener, default 3)
loop:
configureNewConnections() // drain newConnections, register with selector
processNewResponses() // pull from responseQueue, register writes
poll() // selector.poll(timeout)
processCompletedReceives() // → requestChannel.sendRequest(req)
processCompletedSends()
processDisconnected()
closeExcessConnections()
[RequestChannel]
requestQueue: ArrayBlockingQueue[BaseRequest](queued.max.requests, default 500)
[KafkaRequestHandler threads] (num.io.threads, default 8)
loop: requestChannel.receiveRequest() → KafkaApis.handle(request)
The interesting details:
Acceptor backpressure. newConnections is bounded at 20 per processor. If a processor is
saturated, the acceptor blocks on newConnections.put() — which stops accepting. That's deliberate:
better to leave connections in the kernel backlog than to accept them into a queue you can't serve.
Poll timeout. val pollTimeout = if (newConnections.isEmpty) 300 else 0 — if there are
connections waiting to be configured, don't block in the selector at all.
Exception discipline. The processor loop catches Throwable at the top and keeps going:
We catch all the throwables here to prevent the processor thread from exiting. We do this because letting a processor exit might cause a bigger impact on the broker.
Losing a network thread silently degrades a third of your connection capacity with no crash. This is the right call for a broker, and the wrong call for most other software; know which one you're writing.
Channel muting. A channel is muted (removed from the read interest set) while its request is in
flight. That's how Kafka enforces at most one in-flight request per connection server-side, which
in turn is what makes max.in.flight.requests.per.connection=5 safe for idempotent producers —
the broker processes a connection's requests strictly in order. handleChannelMuteEvent /
tryUnmuteChannel implement it, and throttling piggybacks on the same mechanism (§13).
Memory pool. Selector holds a MemoryPool (queued.max.request.bytes). When
memoryPool.availableMemory() < lowMemThreshold (10% of pool), the selector stops reading from
all channels:
this.lowMemThreshold = (long) (0.1 * this.memoryPool.size());
...
if (!outOfMemory && memoryPool.availableMemory() < lowMemThreshold) { ... }
This is a global backpressure valve that pushes flow control down into TCP. Under sustained
overload, receive windows close and producers block in send() rather than the broker OOMing. If
you're building a broker, build this on day one — it is far harder to retrofit.
4.2 Request handler pool and callback re-entry
RequestChannel actually has two queues:
private val requestQueue = new ArrayBlockingQueue[BaseRequest](queueSize)
private val callbackQueue = new ArrayBlockingQueue[BaseRequest](queueSize)
...
def receiveRequest(timeout: Long): BaseRequest = {
val callbackRequest = callbackQueue.poll()
if (callbackRequest != null) callbackRequest
else {
val request = requestQueue.poll(timeout, TimeUnit.MILLISECONDS)
request match {
case _: WakeupRequest => callbackQueue.poll()
case _ => request
}
}
}
The callback queue has strict priority. Its purpose: an operation that went async (a purgatory
completion, a coordinator write, a remote-storage read) needs to finish on an I/O thread, and it
must not queue behind 500 new produce requests. The WakeupRequest sentinel is pushed into
requestQueue when a callback is enqueued, purely to unblock a handler that's parked in
requestQueue.poll(timeout).
This is a nice pattern: a priority lane plus a wakeup token, rather than a priority queue (which would put a comparator on the hot path) or a separate thread pool (which would double the context switches).
4.3 ReplicaManager → Partition → UnifiedLog
KafkaApis.handleProduceRequest → ReplicaManager.appendRecords →
ReplicaManager.appendToLocalLog → Partition.appendRecordsToLeader → UnifiedLog.appendAsLeader.
Partition.appendRecordsToLeader is where the min.insync.replicas check happens, before the
append:
val minIsr = leaderLog.config.minInSyncReplicas.min(remoteReplicasMap.size + 1)
Note the .min(replicas + 1) — if you set min.insync.replicas=3 on a topic with RF=2, it's
clamped rather than making the partition permanently unwritable. This clamp is newer than most
people's mental model.
Partition holds the ISR machinery. Its partitionState field is a state machine:
CommittedPartitionState — ISR is what the controller believes
PendingExpandIsr — we've sent AlterPartition to add a replica, awaiting response
PendingShrinkIsr — we've sent AlterPartition to remove replicas, awaiting response
OngoingReassignmentState — adding/removing replicas mid-reassignment
The pending states are why maximalIsr exists (§7.3).
4.4 Anatomy of UnifiedLog.append
storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java:1115. The sequence,
with the reasoning:
maybeFlushMetadataFile(); // topic id durable first
LogAppendInfo appendInfo = analyzeAndValidateRecords(...); // CRC, sizes, offset monotonicity
if (appendInfo.validBytes() <= 0) return appendInfo; // nothing to do
MemoryRecords trimmedRecords = trimInvalidBytes(records, appendInfo); // drop partial trailing batch
synchronized (lock) { // ← the per-partition write lock
// 1. offset assignment + validation + possible recompression
// 2. leader epoch cache update
// 3. maybeRoll
// 4. producer state analysis (idempotence / txn)
// 5. localLog.append → FileRecords.append + index maybe-append
// 6. updateHighWatermarkWithLogEndOffset
// 7. txn index update, LSO advance
// 8. flush if unflushedMessages >= flushInterval
}
Things worth calling out:
One lock per partition, and it covers real I/O. localLog.append writes to a FileChannel while
holding the lock. It doesn't fsync (usually), so this is a page-cache write, but it is a syscall.
Partition count is therefore your write concurrency — a topic with one partition has one writer,
period. This is the fundamental reason Kafka's scaling unit is the partition and not the topic, and
it's a property you inherit if you copy the design.
Trimming is silent. trimInvalidBytes drops a trailing partial batch without error. That's what
makes it safe for a producer to send a truncated buffer, and it's also why a validBytes() <= 0
result returns a successful-looking LogAppendInfo with no offsets.
Two size checks, not one. There's a per-batch maxMessageSize check, and separately:
if (validRecords.sizeInBytes() > config().segmentSize()) {
throw new RecordBatchTooLargeException(...);
}
You cannot append more than one segment's worth in a single call, because a segment must be able to hold at least one complete append.
Re-validation after recompression. If messageSizeMaybeChanged(), sizes are checked again — a
broker recompressing from lz4 to zstd can change the batch size, and the check uses the original
size for bytesRejectedRate to keep the metric comparable across the change.
Duplicate detection short-circuits the write entirely. If analyzeAndValidateProducerState
returns a maybeDuplicate, the append info is filled in from the original batch's metadata and
nothing is written. The producer gets back the offsets it got the first time. This is the whole of
idempotent produce, and it's ~10 lines.
Ordering of LEO vs. txn index. The comment is explicit:
Append the records, and increment the local log end offset immediately after the append because a write to the transaction index below may fail, and we want to ensure that the offsets of future appends still grow monotonically. The resulting transaction index inconsistency will be cleaned up after the log directory is recovered.
They chose "monotonic offsets always, txn index may need repair" over "both or neither". Correct choice: offset monotonicity is an invariant every other subsystem depends on; the txn index is rebuildable.
4.5 LogValidator: offset assignment and recompression
LogValidator.validateMessagesAndAssignOffsets has three modes:
assignOffsetsNonCompressed— the fast path. Iterate batches, stampbaseOffset,lastOffsetDelta,partitionLeaderEpoch, timestamps, in place in theByteBuffer. No copy, no CRC recompute (remember: leader epoch is outside the CRC).validateMessagesAndAssignOffsetsCompressed— when source and target compression match and nothing forces a rewrite, it can still do in-place header stamping: it validates the inner records by decompressing into aBufferSupplier-provided buffer, then patches only the header.buildRecordsAndAssignOffsets— the slow path. Full decompress → re-validate → recompress. Triggered by a compression type change (compression.typediffers from producer's), a magic downgrade, orLogAppendTimewith compressed input requiring timestamp rewrite.
BrokerCompressionType.targetCompression(config().compression, appendInfo.sourceCompression())
decides. compression.type=producer (the default) is what keeps you on paths 1–2. Setting a
broker or topic compression.type that differs from what producers send silently puts every produce
request on the recompression path. This is the single most common self-inflicted broker CPU
problem in production.
The RequestLocal.bufferSupplier() threaded through here is a per-request-handler-thread buffer
cache — decompression buffers are recycled per thread rather than allocated per batch. That's why
append takes a RequestLocal at all.
4.6 Segment roll
maybeRoll(messagesSize, appendInfo) → LocalLog.roll. Roll triggers:
| Condition | Config |
|---|---|
segment.size + messagesSize > segmentSize | segment.bytes (1 GB) |
segment non-empty and now - segment.created > segmentMs | segment.ms (7d) |
| offset index full | segment.index.bytes (10 MB) |
| time index full | same |
maxOffsetInMessages - baseOffset > Integer.MAX_VALUE | (relative offset overflow) |
On roll: the old segment's indexes are trimmed to their actual size (resize() on the mmap — this
is where AbstractIndex.resize and its remap write-lock earn their keep), a producer state snapshot
is taken, and the new segment's files are created. Index trimming matters: an untrimmed index file
is segment.index.bytes (10 MB) regardless of how full it is, so a broker with 50,000 segments
would waste 500 GB of sparse-file page cache without it.
Roll is not free — it's file creation, mmap, mmap trim, and a producer snapshot, all under the
partition lock. Very small segment.ms on a high-partition-count broker is a known way to make
p99 produce latency ugly.
4.7 The fsync question
if (localLog.unflushedMessages() >= config().flushInterval) flush(false);
config().flushInterval is flush.messages, and its default is Long.MAX_VALUE. There is also
flush.ms, defaulting to Long.MAX_VALUE.
Kafka does not fsync on the produce path. By default it never explicitly fsyncs a data segment at
all except on roll and on clean shutdown. Durability comes from replication, not from the disk.
acks=all means "in the page cache of min.insync.replicas machines", not "on the platters of any
machine".
The reasoning, which I think is correct and which people still argue about:
- An fsync per produce request caps you at the device's sync IOPS. On NVMe that's survivable; on anything with a battery-backed cache it's fine; on EBS gp3 it is not.
- Correlated failure (a rack, an AZ, a bad kernel) defeats fsync anyway, and uncorrelated failure is exactly what replication handles.
- The page cache is a better cache than anything Kafka could build in the JVM: it's shared with
the read path, it's not subject to GC, it survives broker restart, and
sendfilecan serve directly out of it.
The tail risk is real and named: a simultaneous power loss to min.insync.replicas machines loses
acknowledged writes. If your deployment has correlated power domains, set flush.messages=1 and
accept the IOPS bill, or put the replicas in different failure domains. There is no third option and
the docs should say so more loudly.
Contrast with the KRaft metadata log, which does fsync — KafkaRaftLog flushes before responding
to the leader, because Raft's safety proof requires durable votes and durable log entries. Kafka runs
two different durability models in the same process, on purpose.
4.8 Idempotence: ProducerStateManager
Per-partition, per-producer-id state. ProducerStateEntry holds the last few BatchMetadata
records (NUM_BATCHES_TO_RETAIN = 5):
BatchMetadata(lastSeq, lastOffset, offsetDelta, timestamp)
ProducerAppendInfo.append validates:
- Epoch fencing: incoming
producerEpoch < currentEpoch→ProducerFencedException. - Sequence continuity: expected
lastSeq + 1. Gap →OutOfOrderSequenceException. This is fatal-ish, because the broker cannot know whether the missing batch was lost or is merely delayed. - Duplicate: incoming
(baseSequence, lastSequence)matches a retainedBatchMetadata→ return the cached offsets, write nothing.
Retaining 5 batches, not 1, is what makes max.in.flight.requests.per.connection=5 safe with
enable.idempotence=true: a retry of batch n can arrive after n+1..n+4 have been accepted, and
the broker still recognizes it as a duplicate rather than an out-of-order sequence.
Sequence numbers are int32 and wrap. Producer IDs are int64, allocated in blocks by the
controller (ProducerIdControlManager / RPCProducerIdManager, ProducerIdsRecord in the metadata
log) — a block per broker, so InitProducerId is usually a local operation.
State is expired after producer.id.expiration.ms (default 24h) of inactivity, which is also why a
producer idle for longer than that can get UNKNOWN_PRODUCER_ID and must re-initialize.
The empty-batch-retention rule from the format comment ties in here:
if all of the records in a batch are removed during compaction, the broker may still retain an empty batch header in order to preserve the producer sequence information [...] retained only until either a new sequence number is written by the corresponding producer or the producerId is expired
Compaction must not destroy the sequence state that a rebuilt leader needs, or every producer would
get a spurious OutOfOrderSequence after failover.
4.9 acks=all and the purgatory
ReplicaManager.appendRecords → local append succeeds → if acks == -1,
maybeAddDelayedProduce builds a DelayedProduce and puts it in the produce purgatory keyed by
TopicPartitionOperationKey per partition.
val delayedProduce = new DelayedProduce(timeoutMs, initialProduceStatus.asJava, delegate, responseCallback.asJava)
delayedProducePurgatory.tryCompleteElseWatch(delayedProduce, producerRequestKeys)
The completion trigger is Partition.maybeIncrementLeaderHW returning true — which happens when a
follower's FetchRequest advances the leader's view of that follower's LEO. So the produce response
latency for acks=all is:
produce_latency ≈ local_append + (time until every ISR follower's next fetch returns
and its subsequent fetch reports the new LEO)
Note the subsequent fetch. The leader learns a follower has the data only when the follower's
next fetch arrives with a higher fetchOffset. replica.fetch.wait.max.ms (default 500 ms)
bounds how long a follower's fetch parks, but followers use fetch.min.bytes=1 so a fetch returns
as soon as there's data — the practical latency is two network round trips plus the follower's
local append.
DelayedProduce.tryComplete checks each partition's PartitionStatusValidator, which distinguishes:
still-waiting, HW advanced past the required offset (success), leader changed (NotLeaderOrFollower),
or ISR shrank below min.insync.replicas (NotEnoughReplicasAfterAppend — note the AfterAppend
variant; the data is in the log and may still become committed, which is why this error is
explicitly not safe to retry blindly under exactly-once).
5. The read path
5.1 Offset → file position
UnifiedLog.read(startOffset, maxLength, isolation, minOneMessage)
→ LocalLog.read
→ segments.floorEntry(startOffset) // ConcurrentNavigableMap
→ segment.read(startOffset, maxSize, maxPosition, minOneMessage)
→ offsetIndex.lookup(startOffset) // mmap binary search (warm section)
→ FileRecords.searchForOffsetFromPosition() // linear scan from index hint
→ FileRecords.slice(position, length) // no bytes read yet!
FileRecords.slice returns a new FileRecords sharing the same FileChannel with different
start/end. No data has been read at this point. The FetchDataInfo handed back up the stack
contains a lazy view over a file range.
maxPosition is the bound imposed by isolation level. For READ_UNCOMMITTED it's the HW's physical
position; for READ_COMMITTED it's the LSO's. This is why LogOffsetMetadata carries
(messageOffset, segmentBaseOffset, relativePositionInSegment) rather than just an offset — the
fetch path needs the physical position of the HW without doing another index lookup.
minOneMessage handles the case where a single batch exceeds fetch.max.bytes: rather than return
empty forever (a livelock), return the one oversized batch.
5.2 Zero-copy and where it breaks
FileRecords.writeTo:
public int writeTo(TransferableChannel destChannel, int offset, int length) throws IOException {
long newSize = Math.min(channel.size(), end) - start;
int oldSize = sizeInBytes();
if (newSize < oldSize)
throw new KafkaException("Size of FileRecords ... has been truncated during write");
long position = start + offset;
int count = Math.min(length, oldSize - offset);
return (int) destChannel.transferFrom(channel, position, count);
}
transferFrom delegates to FileChannel.transferTo, which on Linux is sendfile(2). The path is
page cache → socket buffer, entirely in the kernel. No user-space copy, no JVM heap allocation,
no GC pressure proportional to fetch volume.
The truncation check is a real hazard: between building the FetchDataInfo and writing it, the log
could be truncated (leader change) or deleted (retention). Failing loudly beats sending garbage.
TransferableChannel's javadoc names the sharp edge:
it will unwrap the destination channel, if possible, in order to benefit from zero copy. This is required because the fast path of
transferTois only executed if the destination buffer inherits from an internal JDK class.
If you wrap the socket channel in anything the JDK doesn't recognize, transferTo silently falls
back to a read/write loop through a heap buffer. PlaintextTransportLayer.transferFrom unwraps
carefully to stay on the fast path.
Zero-copy is lost when:
| Cause | Why |
|---|---|
TLS (SslTransportLayer) | Bytes must be encrypted, which requires user space. This is the big one — expect 20–40% throughput loss. |
| Message format conversion | Down-converting v2→v1 for an ancient client. Removed in 4.0 (v0/v1 no longer supported), so this is now historical. |
READ_COMMITTED? | No. Aborted-txn filtering happens on the consumer; the broker still sendfiles the raw range. |
| Compression change | Only on the produce path, not fetch. |
| Tiered storage | Remote reads land in heap buffers by definition. |
The TLS point is worth internalizing: Kafka's headline throughput numbers are plaintext numbers.
If you must have TLS in the data path, budget for it, and consider whether sendfile-preserving
alternatives (kTLS, offload NICs) are available to you.
5.3 Fetch sessions (KIP-227)
server/src/main/java/org/apache/kafka/server/FetchSession.java and FetchSessionCacheShard.java.
The problem: a follower fetching 5,000 partitions sends a FetchRequest naming all 5,000 partitions
every ~100 ms, even though only a handful have new data. The request itself becomes the bottleneck.
The solution: the broker caches the session's partition set and last-fetched offsets. A follow-up "incremental" fetch sends only changes (added/removed partitions, changed offsets) plus a session ID and epoch. The response contains only partitions with data.
private int cachedSize = -1; // last known size of this session; -1 = not in cache
private final int id;
private final boolean privileged;
private final ImplicitLinkedHashCollection<CachedPartition> partitionMap;
private volatile long lastUsedMs;
private volatile int epoch;
ImplicitLinkedHashCollection is a Kafka-specific data structure: a hash set where the link
pointers live in the elements, so there's no per-entry Node object. With hundreds of thousands
of cached partitions across sessions, that allocation saving is the difference between this being
viable and not.
Eviction is two-tier and it's where the interesting policy lives. FetchSessionCacheShard keeps
two TreeMaps:
private final TreeMap<EvictableKey, FetchSession> evictableByAll = new TreeMap<>();
private final TreeMap<EvictableKey, FetchSession> evictableByPrivileged = new TreeMap<>();
privileged = the session was created by a follower (replication), not a consumer. Followers can
evict consumers; consumers cannot evict followers. The ordering key is (privileged, size, lastUsedMs) — bigger sessions are preferred for retention, since they save more. A session also
becomes evictable-by-all once it's older than evictionMs:
if ((!session.privileged()) || (now - session.creationMs() > evictionMs))
Steal this: when you cache per-client state on a server, classify clients by criticality and make the eviction lattice explicit. "Replication traffic outranks consumer traffic" is a policy you want in the type system, not in a heuristic.
Failure mode to know: when the cache is full (max.incremental.fetch.session.cache.slots, default
1000), new sessions get INVALID_SESSION_ID and fall back to full fetches. This degrades silently
into a throughput cliff. Watch NumIncrementalFetchSessions and
IncrementalFetchSessionEvictionsPerSec.
5.4 DelayedFetch
If fetch.min.bytes isn't satisfied, the fetch goes into the fetch purgatory with
fetch.max.wait.ms. DelayedFetch.tryComplete re-checks accumulated bytes and completes on:
- enough bytes accumulated
- the fetched partition's HW advanced (for follower fetches, LEO advanced)
- leader changed / partition moved / log truncated
- timeout
Completion is triggered from ReplicaManager.completeDelayedFetchRequests(topicPartitions), called
after appends. So an acks=1 produce to a partition immediately unblocks consumers parked on that
partition, without polling.
Note the interaction: produce completions and fetch completions are both purgatory operations
watched on TopicPartitionOperationKey, and an append triggers checkAndComplete on both
purgatories. One append can complete N delayed produces and M delayed fetches. The purgatory's
estimatedTotalOperations / purgeInterval machinery exists to bound the cost of the watcher lists
that build up from completed-but-not-yet-purged operations.
5.5 Read isolation: HW vs LSO
Two ceilings on what a consumer can see:
- High watermark (HW) — the highest offset replicated to all ISR members.
READ_UNCOMMITTEDconsumers read up to HW. - Last stable offset (LSO) —
min(HW, firstUnstableOffset), wherefirstUnstableOffsetis the first offset belonging to an open transaction.READ_COMMITTEDconsumers read up to LSO.
maybeIncrementFirstUnstableOffset() runs on every append. If a transaction opens at offset 1000
and stays open, LSO pins at 1000 even as HW runs to 10,000,000. A single hung transactional
producer blocks every READ_COMMITTED consumer on that partition indefinitely — until
transaction.max.timeout.ms fires and the coordinator aborts it. This is the number-one
exactly-once operational surprise.
TransactionIndex supplies the abort list, and the consumer filters. In
CompletedFetch/AbstractFetch, the consumer maintains a priority queue of aborted transactions by
start offset and drops records whose producerId matches an open abort. Control batches (bit 5 of
attributes) are consumed by the client and never surfaced to the application.
5.6 Tiered storage reads
RemoteLogManager (storage/src/main/java/org/apache/kafka/server/log/remote/storage/). Two SPIs:
RemoteStorageManager— bytes.copyLogSegmentData,fetchLogSegment,fetchIndex,deleteLogSegmentData.RemoteLogMetadataManager— metadata. Default implementation (TopicBasedRemoteLogMetadataManager) stores it in an internal Kafka topic,__remote_log_metadata. Kafka storing Kafka's metadata in Kafka.
Three task pools:
private final ConcurrentHashMap<TopicIdPartition, RLMTaskWithFuture> leaderCopyRLMTasks;
private final ConcurrentHashMap<TopicIdPartition, RLMTaskWithFuture> leaderExpirationRLMTasks;
private final ConcurrentHashMap<TopicIdPartition, RLMTaskWithFuture> followerRLMTasks;
Copy and expiration are separate pools — that split was added because slow expiration (S3 DELETE throttling) was starving the copy path and causing local disks to fill.
Only segments below the high watermark and fully rolled are copied. Local retention
(local.retention.ms / .bytes) then deletes them locally; total retention governs remote.
The read path. ReplicaManager.readFromLog gets an OffsetOutOfRangeException for the local
log when the requested offset is below logStartOffset but above the remote start, and builds a
RemoteStorageFetchInfo. Then:
remoteFetchTask = remoteLogManager.get.asyncRead(remoteFetchInfo, (result: RemoteLogReadResult) => {
remoteFetchResult.complete(result)
...
})
and a DelayedRemoteFetch goes into the purgatory. The I/O thread is not blocked on S3. A
separate RemoteStorageThreadPool does the fetch; the purgatory completes when the future does.
Without this, one slow object-store read would consume an num.io.threads slot for its full latency.
RemoteIndexCache caches remote offset/time/txn indexes on local disk (LRU, sized by
remote.log.index.file.cache.total.size.bytes) so a remote fetch doesn't re-download the index.
The honest performance picture: remote reads are ~100 ms p50 against S3 vs. ~1 ms from page cache, and they are not zero-copy. Tiered storage is for retention economics and for backfill readers, not for latency-sensitive consumers. Design your consumer groups so that lagging consumers are the ones hitting remote.
6. Hierarchical timing wheels and the purgatory
server-common/src/main/java/org/apache/kafka/server/util/timer/TimingWheel.java is one of the
cleanest pieces of code in the tree, and the comment is a small paper.
The problem: hundreds of thousands of concurrent timeouts (every delayed produce, delayed fetch,
delayed join, heartbeat expiry), and the overwhelming majority are cancelled before firing. A
DelayQueue or ScheduledThreadPoolExecutor gives you O(log n) insert and O(log n) delete.
When 99% of your operations are insert-then-delete, that's the wrong shape.
A simple timing wheel is a circular array of n buckets each covering u time units:
A timing wheel has O(1) cost for insert/delete (start-timer/stop-timer) whereas priority queue based timers [...] have O(log n) insert/delete cost.
The drawback is bounded range: n * u. The fix is hierarchy — level k+1 has resolution n times
coarser:
level buckets
1 [c,c] [c+1,c+1] [c+2,c+2]
2 [c,c+2] [c+3,c+5] [c+6,c+8]
3 [c,c+8] [c+9,c+17] [c+18,c+26]
Overflow delegates upward; when a higher bucket expires, its tasks are reinserted and cascade
down to finer wheels. Insert is O(m) where m = number of levels (tiny), delete is O(1).
The O(1) delete is the crux, and it comes from TimerTaskList being a doubly-linked list where
the TimerTaskEntry is held by the TimerTask itself. Cancelling is entry.remove() — unlink
two pointers. No search.
Note the overlap the comment calls out:
bucket [c,c+2] in level 2 won't receive any task since that range is already covered in level 1. [...] This is a bit wasteful, but simplifies the implementation.
A maintainer accepting a small constant-factor waste for a large simplicity win, and documenting it. More code should do this.
How the driving thread works. The wheels themselves don't have a thread. SystemTimer holds a
DelayQueue<TimerTaskList> of buckets, not tasks. So the JDK's DelayQueue — with its O(log n)
ops — is used over the number of non-empty buckets, which is small and bounded, while the number
of tasks is unbounded. This is the trick: use the expensive structure over the cheap dimension.
SystemTimer.advanceClock polls the bucket queue, and an "expiring timer" thread (ExpirationReaper
per purgatory) drives it.
The purgatory (DelayedOperationPurgatory) layers a watcher-list index on top:
public <K extends DelayedOperationKey> boolean tryCompleteElseWatch(T operation, List<K> watchKeys) {
if (operation.safeTryCompleteOrElse(() -> {
watchKeys.forEach(key -> { if (!operation.isCompleted()) watchForOperation(key, operation); });
if (!watchKeys.isEmpty()) estimatedTotalOperations.incrementAndGet();
})) return true;
if (!operation.isCompleted()) {
if (timerEnabled) timeoutTimer.add(operation);
if (operation.isCompleted()) operation.cancel();
}
return false;
}
The double-check (try, then watch, then try again under the operation's lock) is the standard "register before re-checking" pattern that avoids missing a concurrent trigger. The comment then enumerates a real deadlock they hit:
- thread_a holds readlock of stateLock from TransactionStateManager
- thread_a is executing tryCompleteElseWatch()
- thread_a adds op to watch list
- thread_b requires writelock of stateLock (blocked by thread_a)
- thread_c calls checkAndComplete() and holds lock of op
- thread_c is waiting readlock of stateLock (blocked by thread_b)
- thread_a is waiting lock of op (blocked by thread_c)
...and then admits the current fix doesn't eliminate the class, only this instance, and states the convention that actually keeps it safe:
we recommend
DelayedOperationPurgatory.checkAndComplete()be called without holding any exclusive lock.
An invariant maintained by convention and comment rather than by construction. Worth knowing if you touch this code.
Reaper / purge. Completed operations stay in watcher lists until purged. purgeInterval
(producer.purgatory.purge.interval.requests, default 1000) controls when a scan reclaims them.
estimatedTotalOperations vs. actual watched() is the signal. Symptom of getting this wrong:
purgatory size metrics climbing without bound while actual pending work is small.
7. Replication
7.1 Pull replication, and why
Followers fetch. There is no push. ReplicaFetcherThread extends AbstractFetcherThread issues
FetchRequest to the leader with replicaId set, exactly like a consumer but with privileges
(reads above the HW, gets a privileged fetch session, gets OffsetForLeaderEpoch).
Consequences, all of which you inherit if you copy this:
- The leader has no per-follower send queue. No unbounded buffering, no head-of-line blocking from a slow follower. A slow follower just falls behind and eventually leaves the ISR.
- The replication path is the consumer path. Same
FetchRequest, same purgatory, samesendfile. One code path to optimize. Fewer bugs. - The HW lags by one round trip (§7.2). This is the price.
- Adding a replica is trivial — it's just a new fetcher. Compare to push-based systems where the leader must track and manage each replica's stream.
AbstractFetcherManager partitions the work: num.replica.fetchers threads per source broker,
partitions hashed across them. A broker with 4,000 partitions replicating from 10 leaders and
num.replica.fetchers=4 has 40 fetcher threads, each multiplexing ~100 partitions into one
incremental fetch session.
7.2 The high watermark, and its one-round-trip lag
Partition.maybeIncrementLeaderHW:
private def maybeIncrementLeaderHW(leaderLog: UnifiedLog, currentTimeMs: Long = time.milliseconds): Boolean = {
if (isUnderMinIsr) {
trace(s"Not increasing HWM because partition is under min ISR(ISR=${partitionState.isr})")
return false
}
val leaderLogEndOffset = leaderLog.logEndOffsetMetadata
var newHighWatermark = leaderLogEndOffset
remoteReplicasMap.forEach { (_, replica) =>
val replicaState = replica.stateSnapshot
def shouldWaitForReplicaToJoinIsr: Boolean =
replicaState.isCaughtUp(leaderLogEndOffset.messageOffset, currentTimeMs, replicaLagTimeMaxMs) &&
isReplicaIsrEligible(replica.brokerId)
if (replicaState.logEndOffsetMetadata.messageOffset < newHighWatermark.messageOffset &&
(partitionState.maximalIsr.contains(replica.brokerId) || shouldWaitForReplicaToJoinIsr)) {
newHighWatermark = replicaState.logEndOffsetMetadata
}
}
leaderLog.maybeIncrementHighWatermark(newHighWatermark) match { ... }
}
Three things:
isUnderMinIsrshort-circuits. If the ISR is belowmin.insync.replicas, the HW does not advance at all — even for data that is replicated to everyone currently in the ISR. This is what makesacks=all+min.insync.replicas=2actually mean something rather than degrading toacks=1when the ISR shrinks to one.shouldWaitForReplicaToJoinIsrholds the HW back for a replica that's caught up but not yet in the ISR. Without it, the HW could advance past a point, then the catching-up replica joins the ISR, and you'd have an ISR member behind the HW — which breaks the leader-election invariant.- The comment marker "using the maximal" — see §7.3.
The lag: the leader learns follower LEO k from a fetch at fetchOffset = k. It then advances HW to
k. Followers learn the new HW from the next fetch response. So a follower's HW trails the
leader's by one fetch round trip. This is why:
- On leader failover, the new leader truncates to its HW, which may be behind — and this is why KIP-101 leader epochs were needed to avoid data loss/divergence during that truncation.
log.flushand consumer visibility are both bounded by this, and no amount of tuning removes it.
If you're designing this: a push-based system (Raft AppendEntries) can commit in one round trip
because the leader knows immediately that a follower accepted. Kafka trades one RTT of commit
latency for a vastly simpler and more uniform replication path. Whether that's the right trade
depends on whether your commit latency budget is ~5 ms or ~500 µs.
7.3 ISR: expand, shrink, maximal ISR
Expand (maybeExpandIsr, triggered by a follower fetch):
private def isFollowerInSync(followerReplica: Replica): Boolean = {
...
followerEndOffset >= leaderLog.highWatermark && leaderEpochStartOffsetOpt.exists(followerEndOffset >= _)
}
Two conditions, and the second is the subtle one: the follower must be caught up to the current leader epoch's start offset, not just the HW. Otherwise a follower could join the ISR while still holding records from a previous, diverged epoch.
Shrink (maybeShrinkIsr, from a scheduled task): a follower is out of sync if it hasn't fetched
up to the leader's LEO within replica.lag.time.max.ms (default 30s). Note this is purely
time-based — the old replica.lag.max.messages was removed years ago because a message-count
threshold produces ISR flapping under bursty load.
Both go through the controller. The leader sends AlterPartitionRequest; the controller
validates (leader epoch, partition epoch) and writes a PartitionChangeRecord to the metadata log.
Only when that's committed and propagated back does the leader's partitionState become
CommittedPartitionState again.
maximalIsr exists because of the window in between. When PendingExpandIsr is active:
isr = {1, 2} # what the controller has committed
maximalIsr = {1, 2, 3} # what we've proposed
For HW advancement, the leader uses maximalIsr — pessimistic, waits for replica 3 too. For
min.insync.replicas checks it uses the committed isr — pessimistic in the other direction.
Both choices are the conservative one, and that's the point: during the uncertainty window, assume
whichever set makes you safer for the property in question.
PendingShrinkIsr is symmetric: HW advancement still waits for the replicas you're trying to remove
until the controller confirms the removal. If it didn't, you could advance the HW past data that the
about-to-be-removed replica has but the survivors don't — and then fail over to a survivor.
7.4 Leader epochs and log reconciliation
This is KIP-101, and it's the mechanism that lets Kafka use pull replication without divergence.
leader-epoch-checkpoint maps epoch → start offset. On becoming a follower,
AbstractFetcherThread puts the partition in Truncating state and runs:
1. For each truncating partition, take its latest (epoch, LEO).
2. Send OffsetForLeaderEpochRequest(epoch) to the leader.
(Or, on modern versions, the leader piggybacks `divergingEpoch` on the FetchResponse —
see truncateOnFetchResponse.)
3. Leader responds with the end offset of that epoch in ITS log.
4. Follower truncates to min(leaderEndOffsetForEpoch, ownLEO).
5. Repeat with the next-lower epoch if needed.
The key insight: the epoch boundary tells you exactly where two logs can diverge. If the leader says "epoch 5 ended at offset 1000" and you have records at offset 1200 in epoch 5, those 200 records were never committed and must go. You don't need to compare record contents; you don't need to truncate blindly to the HW.
truncateToHighWatermark still exists as the fallback for logs with no epoch information (mixed
format, pre-KIP-101 data). It's marked as the unsafe path.
The modern optimization (truncateOnFetchResponse) piggybacks divergingEpoch on the fetch
response, so the common case needs no extra RPC at all:
.setLeaderEpoch(partitionData.divergingEpoch.epoch)
.setEndOffset(partitionData.divergingEpoch.endOffset)
Design lesson: if your replication protocol truncates, you need a ratchet that identifies the
divergence point without content comparison. Raft uses (prevLogIndex, prevLogTerm) on every
AppendEntries. Kafka uses a persisted epoch→offset map queried on demand. Both work; Kafka's
version has the advantage of being queryable in bulk for thousands of partitions in one RPC.
7.5 Eligible Leader Replicas (KIP-966)
The pre-ELR world had exactly two options when all ISR members are down:
unclean.leader.election.enable=false: partition unavailable until an ISR member returns.unclean.leader.election.enable=true: elect anyone, silently lose data.
ELR adds a third. PartitionRegistration gains elr[] and lastKnownElr[]. When a replica leaves
the ISR because its broker shut down or was fenced — not because it fell behind — it goes into the
ELR. An ELR member is known to have had all committed data at the moment it left. So:
if (isValidNewLeader(preferredReplica)) return new ElectionResult(preferredReplica, false);
if (isValidNewLeader(partition.leader)) return ...
// then: any ISR member
// then: any ELR member ← new, and still a CLEAN election
// then (only if unclean enabled): any live replica
PartitionChangeBuilder maintains it, gated on eligibleLeaderReplicasEnabled (a
MetadataVersion feature, IBP_4_0_IV1+ with elr.version). BrokersToElrs is the controller-side
reverse index (broker → partitions where it's in the ELR), so that when a broker comes back the
controller can immediately find the partitions it can rescue.
lastKnownElr handles the fully-cold-cluster case: if ISR and ELR are both empty, the controller
can still elect the last known ELR member — but only if there's exactly one, and only under
useLastKnownLeaderInBalancedRecovery:
if (partition.lastKnownElr.length != 1) {
log.trace("Try to elect last known leader for {}-{} but lastKnownElr does not only have 1 member...");
More than one candidate means you can't tell which had more data, so it refuses.
This is a genuinely good piece of distributed systems design and it's underappreciated. The insight is that "left the ISR" conflates two very different events — fell behind (may be missing data) and went away (had everything up to the moment it went away) — and that distinguishing them recovers availability with no safety loss.
8. KRaft
ZooKeeper is gone — removed entirely in 4.0. KafkaRaftServer starts a ControllerServer, a
BrokerServer, or both (combined mode), sharing a SharedServer that owns the KafkaRaftClient.
8.1 How it differs from textbook Raft
The class comment is explicit and worth quoting in full:
This class implements a Kafkaesque version of the Raft protocol. Leader election is more or less pure Raft, but replication is driven by replica fetching and we use Kafka's log reconciliation protocol to truncate the log to a common point following each leader election.
The API set:
| RPC | Purpose | Raft equivalent |
|---|---|---|
Vote | request votes (also carries pre-vote flag) | RequestVote |
BeginQuorumEpoch | new leader asserts leadership to voters | (none — Raft uses empty AppendEntries) |
EndQuorumEpoch | leader resigns gracefully | (none) |
Fetch | replication, pull | AppendEntries, inverted |
FetchSnapshot | follower too far behind, get a snapshot | InstallSnapshot, inverted |
The comment explains why BeginQuorumEpoch exists at all:
This is not needed in usual Raft because the leader can use an empty data push to achieve the same purpose. The Kafka Raft implementation, however, is driven by fetch requests from followers, so there must be a way to find the new leader after an election has completed.
That's the fundamental tension of pull-based consensus: the followers don't know who to pull from
until someone tells them. BeginQuorumEpoch is retried indefinitely per voter until acknowledged.
Similarly, truncation detection is piggybacked on Fetch rather than being a separate protocol
state, matching the partition replication design.
Observers vs. voters. Brokers are observers of __cluster_metadata: they fetch, they never vote,
they never become leader. Controllers are voters. This is the same replicaId-based distinction as
partition replication, and it means adding a broker doesn't touch the quorum.
MAX_BATCH_SIZE_BYTES = 8 * 1024 * 1024, MAX_FETCH_WAIT_MS = 500, MAX_NUMBER_OF_BATCHES = 10.
8.2 The quorum state machine, including Prospective
QuorumState.java's comment is the authoritative state diagram:
Resigned → Unattached (higher epoch, or election timeout)
Follower (discovered leader with larger epoch)
Unattached → Unattached (higher epoch, or after giving a binding vote)
Prospective (election timeout)
Follower (discovered leader, equal or larger epoch)
Prospective → Unattached (higher epoch; or no last known leader and lost/timed out)
Candidate (majority of PreVotes granted)
Follower (larger epoch; or had a last known leader and lost/timed out)
Candidate → Unattached (higher epoch)
Prospective (election timeout or loss)
Leader (majority of votes)
Leader → Unattached (higher epoch)
Resigned (graceful shutdown)
Follower (larger epoch)
Follower → Unattached (higher epoch)
Prospective (fetch timeout)
Follower (larger epoch)
Prospective is the pre-vote state (KIP-996) and it's the most important addition to KRaft's
election protocol. ProspectiveState's javadoc:
- Once started, it will send prevote requests and keep record of the received vote responses
- If it receives a message denoting a leader with a higher epoch, it will transition to follower
- If majority votes granted, it will transition to candidate state
- If majority votes rejected or election times out, it will transition to unattached or follower depending on if it knows the leader id and endpoints or not
The problem pre-vote solves: a partitioned node's election timer fires repeatedly. Each time, it bumps its epoch and campaigns. When the partition heals, its high epoch forces the healthy leader to step down — even though the partitioned node was never a viable leader and has a stale log. In a metadata quorum, that means controller churn and a metadata write stall for no reason.
Pre-vote: campaign without bumping the epoch. Ask "would you vote for me?" Voters answer based on
log up-to-dateness and on whether they currently have a healthy leader. Only on a majority of
yeses do you bump the epoch and run a real election. canGrantVote(replicaKey, isLogUpToDate, isPreVote) carries the flag through.
Note also the transition rule that a prospective can re-enter Unattached in the same epoch:
if (epoch < currentEpoch || (epoch == currentEpoch && !isProspective())) {
throw new IllegalStateException(...);
}
Same-epoch backward transitions are normally illegal in Raft; they're legal here specifically because pre-vote didn't bump the epoch, so no state was published.
Complementary is checkQuorum on the leader side (LeaderState):
int majority = (voterStates.size() / 2) + 1;
if (!voterStates.containsKey(localReplicaKey)) majority = majority - 1;
if (fetchedVoters.size() >= majority) { ...reset timer... }
Did not receive fetch request from the majority of the voters within {}ms.
A leader that stops hearing from a majority resigns on its own, rather than waiting to be displaced. That closes the other half of the partitioned-leader problem.
8.3 Leader HW computation
LeaderState.updateHighWatermark:
// Find the largest offset which is replicated to a majority of replicas (the leader counts)
Optional<LogOffsetMetadata> highWatermarkUpdateOpt = followersByDescendingFetchOffset.get(indexOfHw).endOffset;
Sort replicas by fetch offset descending, take the element at the majority index. Standard Raft commit-index computation. Two guards:
if (highWatermarkUpdateOffset > epochStartOffset) { ... }
Raft's rule that a leader may not commit an entry from a previous term by counting replicas — it
must commit something from its own term first. epochStartOffset is the enforcement point. And:
} else if (highWatermarkUpdateOffset < currentHighWatermarkMetadata.offset()) {
log.info("The latest computed high watermark {} is smaller than the current value {}, ...");
HW is monotonic; a decrease means a bug and is logged loudly rather than applied.
The Optional<LogOffsetMetadata> (vs. a bare long) is again the physical-position optimization: the
FetchSnapshot/Fetch responder needs the byte position of the HW.
8.4 Snapshots
__cluster_metadata cannot be compacted with the normal log cleaner — the records are deltas
(PartitionChangeRecord), not key-value overwrites. So KRaft uses snapshots: a full serialized
MetadataImage at (offset, epoch), written as
00000000000000012345-0000000042.checkpoint.
RecordsSnapshotWriter / RecordsSnapshotReader use the same FileRecords machinery as segments,
so FetchSnapshot can sendfile too. UnalignedFileRecords exists because snapshot content isn't
offset-aligned — the position→offset relationship that FileRecords assumes doesn't hold, so a
distinct type prevents accidental misuse.
NotifyingRawSnapshotWriter fires a callback on completion so the log can advance its start offset
and delete the now-redundant prefix.
Generation is triggered by metadata.log.max.record.bytes.between.snapshots (default 20 MB) or
metadata.log.max.snapshot.interval.ms (default 1h).
The bootstrap snapshot (BOOTSTRAP_SNAPSHOT_ID, offset -1) is how kafka-storage format seeds
initial FeatureLevelRecords before any log exists — the bootstrap.checkpoint file.
KRaftControlRecordStateMachine tracks control records embedded in the log (voter set changes,
KRaftVersionRecord), and VoterSetHistory / TreeMapLogHistory keep the voter set as of any
offset — necessary because a Vote request must be evaluated against the voter set that was
current at the relevant offset, not the latest one. Dynamic quorum membership (KIP-853) is what
requires this; AddVoterHandler / RemoveVoterHandler / UpdateVoterHandler implement the
one-at-a-time membership change protocol.
8.5 QuorumController: a single-threaded event loop
metadata/src/main/java/org/apache/kafka/controller/QuorumController.java. This is the piece that
most repays study if you're building a control plane.
Everything is an event on one queue. KafkaEventQueue, one thread. Every mutation is a
ControllerWriteEvent:
class ControllerWriteEvent<T> implements EventQueue.Event, DeferredEvent {
// run():
// 1. verify still active controller (epoch check)
// 2. call op.generateRecordsAndResult() → ControllerResult<T>(records, response)
// 3. raftClient.scheduleAtomicAppend(records)
// 4. deferredEventQueue.add(resultAndOffset.offset(), this) ← does NOT complete the future
// and later, when the offset is committed:
// deferredEventQueue.completeUpTo(offsetControl.lastStableOffset());
}
The design properties this buys:
- No locks anywhere in the control managers.
ReplicationControlManager,ClusterControlManager,ConfigurationControlManager,FeatureControlManagerare all single-threaded-by-construction. Read their code — there is not asynchronizedor aConcurrentHashMapin the state. That's an enormous simplification for logic this intricate. - Deterministic replay. The controller's state is exactly
fold(replay, records). A standby controller applying the same records reaches the same state. This is testable — andtest-common/has harnesses that do exactly that. - Responses are deferred until commit. The client's
CreateTopicsResponsedoesn't arrive until theTopicRecordis committed to a majority of the quorum. No dirty reads of uncommitted controller state ever escape. - Snapshottable state. The managers' state lives in
TimelineHashMap/TimelineHashSet(server-common/.../timeline/) — persistent data structures with epoch-tagged versions, so the controller can serve reads at a past offset and can revert state if a write is not committed (leadership loss). This is the mechanism that makes "generate records, respond later" safe: if the append fails, the in-memory state is rolled back to the last committed offset.
ControllerOperationFlag carries per-operation policy (e.g. DOES_NOT_UPDATE_QUEUE_TIME for
periodic tasks, RUNS_IN_PREMIGRATION).
EventPerformanceMonitor logs slow events. On a controller managing a million partitions, a
single event that takes 500 ms stalls all metadata mutation. The monitor exists because that's the
dominant failure mode of a single-threaded design and you need it visible.
PeriodicTaskControlManager schedules background work (ELR cleanup, expired-token removal,
partition-leader balancing) as events on the same queue, so background work is serialized with
foreground work and can't race it.
ActivationRecordsGenerator produces the records written on becoming active controller — a
tricky bit of code that handles bootstrap, MetadataVersion upgrades detected at activation, and
migration-era leftovers.
Steal all of this. The pattern — single-threaded deterministic state machine, replicated log for durability, deferred completion at commit, persistent data structures for rollback — is the single highest-leverage design in the Kafka codebase. It converts a nightmarishly concurrent problem (cluster metadata mutation) into sequential code that reads like a textbook.
The cost is honest too: one core, and a stall anywhere stalls everything. Kafka's answer is aggressive monitoring plus keeping the per-event work small. If your control plane has genuinely CPU-heavy operations, you need a different answer (batching, or sharding the state machine).
8.6 MetadataImage / MetadataDelta / MetadataLoader
MetadataImage — immutable snapshot of all cluster metadata at an offset
MetadataDelta — a mutable builder holding changes since an image
MetadataLoader — reads the raft log, builds deltas, publishes images
MetadataPublisher — consumers of images/deltas (ReplicaManager, coordinators, quota managers, ...)
public void replay(ApiMessage record) { /* dispatch by record type */ }
public void replay(TopicRecord record) { getOrCreateTopicsDelta().replay(record); }
public void replay(PartitionChangeRecord record) { getOrCreateTopicsDelta().replay(record); }
public void replay(FeatureLevelRecord record) { ... }
public void replay(ClearElrRecord record) { ... }
...
Lazy sub-delta creation (if (topicsDelta == null) topicsDelta = new TopicsDelta(image.topics()))
means a batch that only touches configs allocates nothing in the topics dimension. With a million
partitions, that's the difference between a metadata batch costing microseconds and costing a
full-image copy.
MetadataBatchLoader accumulates records until a batch boundary before publishing — publishers see
transactionally-consistent images, never a half-applied batch.
TopicsDelta.localChanges(brokerId) produces a LocalReplicaChanges — exactly the set of
leader/follower/delete transitions this broker must perform. ReplicaManager.applyDelta consumes it.
This replaces the ZooKeeper-era LeaderAndIsrRequest broadcast entirely: instead of the controller
computing and sending per-broker instructions, each broker derives its own instructions from the
shared log. That's a fundamental architectural improvement — the controller no longer needs to know
what each broker has already processed.
Failure mode this eliminated: in the ZK world, a broker that missed a LeaderAndIsrRequest had
stale state and the controller had to detect and resend. In KRaft, a broker that's behind is simply
behind in the log, and catches up by fetching. Divergence is impossible by construction.
8.7 Feature flags and MetadataVersion
server-common/src/main/java/org/apache/kafka/server/common/MetadataVersion.java:
IBP_4_0_IV1(23, "4.0", "IV1", true),
IBP_4_0_IV2(24, "4.0", "IV2", false),
IBP_4_0_IV3(25, "4.0", "IV3", false),
IBP_4_1_IV0(26, "4.1", "IV0", false),
IBP_4_1_IV1(27, "4.1", "IV1", false),
IBP_4_2_IV0(28, "4.2", "IV0", false),
IBP_4_2_IV1(29, "4.2", "IV1", false),
IBP_4_3_IV0(30, "4.3", "IV0", true),
IBP_4_4_IV0(31, "4.4", "IV0", false), // dead-letter queue for share groups (KIP-1191)
IBP_4_4_IV1(32, "4.4", "IV1", true),
IBP_4_4_IV2(33, "4.4", "IV2", true);
public static final MetadataVersion LATEST_PRODUCTION = IBP_4_3_IV0;
The boolean is didMetadataChange — whether the version introduced new metadata record types or
versions, which determines whether a downgrade is possible. Versions above LATEST_PRODUCTION are
"testing" and require unstable.metadata.versions.enable.
Capability checks read as predicates on the version:
public boolean isElrSupported() { return this.isAtLeast(IBP_4_0_IV1); }
public boolean isShareGroupDLQSupported() { return this.isAtLeast(IBP_4_4_IV1); }
Beyond metadata.version, there are independent features (kraft.version, transaction.version,
group.version, eligible.leader.replicas.version, share.version, streams.version) each with
their own level, stored as FeatureLevelRecords and managed by FeatureControlManager.
QuorumFeatures and ClusterFeatureSupportDescriber compute the max level the whole cluster
supports, so a feature can't be enabled above what the oldest node understands.
This is how you do rolling upgrades in a replicated-log system: the log is the source of truth for what's enabled, the enabling record is itself in the log, and every node's behaviour is a pure function of the log prefix it has applied. There's no "wait for everyone to restart" step and no distributed agreement problem separate from the log.
9. Coordinators
9.1 The coordinator runtime
coordinator-common/src/main/java/org/apache/kafka/coordinator/common/runtime/ — CoordinatorRuntime
is a generic framework, and the group, share, and (in progress) transaction coordinators are all
instances of it. It is essentially QuorumController's design applied to a partitioned state
machine.
CoordinatorRuntime<S extends CoordinatorShard<U>, U>
├─ one CoordinatorShard per partition of the backing internal topic
├─ CoordinatorEventProcessor — striped executor, events for a shard are serialized
├─ coordinator writes produce records → appended to __consumer_offsets / __share_group_state
└─ responses deferred until the append is committed (HW advances past it)
The properties mirror the controller: single-threaded per shard (so GroupMetadataManager has no
locks), deterministic replay from the log, and responses that don't escape before durability.
The difference is sharding — 50 partitions of __consumer_offsets by default means 50 independent
state machines and 50 threads' worth of parallelism, which is what lets one broker coordinate tens of
thousands of groups.
CoordinatorLoader replays a partition's records into a shard on becoming leader for it.
SnapshotRegistry + timeline collections again provide the rollback-on-uncommitted-write property.
9.2 Group coordinator: KIP-848
The old protocol (JoinGroup/SyncGroup, "classic") had a structural problem: the assignment was
computed by an elected group leader — a client. That produced:
- Stop-the-world rebalances. Every member revokes everything, rejoins, waits for the leader to compute, gets a new assignment. A 500-member group with a 100 ms assignor takes seconds of total unavailability.
- Client-side assignor code that the broker couldn't validate or version.
session.timeout.ms/max.poll.interval.mscoupling that made a slow processing loop look like a dead member.
KIP-848 moves assignment server-side and makes reconciliation incremental and per-member. The protocol is a single RPC:
ConsumerGroupHeartbeat(groupId, memberId, memberEpoch, subscribedTopicNames|regex,
rebalanceTimeoutMs, topicPartitions[ownedPartitions])
→ ConsumerGroupHeartbeatResponse(memberEpoch, heartbeatIntervalMs, assignment)
That's it. No join, no sync, no leader. ConsumerGroupHeartbeatRequest carries the member's owned
partitions; the response carries its target. The state machine in between does the rest.
The coordinator maintains:
- Group epoch — bumped on any subscription/membership/metadata change.
- Target assignment — computed by a server-side assignor when the group epoch changes, stored as
ConsumerGroupTargetAssignmentMemberRecords. Has its own epoch. - Per-member current assignment — reconciled toward the target, one member at a time, independently.
ConsumerGroupHeartbeat also carries subscribedTopicRegex — server-side regex subscription
(ResolvedRegularExpression records), which removes the old requirement that every member have
metadata for every topic in order to evaluate the pattern consistently.
9.3 The reconciliation state machine
group-coordinator/.../modern/consumer/CurrentAssignmentBuilder.java:
The CurrentAssignmentBuilder class encapsulates the reconciliation engine of the consumer group protocol. Given the current state of a member and a desired or target assignment state, the state machine takes the necessary steps to converge them.
Three member states (modern/MemberState.java):
STABLE // fully reconciled with the target assignment
UNREVOKED_PARTITIONS // must revoke some partitions before advancing epoch
UNRELEASED_PARTITIONS// advanced epoch, waiting on partitions not yet revoked by previous owners
UNKNOWN // forward-compat: a state this version doesn't know
build():
case STABLE:
if (member.memberEpoch() != targetAssignmentEpoch) return computeNextAssignment(...);
else if (hasSubscriptionChanged) return updateCurrentAssignment(...);
else return member;
case UNREVOKED_PARTITIONS:
// revocation is confirmed by ABSENCE from the heartbeat's owned-partitions list
if (ownsRevokedPartitions(member.partitionsPendingRevocation())) {
if (hasSubscriptionChanged) return updateCurrentAssignment(...);
else return member; // still waiting — no progress
}
return computeNextAssignment(...);
case UNRELEASED_PARTITIONS:
return computeNextAssignment(...); // pick up partitions as they free up
case UNKNOWN:
if (ownedTopicPartitions == null || !ownedTopicPartitions.isEmpty())
throw new FencedMemberEpochException("The consumer group member is in a unknown state. "
+ "The member must abandon all its partitions and rejoin.");
return computeNextAssignment(targetAssignmentEpoch, member.assignedPartitions());
Three details worth extracting:
Revocation is proven by omission. The coordinator never asks "did you revoke?" — it observes
that the partition stopped appearing in ownedTopicPartitions. That's a level-triggered protocol,
not edge-triggered: a lost heartbeat costs you a round trip, not correctness. Enormously more robust
than an ack-based revoke.
currentPartitionEpoch is the safety interlock.
/**
* A function which returns the current epoch of a topic-partition or -1 if the
* topic-partition is not assigned. The current epoch is the epoch of the current owner.
*/
private BiFunction<Uuid, Integer, Integer> currentPartitionEpoch;
A member can only be given a partition once the previous owner's epoch shows it has released it.
UNRELEASED_PARTITIONS is precisely "I've moved to the new epoch, but partition X is still owned by
someone at an older epoch." This is the mechanism that makes the whole thing safe without a global
barrier: instead of "everyone stops, then everyone starts," it's a per-partition handoff with an
epoch-based lock.
The UNKNOWN case is a forward-compatibility fence. A coordinator downgrade encountering a
member state written by a newer version can't reason about it, so it forcibly resets the member.
Failing safe rather than guessing. Note it only throws if the member claims partitions — a member
with nothing to lose is just re-reconciled from scratch.
9.4 Assignors
group-coordinator/.../assignor/:
| Assignor | Use |
|---|---|
UniformHomogeneousAssignmentBuilder | all members subscribe to the same topics — the common case, optimized |
UniformHeterogeneousAssignmentBuilder | different subscriptions per member |
RangeAssignor | co-partitioning: partition i of every subscribed topic to the same member |
SimpleAssignor / SimpleHomogeneous... / SimpleHeterogeneous... | share groups (no exclusivity) |
StickyTaskAssignor | streams groups (KIP-1071), task-aware with standby placement |
The homogeneous/heterogeneous split is a real optimization: homogeneous lets you treat the
assignment as balancing P partitions over M members with a stickiness bias, which is
near-linear. Heterogeneous is a bipartite matching problem.
TopicIds and RangeSet exist to avoid materializing partition lists. A topic with 10,000
partitions is a RangeSet(0, 10000), not a HashSet of 10,000 Integers. UnionSet avoids
copying when merging subscriptions. On a coordinator handling large groups these allocation-avoidance
types dominate the profile.
TargetAssignmentBuilder computes the new target and diffs it against the old, emitting records only
for members whose assignment changed — so a group of 1,000 where one member joins writes ~2 records,
not 1,000.
Server-side assignment also means group.consumer.assignors is a broker config with a broker
plugin interface. You can now ship a custom assignor without redeploying every consumer. That's the
practical win people underrate.
9.5 Transaction coordinator and KIP-890
TransactionState (transaction-coordinator/.../TransactionState.java):
EMPTY → Ongoing (AddPartitionsToTxn / AddOffsetsToTxn)
→ PrepareAbort (EndTxn abort, TV2 only)
ONGOING → PrepareCommit (EndTxn commit) | PrepareAbort (EndTxn abort)
PREPARE_COMMIT → CompleteCommit (all markers acked)
PREPARE_ABORT → CompleteAbort (all markers acked)
COMPLETE_COMMIT / COMPLETE_ABORT → (evicted from cache)
DEAD → transactionalId expired
PREPARE_EPOCH_FENCE → mid epoch bump, fencing older producers
State lives in __transaction_state (compacted, 50 partitions). The TransactionStateManager +
TransactionCoordinator (still Scala in core/src/main/scala/kafka/coordinator/transaction/) own
it. TxnMarkerQueue / TransactionMarkerChannelManager drive WriteTxnMarkers to every partition
that participated.
Commit is two-phase:
- Write
PREPARE_COMMITto__transaction_state, durably. (Now the outcome is decided.) - Send
WriteTxnMarkersto every participating partition leader. Each appends a control batch (attributes bit 5) at the end of the partition, which advances that partition's LSO past the transaction's records. - When all markers are acked, write
COMPLETE_COMMIT.
A coordinator crash between 1 and 3 is recovered by replaying __transaction_state and re-sending
markers. Idempotent because a duplicate marker for an already-completed txn is a no-op.
KIP-890 (transaction version 2) fixes a genuine hanging-transaction bug class. In TV1, the
producer's epoch was bumped only on InitProducerId, so a zombie producer that had been partitioned
could still have an in-flight Produce land after its transaction was aborted — writing a record
into a transaction that no longer existed, hanging the LSO forever.
TV2 fixes it by:
- Bumping the producer epoch on every transaction completion. A zombie's epoch is stale immediately, so its produce is fenced.
- Removing the explicit
AddPartitionsToTxnround trip from the client — the broker implicitly adds the partition on first write, and verifies with the coordinator via a broker-sideAddPartitionsToTxn(theVerificationGuardyou see threaded throughUnifiedLog.append).
VerificationGuard is the object identity token proving that a given append was verified against the
coordinator for the current transaction. VerificationGuard.SENTINEL means "not applicable."
And KIP-1228 adds epoch validation on markers themselves — hence
transactionVersion reaching all the way down into UnifiedLog.append:
// @param transactionVersion the transaction version for the records (1 for TV1, 2 for TV2, etc.)
// Used for epoch validation of transaction markers (KIP-1228).
The fact that a wire-protocol feature version has to be plumbed into the storage layer's append method tells you something about how deeply transactions cut across this system.
9.6 Share groups (KIP-932)
The headline feature of the 4.x line: queue semantics on top of the log. Many consumers can read the same partition cooperatively, with per-record acknowledgement — the RabbitMQ/SQS model, without giving up the log.
core/src/main/java/kafka/server/share/SharePartition.java. Per-record state:
Map.of((byte) 0, RecordState.ARCHIVED, // represents gap
AcknowledgeType.ACCEPT.id, RecordState.ACKNOWLEDGED,
AcknowledgeType.RELEASE.id, RecordState.AVAILABLE,
AcknowledgeType.REJECT.id, RecordState.ARCHIVED)
States: AVAILABLE → ACQUIRED (delivered, lock timer running) → ACKNOWLEDGED (accepted) |
ARCHIVED (rejected or delivery-count exhausted) | back to AVAILABLE (released or lock timeout).
private final NavigableMap<Long, InFlightBatch> cachedState; // ConcurrentSkipListMap
private long startOffset; // SPSO — share-partition start offset
private long endOffset;
startOffset (the Share-Partition Start Offset) is the analogue of a committed consumer offset, but
it can only advance past a contiguous run of terminal-state records. Everything between
startOffset and endOffset is in the in-flight map.
Mechanics you need to know:
ShareFetchacquires records and starts an acquisition lock (group.share.record.lock.duration.ms). Lock expiry returns records toAVAILABLE— this is the redelivery mechanism.deliveryCountper record. Exceedinggroup.share.delivery.count.limitarchives the record. With KIP-1191 (IBP_4_4_IV1) it goes to a dead-letter queue topic instead of being silently dropped.- State is durable. The
ShareCoordinatorpersistsShareSnapshot/ShareUpdaterecords into__share_group_state.PersisterStateBatchCombinermerges overlapping offset ranges to keep the state compact — this is the piece that determines whether the design scales, since naively you'd store per-offset state. GapWindow/persisterReadResultGapWindowhandles the reconstruction problem: after restart, records that were compacted or never had state must be treated as gaps, not as unacknowledged.
What you give up: ordering. A share group has no ordering guarantee within a partition, because
records are acknowledged out of order by design. If you need ordering, use a consumer group. Also,
this is materially more per-record broker-side state than a consumer group's single offset —
deliveryCompleteCount tracking exists specifically to make lag computable without walking the map.
Why this matters strategically: it removes the main reason teams run both Kafka and a traditional queue. The work-queue use case (competing consumers, per-message ack, redelivery, DLQ) was the one thing Kafka structurally couldn't do. Now it can, at the cost of a coordinator that has to track per-record state.
10. Log compaction
storage/.../log/{LogCleaner, LogCleanerManager, Cleaner, SkimpyOffsetMap}.java.
Selection. LogCleanerManager.grabFilthiestCompactedLog:
List<LogToClean> cleanableLogs = dirtyLogs.stream()
.filter(ltc -> (ltc.needCompactionNow() && ltc.cleanableBytes() > 0)
|| ltc.cleanableRatio() > ltc.log().config().minCleanableRatio)
...
LogToClean filthiest = cleanableLogs.stream().max(Comparator.comparingDouble(LogToClean::cleanableRatio))
cleanableRatio = dirtyBytes / (dirtyBytes + cleanBytes), compared against min.cleanable.dirty.ratio
(default 0.5). needCompactionNow handles max.compaction.lag.ms — a hard deadline that forces
compaction regardless of ratio, which exists for GDPR-style delete guarantees:
long maxCompactionLagMs = Math.max(log.config().maxCompactionLagMs, 0L);
long cleanUntilTime = now - maxCompactionLagMs;
There's also min.compaction.lag.ms at the other end — a floor, so recently-written records survive
long enough for consumers to see them at least once.
The two-pass algorithm.
Pass 1 — build the offset map. Cleaner.buildOffsetMap scans the dirty section and inserts
key → latestOffset into a SkimpyOffsetMap.
Pass 2 — copy segments. For each record in the clean+dirty range, retain it iff
offsetMap.get(key) <= record.offset (i.e., this is the latest version) and it isn't an expired
tombstone. Write survivors to a .cleaned segment, group several old segments into one new one
(since they've shrunk), then .swap, then rename.
SkimpyOffsetMap is the interesting part. It is deliberately, named-in-the-class lossy-ish:
public SkimpyOffsetMap(int memory, String hashAlgorithm) {
this.digest = MessageDigest.getInstance(hashAlgorithm); // default MD5
this.hashSize = digest.getDigestLength(); // 16 bytes
this.bytesPerEntry = hashSize + 8; // 24 bytes: hash + offset
this.slots = memory / bytesPerEntry;
}
It stores the MD5 hash of the key, not the key. 24 bytes per entry regardless of key length. Linear probing on collision:
// limit attempt to number of slots once positionOf(..) enters linear search mode
int maxAttempts = slots + hashSize - 4;
The design trade: a 128-bit hash collision would cause the cleaner to retain the wrong record for a key. The probability at any realistic key count is negligible (birthday bound on 2^128), and the payoff is a fixed 24 bytes per key with no key materialization. MD5 here is a hash, not a security primitive — its cryptographic weaknesses are irrelevant and it's fast with a JDK intrinsic.
Capacity math you will need in production:
keys_per_cleaner_pass = (log.cleaner.dedupe.buffer.size / log.cleaner.threads)
/ 24 bytes × log.cleaner.io.buffer.load.factor
Default: 128 MB / 1 thread / 24 × 0.9 ≈ 5 million keys per pass. If a partition's dirty section
has more distinct keys than that, the cleaner compacts only a prefix of the dirty section per pass.
It still makes progress, but slowly, and max.compaction.lag.ms guarantees will be missed. Symptom:
max-dirty-percent stuck high, __consumer_offsets growing. Fix: raise
log.cleaner.dedupe.buffer.size and/or log.cleaner.threads.
Tombstone retention. A null-valued record must be retained long enough for every consumer to
observe the delete, otherwise a consumer that was offline would rebuild state with the key still
present. delete.retention.ms (24h) governs. The mechanism is the delete horizon (attributes
bit 6): when the cleaner first processes a batch containing tombstones, it stamps BaseTimestamp
with the horizon time and sets the bit. Subsequent passes remove tombstones past that horizon. Prior
to this bit, the horizon was inferred from segment modification time, which was fragile.
What compaction cannot do: it is not a delete-by-predicate, not transactional, and does not
compact the active segment. CleanedTransactionMetadata handles the interaction with transactions —
aborted-transaction records can be dropped, but their markers must be retained until every record
they abort is gone.
log.cleanup.policy=compact,delete applies both: retention deletes old segments and
compaction dedupes what remains. This is what __consumer_offsets uses.
11. Producer client internals
clients/src/main/java/org/apache/kafka/clients/producer/internals/.
send() [user thread]
→ interceptors → serializers → partitioner
→ RecordAccumulator.append() ← batching happens here
...
[Sender thread — one per producer]
loop:
accumulator.ready(cluster, now) → which nodes have sendable data
accumulator.drain(...) → node → List<ProducerBatch>
build ProduceRequest per node
client.poll() → NetworkClient / Selector
RecordAccumulator — ConcurrentMap<String /*topic*/, TopicInfo>, each with
ConcurrentMap<Integer, Deque<ProducerBatch>>. Locking is per-deque:
Deque<ProducerBatch> dq = topicInfo.batches.computeIfAbsent(effectivePartition, k -> new ArrayDeque<>());
synchronized (dq) {
if (partitionChanged(topic, topicInfo, partitionInfo, dq, nowMs, cluster)) continue;
RecordAppendResult appendResult = tryAppend(timestamp, key, value, headers, callbacks, dq, nowMs);
if (appendResult.appended()) return updatePartitionInfoOnAppend(...);
}
Note the while (true) { ... continue; } retry loop: the partition is peeked before taking the
deque lock (to avoid holding a lock across partition selection), so it must be re-validated after.
Classic optimistic pattern.
BufferPool.allocate is called outside the deque lock, and can block up to max.block.ms. The
comment is explicit about why time.milliseconds() is refreshed after:
// NOTE: getting time may be expensive, so calling it under a lock should be avoided.
BufferPool maintains a free list of exactly-batch.size buffers plus a non-pooled remainder. A
record larger than batch.size gets its own oversized buffer that is not returned to the pool.
Consistently oversized records therefore mean constant allocation — one reason batch.size should
be ≥ your p99 record size.
BuiltInPartitioner — the sticky partitioner, and it is not what people think. It is not
"round-robin per batch." From updatePartitionInfo:
int producedBytes = partitionInfo.producedBytes.addAndGet(appendedBytes);
...
if (producedBytes >= stickyBatchSize && enableSwitch || producedBytes >= stickyBatchSize * 2) {
// switch partition
}
It sticks to a partition until stickyBatchSize bytes have been produced to it, then switches.
The * 2 upper bound is a forced switch even when enableSwitch is false:
// between stickyBatchSize and stickyBatchSize * 2 bytes, to better align with batch boundary
enableSwitch is false while the deque has incomplete batches, so the switch aligns with a batch
boundary rather than splitting one. And nextPartition is load-aware — it weights by partition
queue depth so a slow broker gets less traffic, rather than uniform round-robin.
Delivery timeout. deliveryTimeoutMs is the total budget from send() to callback, covering
accumulator time + all retries. nextBatchExpiryTimeMs caches the earliest expiry so the Sender's
poll timeout can be set correctly instead of scanning every deque each loop.
Batch splitting. splitAndReenqueue handles RecordTooLargeException after compression: the
producer estimated the compressed size, the broker rejected it, so split in half and retry.
Sequences are reassigned:
// We treat the newly split batches as if they are not even tried.
// We should track the newly created batches since they already have assigned sequences.
Ordering under retry. insertInSequenceOrder maintains the invariant that batches with assigned
sequences leave the deque in sequence order:
// Further, once batches are being retried, we are reduced to a single in flight request for that
// partition. So when the subsequent batches come back in sequence order, they will have to be
// placed further back in the queue.
This, plus max.in.flight.requests.per.connection <= 5, plus the broker's 5-batch producer state
retention, is the complete ordering-under-retry story for the idempotent producer.
ChunkedRecordAccumulator / ChunkedProducerBatch are newer: instead of one contiguous
batch.size buffer per batch, allocate chunks. Reduces waste when partitions are numerous and
batches rarely fill.
TransactionManager — client-side transaction state machine, sequence number allocation, and
the TxnPartitionMap of per-partition sequence/epoch state. It is also what enforces that you can't
call send() outside beginTransaction() in transactional mode.
12. Consumer client internals
Two implementations behind ConsumerDelegate:
ClassicKafkaConsumer— the original. User thread does everything; aHeartbeatThreadruns alongside for the classic protocol.ConsumerCoordinatorimplements JoinGroup/SyncGroup.AsyncKafkaConsumer— the new one (KIP-945), default with the consumer protocol.
The async design:
[user thread] [ConsumerNetworkThread]
poll() loop:
→ ApplicationEventQueue ────► process application events
◄──── BackgroundEventQueue requestManagers.poll(now) → NetworkClientDelegate
← FetchBuffer networkClientDelegate.poll()
→ completed fetches into FetchBuffer
RequestManager implementations, each owning one concern:
| Manager | Responsibility |
|---|---|
FetchRequestManager | build/track fetch requests, fetch sessions |
CommitRequestManager | auto-commit, explicit commit, offset fetch |
ConsumerHeartbeatRequestManager | ConsumerGroupHeartbeat, member epoch |
CoordinatorRequestManager | find/track the group coordinator |
OffsetsRequestManager | ListOffsets for seek/beginning/end |
TopicMetadataRequestManager | metadata |
ConsumerNetworkThread.poll() calls each manager's poll(now), gets back PollResults (requests to
send + a next-poll timeout), and hands them to NetworkClientDelegate.
Why this matters: in the classic consumer, poll() had to be called frequently or the member was
evicted, because heartbeats rode on the user thread's poll loop (partly — the classic protocol did
have a background heartbeat thread, but rebalance callbacks still ran on the user thread and blocked
everything). In the async consumer, network activity is fully decoupled: a slow poll() loop affects
max.poll.interval.ms only.
ConsumerMembershipManager is the client mirror of the coordinator's reconciliation state
machine. It receives a target assignment, invokes ConsumerRebalanceListener callbacks via
ConsumerRebalanceListenerInvoker (on the user thread, dispatched through the background event
queue), and only reports the partition as revoked in the next heartbeat once onPartitionsRevoked
has returned. That's what makes the coordinator's "revocation proven by omission" protocol correct.
FetchBuffer / FetchCollector — the network thread parks completed fetches in FetchBuffer;
poll() on the user thread drains and deserializes via FetchCollector. Deserialization on the user
thread is deliberate: it keeps user-supplied Deserializer code (which can be slow or can throw) off
the network thread.
CompletedFetch is where READ_COMMITTED filtering happens — it holds the aborted-transaction
priority queue and drops records from aborted producers as it iterates.
AbstractStickyAssignor is still there for classic-protocol groups: CooperativeStickyAssignor
implements incremental cooperative rebalancing (KIP-429), the pre-848 mitigation. If you're on the
new protocol you don't need it.
13. Quotas, throttling, and backpressure
Four independent quota types, three enforcement mechanisms.
Types: produce byte-rate, fetch byte-rate, request percentage (request_percentage — CPU time in
network+IO threads), and controller mutation rate (create/delete topics, partition changes).
ClientQuotaManager (server/src/main/java/org/apache/kafka/server/quota/) uses the metrics
library's Rate over N sample windows (quota.window.num = 11, quota.window.size.seconds = 1).
When a Sensor.record() throws QuotaViolationException, throttle time is computed as: how long
must we pause so that the observed rate falls back to the quota, given the current window's
accumulated value.
Quota entities resolve hierarchically: (user, client-id) → (user) → (client-id) → <default>.
Configured in __cluster_metadata via ClientQuotaRecord, applied through ClientQuotasDelta.
Enforcement:
-
throttleTimeMsin the response. The client is expected to pause. Well-behaved clients do. -
Channel muting. The broker also mutes the connection for
throttleTimeMsviaThrottledChanneland the purgatory. FromSocketServer:Try unmuting the channel. If there was no quota violation and the channel has not been throttled, it will be unmuted immediately. If the channel has been throttled, it will be unmuted only if the throttling delay has already passed by now.
This is the part that makes quotas actually enforceable against a misbehaving client — you don't need the client's cooperation.
-
unrecordQuotaSensor. A subtlety worth reading:For a throttled fetch, the broker should return an empty response and thus should not record the value. Ideally, we would like to compute the throttle time before actually recording the value, but the current Sensor code couples value recording and quota checking very tightly. As a workaround, we will unrecord the value for the fetch in case of throttling.
A negative record to undo an accounting entry, because the API couples measurement and decision. This is the sort of thing you find in mature code, and the honest comment is worth more than a clean-looking abstraction would be.
Replication throttling is separate: leader.replication.throttled.rate /
follower.replication.throttled.rate plus per-topic *.throttled.replicas lists (validated by
ThrottledReplicaListValidator). Used during reassignments so rebalancing doesn't starve production
traffic.
Backpressure layers, from outermost in:
1. TCP receive window ← MemoryPool exhaustion stops selector reads
2. newConnections queue (20/processor) ← acceptor blocks, kernel backlog absorbs
3. requestQueue (queued.max.requests=500) ← processors block on sendRequest
4. Quotas ← channel mute + throttleTimeMs
5. Purgatory ← delayed ops don't hold IO threads
Layer 5 is the one people miss. A purgatory-based design means a request that's waiting costs a
DelayedOperation object and a timing-wheel entry, not a thread. That's why 8 I/O threads can serve
100,000 in-flight acks=all produce requests.
14. Comparison with other streaming systems
14.1 Kafka vs. Amazon Kinesis Data Streams
The single biggest architectural difference: Kinesis has no consumer-side log abstraction — it has a shard iterator.
| Dimension | Kafka | Kinesis Data Streams |
|---|---|---|
| Unit of parallelism | Partition | Shard |
| Position | Consumer-owned integer offset; seek anywhere in retention | ShardIterator (opaque, expires in 5 min); SequenceNumber for AT_SEQUENCE_NUMBER |
| Retention | Unbounded (disk/tiered), default 7d | 24h default, up to 365d (extra cost) |
| Record size | max.message.bytes default 1 MB, tunable to ~10s of MB | Hard 1 MB, non-negotiable |
| Batching | Producer-side, batch is the on-disk atom | PutRecords up to 500 records / 5 MB; KPL adds client-side aggregation as a convention (protobuf-in-record), which every consumer must de-aggregate |
| Throughput unit | Broker/disk/network bound; a partition does 10s of MB/s | Hard: 1 MB/s or 1,000 rec/s in, 2 MB/s out per shard. Exceed → ProvisionedThroughputExceededException |
| Fan-out | N consumer groups share the same sendfiled bytes; cost is network only | Shared: 2 MB/s total across all consumers, 5 GetRecords/s. Enhanced Fan-Out: 2 MB/s per consumer, up to 20, HTTP/2 push, priced per consumer-shard-hour |
| Scaling | Add partitions (irreversible, breaks key→partition); reassign replicas online | UpdateShardCount splits/merges shards; produces a shard lineage tree consumers must traverse (parent shards must be fully read before children) |
| Ordering | Per partition | Per shard — but resharding breaks it: a key's records may span a parent and child shard |
| Delivery semantics | At-least-once; exactly-once via idempotent producer + transactions | At-least-once only. No idempotent producer, no transactions. Dedup is your problem |
| Consumer coordination | Group coordinator, server-side assignment (KIP-848) | KCL only, using a DynamoDB table for lease management. Not part of the service |
| Replication | Configurable RF, ISR, your choice of durability | 3 AZs, opaque, not configurable |
| Ops burden | You run it (or pay MSK/Confluent) | None |
| Protocol | Open binary protocol, dozens of client implementations | AWS SDK/HTTPS only |
| Compaction | Yes | No |
| Queue semantics | Share groups (KIP-932) | No (that's SQS) |
| Transactional cross-partition writes | Yes | No |
The deep differences, not the feature table:
-
Kafka's offset is a number; Kinesis's iterator is a capability. A Kafka consumer stores an
int64and can resume from it a week later. A KinesisShardIteratorexpires in 5 minutes, so the consumer must store aSequenceNumberand re-derive an iterator. That's an extra API call and a latency floor per restart, and it means Kinesis can move data around behind the abstraction — which is exactly what makes resharding possible without rewriting history. Kafka's offset stability is why partition count can't decrease. -
The shard lineage tree is Kinesis's price for elastic scaling. When you reshard, the parent shard is sealed and children are created. Consumers must read the parent to
SHARD_ENDbefore reading children, or ordering breaks. KCL handles this; hand-rolled consumers routinely get it wrong. Kafka's answer to elasticity is "over-partition up front," which pushes the cost to provisioning time rather than runtime. Neither is obviously better; they're different failure modes. -
Fan-out economics are inverted. In Kafka, adding a consumer group is nearly free — same page cache, same
sendfile, incremental network. In Kinesis, the shared 2 MB/s egress means the n-th consumer group degrades the others, so past ~2 groups you're buying Enhanced Fan-Out at $0.015/consumer-shard-hour plus $0.013/GB. At 100 shards and 5 consumers that's ~$550/month in EFO hours alone before data. This is the number that drives most Kinesis→Kafka migrations. -
KPL aggregation is a protocol-level wart. Because a record is capped at 1 MB and billed per record, the KPL packs many user records into one Kinesis record using a protobuf envelope. This is a client library convention, not a service feature — so any consumer not using KCL/KPL sees protobuf blobs. Kafka made batching part of the wire format and the storage format, so every consumer benefits and no one has to know.
-
Exactly-once. Kinesis has no equivalent of the idempotent producer or transactions. Building EOS on Kinesis means an external dedup store keyed by a producer-supplied ID, checked on the consumer side. That's a real system you have to build, operate, and scale.
When Kinesis is genuinely the right call: low-to-moderate, predictable throughput; you're all-in on AWS; you value zero operational burden over cost and features; your consumers are Lambda (the integration really is excellent). When it isn't: high fan-out, high throughput, long retention, exactly-once, compaction, or any need for a protocol other than AWS's.
14.2 Kafka vs. Apache Pulsar
Pulsar's core architectural claim is separation of serving and storage: brokers are stateless, storage is BookKeeper.
| Kafka | Pulsar | |
|---|---|---|
| Storage | Broker-local log, replicated by ISR | Apache BookKeeper ledgers; broker owns no data |
| Replication | Leader/follower, ISR | Quorum writes to writeQuorum bookies, ack on ackQuorum |
| Broker failover | Leader election, new leader already has data | Instant — any broker can take a topic, data is in BookKeeper |
| Partition rebalance | Move data (or don't move it, and accept skew) | Move ownership only, no data movement |
| Consumption modes | Consumer groups; share groups (new) | Exclusive, Failover, Shared, Key_Shared — since day one |
| Multi-tenancy | Quotas + ACLs, one flat namespace | tenant/namespace/topic hierarchy, first-class |
| Geo-replication | MirrorMaker 2 (a Connect app) | Built into the broker |
| Tiered storage | KIP-405, since 3.6 | Since 2.1 |
| Latency | Lower p50 (no extra hop); page cache serving | Extra network hop broker→bookie; BookKeeper journals to a separate device |
| Ops | One system (since ZK removal) | Broker + BookKeeper + ZooKeeper (ZK still required in most deploys) |
Honest assessment: Pulsar's storage separation is the better architecture on paper and Kafka's single-system operation is the better architecture in practice. Pulsar's instant failover and zero-data-movement rebalancing are real advantages that Kafka cannot match without a rewrite. But you operate three distributed systems instead of one, and BookKeeper's failure modes (ledger recovery, auto-recovery, bookie decommissioning) are their own specialty.
Note the convergence: share groups are Kafka's answer to Shared subscriptions, tiered storage
answered offloaders, and KRaft answered "why do I need ZooKeeper." The remaining structural gap is
storage separation.
14.3 Kafka vs. Redpanda
Redpanda is a C++ reimplementation of the Kafka protocol on Seastar (thread-per-core,
shared-nothing, DPDK-capable, io_uring).
- Same wire protocol — your clients don't change.
- Thread-per-core with no shared state, so no locks and no cross-core cache-line traffic. Kafka's
per-partition
synchronized (lock)becomes a core-local operation. - Raft per partition rather than ISR. Every partition is its own Raft group. This gives real
quorum commit semantics instead of ISR, and eliminates the "ISR shrank to 1, now
acks=allmeans nothing" class of problem. - No JVM — no GC pauses, and memory is explicitly managed rather than page-cache-dependent. Redpanda bypasses the page cache and manages its own.
- fsync by default, unlike Kafka. Their argument is that with Raft + fast NVMe you can afford it; Kafka's counter-argument is the IOPS bill.
Their published p99 advantages are real and largely attributable to the thread-per-core model and the absence of GC. The counter-arguments: a much smaller ecosystem, per-partition Raft has higher metadata overhead at very large partition counts, and you're betting on one vendor's implementation of a protocol whose spec is "whatever Apache Kafka does."
The interesting lesson for a maintainer: most of Redpanda's advantage comes from things Kafka could have (thread-per-core partitioning of work, per-partition consensus) but can't adopt now without breaking everything. Architecture is path-dependent.
14.4 Kafka vs. WarpStream / diskless (KIP-1150)
WarpStream (and Confluent Freight, and AutoMQ) implement the Kafka protocol with S3 as the only storage layer. No local disks, no inter-AZ replication traffic, stateless agents.
The economics: inter-AZ transfer for replication is often the largest line item in a cloud Kafka bill — three copies means 2× your ingest crosses AZ boundaries at ~$0.02/GB each way. Writing straight to S3 (which is already multi-AZ) eliminates it entirely.
The cost: latency. S3 PUT is ~50–200 ms, so produce latency goes from single-digit ms to hundreds of ms. These systems batch aggressively and target workloads where a 200–500 ms end-to-end latency is acceptable — which, honestly, is most analytics pipelines.
KIP-1150 ("diskless topics") brings this model into Apache Kafka itself as a per-topic option. This is the most consequential thing on Kafka's roadmap: it makes the storage model a topic-level choice rather than a cluster-level architecture decision. Latency-sensitive topics stay on local disks with ISR; high-volume log/telemetry topics go diskless and cost 5–10× less to run.
14.5 Quick positioning of the rest
- Azure Event Hubs — has a Kafka-protocol endpoint; underneath it's its own system. Throughput Units, 7-day max retention (90 with Premium/Dedicated), no compaction, no transactions. Same managed-service trade as Kinesis with better protocol compatibility.
- Google Pub/Sub — not a log at all. Per-message ack, no offsets, no replay by position (Seek by timestamp only), automatic scaling, no partitions to manage. Pub/Sub Lite was the partitioned Kafka-like variant; it was deprecated.
- NATS JetStream — dramatically simpler operationally, single binary, excellent for edge/IoT/request-reply. Consumers have both push and pull modes and per-message ack. Not designed for the multi-TB-per-day replayable-log use case.
- Apache RocketMQ — architecturally closest to Kafka; dominant in China. Notable for a single
shared
CommitLogper broker with per-topic index files, rather than per-partition files — which makes it much better at very high topic counts and worse at per-partition sequential read locality. - Redis Streams — a log data type inside Redis. Consumer groups with per-message ack (
XACK), pending-entries list,XAUTOCLAIMfor redelivery. Memory-bound, so retention is small. Excellent for a work queue at the scale where you already run Redis.
15. Design lessons: building your own
If you're implementing a durable log — or borrowing pieces for something else — these are the ideas in Kafka worth taking, ranked by leverage.
15.1 Make the batch, not the record, your atom
Compression, checksum, idempotence identity, transactional membership, index granularity, and replication unit should all be the same object. Kafka's per-record cost is ~6–8 bytes of framing and essentially zero CPU because everything expensive happens once per batch. Systems that made the record the atom (per-message compression, per-message ack, per-message index entry) spend 10–100× more per unit of data and can never claw it back.
Corollary: expose the batch to the client. If the producer builds the batch and the broker stores it byte-identically, you get zero-copy on both sides. If the broker has to reframe, you've lost.
15.2 Put the checksum boundary between client-authored and server-authored fields
PartitionLeaderEpoch outside the CRC is worth its awkwardness many times over. Before you finalize
a format, list every field and mark who writes it. Any server-written field inside a client-computed
checksum is a full-payload recompute on your hottest path.
15.3 Sparse index + linear scan beats a dense index
8 bytes per 4 KB of log. 2 MB of index per 1 GB segment. Compare to a B-tree or an LSM's block index
- bloom filters + manifest. The linear scan from the index hint is 4 KB of sequential page-cache reads — cheaper than the extra index levels you'd traverse to avoid it. This works because the access pattern is "seek once, then stream," which is true of almost every log consumer.
15.4 Design your data structures around the page cache, not around big-O
The warm-section binary search (§3.6) is the canonical example: same complexity, but a probe set that doesn't drift. Ask of every mmapped structure: which pages does a typical operation touch, and does that set change as the structure grows? If it changes, you have a periodic latency cliff you haven't found yet.
15.5 Don't fsync; replicate — but say so out loud
Kafka's durability model is "N page caches" not "one disk." That's a defensible engineering choice
with a precisely-statable failure mode (simultaneous power loss to min.insync.replicas nodes).
Whatever you choose, state the failure mode in the docs in one sentence, because your users will
assume the other one.
15.6 Use a hierarchical timing wheel for timeouts, and put the priority queue over buckets
O(1) insert and delete matters when 99% of timers are cancelled. And the DelayQueue<TimerTaskList>
trick — expensive structure over the cheap dimension (buckets), cheap structure over the expensive
dimension (tasks) — generalizes far beyond timers.
15.7 Delayed operations should cost an object, not a thread
The purgatory is why 8 I/O threads serve 100,000 in-flight requests. Any time you're tempted to block a worker thread on a condition that might be satisfied by another request, build a watcher-list + timer instead. The complexity is real (see the deadlock comment) but the alternative is a thread pool sized by your worst-case concurrency.
15.8 One event queue, deterministic replay, deferred completion
The QuorumController pattern (§8.5). If your control plane state is small enough to fit in memory
and your mutation rate is low enough for one core:
mutation → single-threaded handler → (records, response)
→ append records to replicated log
→ hold response until the append commits
→ apply records to in-memory state on commit
→ persistent (timeline) data structures so uncommitted state can be reverted
You get: no locks, deterministic replay, testable state machines, no dirty reads, and standby replicas for free. The cost is one core and the need to keep per-event work small. I'd take this trade in almost every control plane I've seen.
15.9 Level-triggered protocols beat edge-triggered ones
KIP-848's revocation is confirmed by a partition's absence from the next heartbeat, not by a revoke-ack message. A lost message costs a round trip, not correctness. Same idea in KRaft: brokers derive their own leader/follower duties from the shared log rather than receiving instructions the controller must track delivery of.
Rule of thumb: if your protocol has a message whose loss requires retry-tracking on the sender, consider whether the receiver could instead just report its state and let the sender diff.
15.10 Distinguish "fell behind" from "went away"
ELR (§7.5). Conflating them cost Kafka a decade of unclean-election data loss. Any time you evict a member from a quorum/ISR/membership set, ask whether the eviction reason tells you something about what that member knows.
15.11 Push the version boundary into the log
MetadataVersion + FeatureLevelRecord: the record that enables a feature is itself in the
replicated log, so every node's behaviour is a pure function of its log prefix. No separate
agreement protocol, no "wait for all nodes to restart," and downgrade safety is a property you can
compute (didMetadataChange).
15.12 Backpressure at every layer, and let TCP be the outermost one
Kafka's five layers (§13) all terminate in "stop reading from the socket," which closes the receive
window, which blocks the producer's send(). That's the only backpressure mechanism that can't be
ignored by a misbehaving client. Everything else — response-carried throttle times, queue bounds —
is an optimization on top of it.
15.13 Write the post-mortem in the comment
AbstractIndex's page-fault analysis, DelayedOperationPurgatory's 7-step deadlock,
FileRecords's "avoid calling lastOffset()," ClientQuotaManager's "unrecord as a workaround
because the Sensor API couples recording and checking." These comments are why a new maintainer can
be productive in this codebase. The value isn't the explanation of what the code does — it's the
record of what was tried and why it failed.
16. Maintainer's appendix
16.1 Build and test
./gradlew clean jar -PscalaVersion=2.13 # build
./gradlew processMessages # regenerate protocol classes from JSON
./gradlew unitTest # unit only
./gradlew :core:test --tests kafka.log.LogManagerTest
./gradlew :storage:test --tests '*UnifiedLog*'
./gradlew checkstyleMain spotbugsMain # style/static analysis — CI will reject you otherwise
./gradlew :jmh-benchmarks:jmh -PjmhArgs='RecordBatchIterationBenchmark'
./gradlew :core:integrationTest # slow
test-common/ holds ClusterTestExtensions — the JUnit 5 machinery for spinning up real KRaft
clusters in-process. @ClusterTest(types = {Type.KRAFT}, brokers = 3) gives you a real cluster in a
unit test. This is the single most useful thing in the repo for writing a credible patch.
api-checker/ enforces public API compatibility. If you change a signature in clients, it will
tell you.
16.2 Adding an RPC field — the actual workflow
- Edit
clients/src/main/resources/common/message/XxxRequest.json/XxxResponse.json. BumpvalidVersions. Add the field with"versions": "N+". ./gradlew processMessages.- Add an
ApiKeysentry if it's a new API. - Gate on
MetadataVersion(or a dedicated feature) if brokers must agree. - Handle it in
KafkaApis(orControllerApis). - Add to
ApiVersionsResponsehandling if version negotiation matters. - Tests:
MessageTestround-trip,RequestResponseTest, and an integration test.
The JSON is the source of truth. Never hand-edit generated classes.
16.3 Metrics you should be able to read cold
| Metric | Watch for |
|---|---|
UnderReplicatedPartitions | > 0 sustained = ISR trouble |
UnderMinIsrPartitionCount | > 0 = acks=all producers are failing |
OfflinePartitionsCount | > 0 = data unavailable |
RequestHandlerAvgIdlePercent | < 0.3 = I/O threads saturated; raise num.io.threads |
NetworkProcessorAvgIdlePercent | < 0.3 = network threads saturated |
RequestQueueSize | near queued.max.requests = backpressure engaged |
TotalTimeMs p99 by request type, broken into RequestQueueTimeMs, LocalTimeMs, RemoteTimeMs, ResponseQueueTimeMs, ResponseSendTimeMs | Learn this decomposition. RemoteTimeMs high on Produce = slow followers. LocalTimeMs high = disk or recompression. RequestQueueTimeMs high = not enough I/O threads |
PurgatorySize{Produce,Fetch} | growing without bound = purge interval or a leak |
NumIncrementalFetchSessions, IncrementalFetchSessionEvictionsPerSec | evictions > 0 = session cache too small, full fetches incoming |
max-dirty-percent (cleaner) | stuck high = dedupe buffer too small |
MaxLagBetweenAppendAndFlush (KRaft) | metadata log fsync latency |
LastAppliedRecordLagMs (broker) | broker's metadata is stale |
BytesInPerSec / BytesOutPerSec ratio | out/in ≫ consumer count = something's re-reading from disk |
16.4 Debugging on a live broker
# Dump a segment, including producer/txn state
kafka-dump-log.sh --files 00000000000000000000.log --print-data-log --deep-iteration
# Just the batch headers — much faster, shows offsets/epochs/producerId/sequences
kafka-dump-log.sh --files 00000000000000000000.log
# Indexes, with consistency verification against the log
kafka-dump-log.sh --files 00000000000000000000.index --index-sanity-check
kafka-dump-log.sh --files 00000000000000000000.timeindex
kafka-dump-log.sh --files 00000000000000000000.txnindex
# Producer state snapshot
kafka-dump-log.sh --files 00000000000000000000.snapshot
# The metadata log itself
kafka-dump-log.sh --cluster-metadata-decoder --files __cluster_metadata-0/*.log
# Interactive metadata browser
kafka-metadata-shell.sh --snapshot __cluster_metadata-0/*.checkpoint
> ls /topics
> cat /topics/orders/0/data
# Quorum health
kafka-metadata-quorum.sh --bootstrap-controller localhost:9093 describe --status
kafka-metadata-quorum.sh --bootstrap-controller localhost:9093 describe --replication
# Feature levels
kafka-features.sh --bootstrap-server localhost:9092 describe
# Hanging transaction hunting
kafka-transactions.sh --bootstrap-server localhost:9092 list
kafka-transactions.sh --bootstrap-server localhost:9092 find-hanging --broker-id 1
kafka-transactions.sh --bootstrap-server localhost:9092 abort --topic t --partition 0 --start-offset N
The one to internalize: find-hanging + abort is the fix for a pinned LSO blocking
READ_COMMITTED consumers. It's the most common exactly-once incident.
16.5 Failure modes worth memorizing
| Symptom | Likely cause |
|---|---|
| p99 produce spikes ~1s, periodic | Index page faults — should be fixed by warm-section search; if you see it, check for a huge index or a non-4K page size |
Broker CPU pinned, LocalTimeMs high on Produce | Recompression: broker/topic compression.type ≠ producer's |
READ_COMMITTED consumers stalled, BytesOut fine for READ_UNCOMMITTED | Hanging transaction pinning LSO |
| ISR flapping | replica.lag.time.max.ms too low, or GC pauses on followers, or network |
UNKNOWN_PRODUCER_ID after idle | producer.id.expiration.ms elapsed; producer must re-init |
__consumer_offsets growing unbounded | Cleaner starved: log.cleaner.dedupe.buffer.size too small for the key cardinality |
| Rebalance storms (classic protocol) | max.poll.interval.ms exceeded by slow processing → migrate to KIP-848 |
| Slow controller, everything stalls | Single-threaded event loop; check EventPerformanceMonitor logs |
| Fetch throughput collapses on a large-partition consumer | Fetch session evicted → full fetches; check eviction rate |
Produce latency high, RemoteTimeMs dominant | acks=all waiting on a slow ISR member — find it via per-replica lag |
16.6 Reading order, if you're new to the tree
DefaultRecordBatch.java+DefaultRecord.java— the format. Everything else assumes it.UnifiedLog.append— the write path in one method.AbstractIndex— including the whole comment.TimingWheel+DelayedOperationPurgatory— the async model.Partition.scala— ISR, HW, epochs.QuorumState.java+KafkaRaftClientclass doc — consensus.QuorumController.java— the control plane pattern.CurrentAssignmentBuilder.java— the cleanest state machine in the repo.RecordAccumulator+Sender— the client side.jmh-benchmarks/— what the maintainers actually consider hot.
16.7 Things that are not true anymore (but are still in every blog post)
- ZooKeeper. Gone in 4.0.
kafka.zk,KafkaController,LeaderAndIsrRequest,UpdateMetadataRequest,StopReplicaRequest— all removed. org.apache.kafka.common.record.DefaultRecordBatch— moved to...record.internal.- The log layer is Scala. It's Java, in
storage/. - Message formats v0/v1 — removed. Only v2 (magic 2) is supported, so down-conversion is gone.
--zookeeperon any CLI tool.- The consumer rebalance protocol is JoinGroup/SyncGroup — that's "classic"; the default is KIP-848.
- Kafka can't do queues. Share groups (KIP-932) exist.
unclean.leader.election.enableis your only availability lever — ELR (KIP-966) is the third option.- Kafka requires local disks for everything — tiered storage (KIP-405) is GA, and diskless topics (KIP-1150) are in flight.
Compiled from apache/kafka @ 930ebc5608bb0ac938085321d09b402b850ca87b, 4.4.0-SNAPSHOT.
Where I've quoted a comment, it's because the comment is the specification.
Source: CS7641 Machine Learning Prep ·
cs7641-Machine-Learning-Prep.md· updated 2026-08-06 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
CS 7641 Machine Learning — Preparedness Study Guide
Target: be able to answer Yes to all nine questions on the official OMSCS CS 7641 Machine Learning preparedness sheet.
Source: https://omscs.gatech.edu/sites/default/files/documents/2023/CS%207641-Course%20Preparedness%20Questions.pdf
Official prerequisite framing from that document: an introductory AI course, plus representational issues in AI, some AI programming, and background (or willingness to pick up background) in statistics and information theory.
Table of Contents
- The nine questions at a glance
- Eigenvectors and eigenvalues
- Singular Value Decomposition
- Conditions of a valid distance metric
- Bayes Rule
- Expectation of a random variable
- Covariance and correlation
- Search algorithms — BFS, DFS, A*
- Asymptotic analysis of search algorithms
- Programming background
- Information theory (implied by the prerequisite text)
- Reading list, mapped to questions
- Self-test with answers
- Three-week study plan
- The study plan, worked
- Week 1, Days 1–2 — Vectors, matrices, rank, null space, orthogonality
- Week 1, Days 3–4 — Eigenvalues and eigenvectors, five 2×2 and two 3×3
- Week 1, Days 5–6 — SVD by hand, end to end
- Week 1, Day 7 — PCA from scratch, two ways
- Week 2, Days 1–2 — Conditional probability and five base-rate problems
- Week 2, Days 3–4 — Expectation, variance, and the distribution table derived
- Week 2, Day 5 — Covariance by hand, then with numpy
- Week 2, Day 6 — Information gain of a decision-tree split
- Week 2, Day 7 — Bias–variance decomposition, derived
- Week 3, Days 1–2 — BFS, DFS, UCS implemented
- Week 3, Days 3–4 — A* and the node-expansion comparison
- Week 3, Day 5 — The complexity table, derived and measured
- Week 3, Days 6–7 — The scikit-learn warm-up, run
1. The nine questions at a glance
| # | Question | What "Yes" actually requires | Section |
|---|---|---|---|
| 1 | Eigenvectors / eigenvalues | Solve det(A − λI) = 0 for a 2×2 or 3×3 by hand; find the eigenvector for each λ | §2 |
| 2 | SVD | State A = UΣVᵀ, know how U, Σ, V relate to AAᵀ and AᵀA, compute a small one | §3 |
| 3 | Valid distance metric | Name all four axioms; give a non-example for each | §4 |
| 4 | Bayes Rule | Write it, derive it, apply it to a base-rate problem without falling for the base-rate fallacy | §5 |
| 5 | Expectation | Discrete and continuous definitions, linearity, LOTUS | §6 |
| 6 | Covariance / correlation | Compute both from raw data; know the population vs sample denominator | §7 |
| 7 | BFS / DFS / A* | Trace each on a graph; state admissibility and consistency | §8 |
| 8 | Asymptotic analysis of those | Reproduce the completeness / optimality / time / space table | §9 |
| 9 | Programming | Read and modify code in Python (or R / MATLAB / Java) | §10 |
Practical note: CS 7641 Machine Learning assignments are analysis-and-writeup heavy. Question 9 says "not necessarily programmed," but in practice you will be running scikit-learn, ABAGAIL-style optimization code, and plotting a lot of learning curves. Treat Python + numpy + scikit-learn + matplotlib as the real bar.
2. Eigenvectors and eigenvalues
Definition
For a square matrix A (n×n), a nonzero vector v is an eigenvector with eigenvalue λ if:
A v = λ v
Interpretation: A acts on v by pure scaling — no rotation. Eigenvectors are the invariant directions of the linear map.
How to compute
- Rearrange:
(A − λI) v = 0withv ≠ 0. - A nonzero solution exists only if
A − λIis singular, so solve the characteristic equation:
This is a degree-n polynomial in λ; its roots are the eigenvalues.det(A − λI) = 0 - For each λ, solve the null space of
(A − λI)to get the eigenvector(s). Normalize if you want unit length.
Worked example (2×2)
A = [ 4 1 ]
[ 2 3 ]
Characteristic polynomial:
det([4−λ 1 ; 2 3−λ]) = (4−λ)(3−λ) − (1)(2)
= λ² − 7λ + 12 − 2
= λ² − 7λ + 10
= (λ − 5)(λ − 2)
So λ₁ = 5, λ₂ = 2.
- λ = 5:
(A − 5I) = [−1 1; 2 −2]→−x + y = 0→v₁ = (1, 1)ᵀ - λ = 2:
(A − 2I) = [ 2 1; 2 1]→2x + y = 0→v₂ = (1, −2)ᵀ
Sanity checks:
trace(A) = 4 + 3 = 7 = 5 + 2 = Σλᵢ✅det(A) = 12 − 2 = 10 = 5 × 2 = Πλᵢ✅
Properties worth memorizing
Σ λᵢ = trace(A),Π λᵢ = det(A)Ais invertible ⟺ no eigenvalue is 0- Eigenvalues of
Aᵏareλᵏ; ofA⁻¹are1/λ - Symmetric real matrices: all eigenvalues real, eigenvectors for distinct eigenvalues are orthogonal, and
A = QΛQᵀwithQorthogonal (spectral theorem) - Positive semi-definite ⟺ all
λ ≥ 0; positive definite ⟺ allλ > 0 - Algebraic multiplicity (root multiplicity) ≥ geometric multiplicity (null-space dimension). When they differ,
Ais not diagonalizable. - Eigendecomposition requires square; SVD does not — that's why SVD is the workhorse in ML
Why CS 7641 Machine Learning cares
- PCA: eigenvectors of the covariance matrix are the principal components; eigenvalues are variance explained per component
- Spectral clustering: eigenvectors of the graph Laplacian
- Markov chains / MDPs: stationary distribution is the eigenvector of the transition matrix for λ = 1
- Optimization: eigenvalues of the Hessian tell you curvature, conditioning, and whether a critical point is a min, max, or saddle
numpy
import numpy as np
A = np.array([[4., 1.], [2., 3.]])
vals, vecs = np.linalg.eig(A) # general square
vals, vecs = np.linalg.eigh(A) # symmetric/Hermitian — faster, sorted, real
# vecs[:, i] is the eigenvector for vals[i]
3. Singular Value Decomposition
Definition
For any real m × n matrix A (square or not, full rank or not):
A = U Σ Vᵀ
U:m × morthogonal — columns are left singular vectorsΣ:m × ndiagonal, entriesσ₁ ≥ σ₂ ≥ … ≥ σᵣ > 0, rest zero — singular valuesV:n × northogonal — columns are right singular vectorsr = rank(A)= number of nonzero singular values
Thin/compact SVD: keep only the first r columns of U and V → A = U_r Σ_r V_rᵀ. This is what you almost always use.
Relationship to eigendecomposition
AᵀA = V Σᵀ Σ Vᵀ → V = eigenvectors of AᵀA, σᵢ² = eigenvalues of AᵀA
AAᵀ = U Σ Σᵀ Uᵀ → U = eigenvectors of AAᵀ, same nonzero eigenvalues
That relationship is the hand-computation recipe.
Procedure for computing by hand
- Form
AᵀA(pick whichever ofAᵀA/AAᵀis smaller). - Eigendecompose it → eigenvalues
λᵢ, eigenvectorsvᵢ. σᵢ = √λᵢ, sorted descending. Columns ofVare thevᵢ.uᵢ = A vᵢ / σᵢfor eachσᵢ > 0. Extend to a full basis with Gram–Schmidt if you need full U.
Worked example (rank-deficient)
A = [ 1 1 ]
[ 1 1 ]
AᵀA = [2 2; 2 2] → eigenvalues 4 and 0.
σ₁ = 2,σ₂ = 0→ rank 1- λ=4 eigenvector:
(1,1)ᵀ→v₁ = (1/√2, 1/√2)ᵀ - λ=0 eigenvector:
(1,−1)ᵀ→v₂ = (1/√2, −1/√2)ᵀ u₁ = A v₁ / σ₁ = (2/√2, 2/√2)ᵀ / 2 = (1/√2, 1/√2)ᵀ
So A = 2 · u₁ v₁ᵀ, a single rank-1 term. Verify: 2 · (1/√2)(1/√2) = 1 in every entry ✅
Second example (symmetric PSD)
A = [ 3 1 ]
[ 1 3 ]
Symmetric with positive eigenvalues 4 and 2, so SVD = eigendecomposition: σ = (4, 2), u₁ = v₁ = (1,1)ᵀ/√2, u₂ = v₂ = (1,−1)ᵀ/√2.
Caution: SVD equals eigendecomposition only when
Ais symmetric positive semi-definite. If a symmetric matrix has a negative eigenvalueλ, thenσ = |λ|and the sign moves intou.
Key facts
- Always exists, for every matrix. Singular values are unique; singular vectors are not (sign/rotation freedom in repeated σ).
- Eckart–Young–Mirsky: truncating to the top-k singular triplets gives the best rank-k approximation in both Frobenius and spectral norm. This is the theoretical basis for PCA, LSA, and matrix-completion recommenders.
‖A‖₂ = σ₁,‖A‖_F = √(Σ σᵢ²), condition numberκ = σ₁/σᵣ- Pseudoinverse:
A⁺ = V Σ⁺ UᵀwhereΣ⁺inverts the nonzero σ. This is how least squares is solved stably. - PCA = SVD of the mean-centered data matrix. Centering is not optional.
numpy
U, s, Vt = np.linalg.svd(A, full_matrices=False) # thin SVD; s is a 1-D array
A_k = (U[:, :k] * s[:k]) @ Vt[:k, :] # best rank-k approximation
4. Conditions of a valid distance metric
A function d: X × X → ℝ is a metric iff, for all x, y, z ∈ X:
| # | Condition | Statement |
|---|---|---|
| 1 | Non-negativity | d(x, y) ≥ 0 |
| 2 | Identity of indiscernibles | d(x, y) = 0 ⟺ x = y |
| 3 | Symmetry | d(x, y) = d(y, x) |
| 4 | Triangle inequality | d(x, z) ≤ d(x, y) + d(y, z) |
Note: (1) is implied by (2)+(3)+(4), so some texts list only three axioms. If asked, give all four — say the four and note that non-negativity is derivable.
Weakened variants (know the names)
- Pseudometric: drops "only if" in (2) — distinct points can have distance 0
- Quasimetric: drops symmetry (e.g. one-way street driving distance)
- Semimetric: drops the triangle inequality
- Divergence: only
d ≥ 0andd(x,x) = 0(e.g. KL)
Common metrics
| Metric | Formula | Metric? |
|---|---|---|
| Euclidean (L2) | √Σ(xᵢ − yᵢ)² | ✅ |
| Manhattan (L1) | `Σ | xᵢ − yᵢ |
| Chebyshev (L∞) | `maxᵢ | xᵢ − yᵢ |
| Minkowski (Lp) | `(Σ | xᵢ − yᵢ |
| Hamming | count of differing positions | ✅ |
| Jaccard distance | `1 − | A∩B |
| Mahalanobis | √((x−y)ᵀ Σ⁻¹ (x−y)) | ✅ if Σ is positive definite (pseudometric if only PSD) |
| Angular distance | arccos(cos_sim)/π | ✅ |
Non-examples — these are the exam questions
- Squared Euclidean — violates the triangle inequality. On the line:
d(0,2) = 4, butd(0,1) + d(1,2) = 1 + 1 = 2, and4 > 2. ❌ - Cosine "distance"
1 − cos θ— violates the triangle inequality. (The angular versionθ/πfixes it.) ❌ - KL divergence
D(P‖Q)— violates symmetry and the triangle inequality. It's a divergence, not a metric. Jensen–Shannon distance (√JSD) is a proper metric. ❌ - Lp with p < 1 — e.g.
p = 0.5violates the triangle inequality. ❌
Why CS 7641 Machine Learning cares
k-NN, k-means, kernel methods, and DBSCAN all assume some notion of proximity. Whether it's a true metric determines whether you can use metric-tree indexes (KD-tree, ball tree), whether triangle-inequality pruning is valid, and whether clustering guarantees hold. Also: L2 in high dimensions concentrates (the curse of dimensionality), which is why L1 or cosine often works better on high-dimensional data.
5. Bayes Rule
Statement
P(A | B) = P(B | A) · P(A) / P(B)
with P(B) > 0. In ML naming:
posterior = likelihood × prior / evidence
P(h | D) = P(D | h) · P(h) / P(D)
Derivation (one line — be able to do this)
By definition of conditional probability, P(A|B) = P(A∩B)/P(B) and P(B|A) = P(A∩B)/P(A). Therefore P(A∩B) = P(B|A)P(A), and substituting gives the rule.
Law of total probability (the denominator)
P(B) = Σᵢ P(B | Aᵢ) P(Aᵢ) for a partition {Aᵢ}
Worked example — base rate fallacy
Disease prevalence 1%. Test sensitivity P(+ | D) = 0.99. Specificity P(− | ¬D) = 0.95, so false positive rate P(+ | ¬D) = 0.05. You test positive. What is P(D | +)?
P(+) = P(+|D)P(D) + P(+|¬D)P(¬D)
= (0.99)(0.01) + (0.05)(0.99)
= 0.0099 + 0.0495
= 0.0594
P(D | +) = 0.0099 / 0.0594 = 0.1667
≈ 16.7%, not 99%. The prior dominates when the base rate is low. If you find this surprising, redo it until you don't.
ML forms you must recognize
- MAP:
h_MAP = argmax_h P(D|h)P(h)— the evidenceP(D)drops out because it doesn't depend onh - MLE:
h_MLE = argmax_h P(D|h)— MAP with a uniform prior - Naive Bayes: assume features conditionally independent given the class:
Work in log space to avoid underflow. Use Laplace smoothing for unseen feature values.P(y | x₁…xₙ) ∝ P(y) Πᵢ P(xᵢ | y) - Bayesian vs frequentist framing: Bayesians treat parameters as random variables with priors; MAP/MLE is where CS 7641 Machine Learning spends most of its time (the Bayesian Learning lectures).
Related identities
- Chain rule:
P(A,B,C) = P(A|B,C)P(B|C)P(C) - Conditional independence:
P(A,B|C) = P(A|C)P(B|C)— the assumption behind both Naive Bayes and Bayes nets - Odds form:
posterior odds = likelihood ratio × prior odds
6. Expectation of a random variable
Definitions
Discrete:
E[X] = Σₓ x · P(X = x)
Continuous:
E[X] = ∫ x · f(x) dx
LOTUS (law of the unconscious statistician) — expectation of a function, no need to derive the distribution of g(X):
E[g(X)] = Σₓ g(x) P(X = x) or ∫ g(x) f(x) dx
Properties
| Property | Statement | Requires independence? |
|---|---|---|
| Linearity | E[aX + bY + c] = aE[X] + bE[Y] + c | ❌ No |
| Product | E[XY] = E[X]E[Y] | ✅ Yes |
| Variance | Var(X) = E[X²] − (E[X])² | — |
| Scaling variance | Var(aX + b) = a² Var(X) | — |
| Sum variance | Var(X+Y) = Var(X) + Var(Y) + 2Cov(X,Y) | — |
| Tower / total expectation | `E[X] = E[E[X | Y]]` |
| Jensen | E[g(X)] ≥ g(E[X]) for convex g | — |
Linearity holding without independence is the single most-used fact in ML derivations. Know it cold.
Worked example — fair six-sided die
E[X] = (1+2+3+4+5+6)/6 = 21/6 = 3.5
E[X²] = (1+4+9+16+25+36)/6 = 91/6 ≈ 15.167
Var(X) = 91/6 − (3.5)² = 15.167 − 12.25 = 2.9167 = 35/12
SD(X) ≈ 1.708
Standard distributions — memorize these
| Distribution | E[X] | Var(X) |
|---|---|---|
| Bernoulli(p) | p | p(1−p) |
| Binomial(n,p) | np | np(1−p) |
| Geometric(p) | 1/p | (1−p)/p² |
| Poisson(λ) | λ | λ |
| Uniform(a,b) | (a+b)/2 | (b−a)²/12 |
| Exponential(λ) | 1/λ | 1/λ² |
| Normal(μ,σ²) | μ | σ² |
Why CS 7641 Machine Learning cares
- Expected loss / risk minimization is the definition of the learning objective
- Bias–variance decomposition:
E[(y − ŷ)²] = Bias² + Variance + Irreducible error— this is a pure expectation manipulation and it shows up in every assignment writeup - Value functions in reinforcement learning are expectations over trajectories:
V(s) = E[Σ γᵗ rₜ | s₀ = s] - Entropy is
E[−log p(X)]
7. Covariance and correlation
Covariance
Cov(X, Y) = E[(X − μₓ)(Y − μ_y)] = E[XY] − E[X]E[Y]
Sample estimators:
population: Cov = (1/n) Σ (xᵢ − x̄)(yᵢ − ȳ)
sample: Cov = (1/(n−1)) Σ (xᵢ − x̄)(yᵢ − ȳ) ← Bessel's correction, unbiased
Units are units-of-X × units-of-Y, so the magnitude is not interpretable on its own — that's what correlation fixes.
Pearson correlation
ρ(X, Y) = Cov(X, Y) / (σₓ σ_y) ρ ∈ [−1, 1]
Dimensionless. ρ = ±1 ⟺ perfect linear relationship.
Worked example
X = [1, 2, 3, 4] Y = [2, 4, 5, 4]
x̄ = 2.5 ȳ = 3.75
| xᵢ − x̄ | yᵢ − ȳ | product |
|---|---|---|
| −1.5 | −1.75 | 2.625 |
| −0.5 | +0.25 | −0.125 |
| +0.5 | +1.25 | 0.625 |
| +1.5 | +0.25 | 0.375 |
| Σ | 3.500 |
- Population covariance = 3.5 / 4 = 0.875
- Sample covariance = 3.5 / 3 ≈ 1.167
Var(X)_pop = 5/4 = 1.25→σₓ = 1.118Var(Y)_pop = 4.75/4 = 1.1875→σ_y = 1.090ρ = 0.875 / (1.118 × 1.090) = 0.875 / 1.2183 ≈ **0.718**
(Use the same convention — population or sample — in numerator and denominator; ρ is identical either way since the n vs n−1 cancels.)
Properties
Cov(X, X) = Var(X)- Symmetric:
Cov(X,Y) = Cov(Y,X) - Bilinear:
Cov(aX + b, cY + d) = ac · Cov(X, Y) - Independent ⟹
Cov = 0. The converse is false. Classic counterexample:X ~ Uniform(−1,1),Y = X². ThenCov(X,Y) = E[X³] − E[X]E[X²] = 0 − 0 = 0, but Y is fully determined by X. - Correlation captures linear dependence only. Use mutual information (or Spearman rank correlation) for monotone/nonlinear dependence.
- Correlation ≠ causation. Say it out loud in every assignment writeup.
Covariance matrix
For a random vector X ∈ ℝᵈ:
Σ = E[(X − μ)(X − μ)ᵀ] d × d, symmetric, positive semi-definite
Σᵢⱼ = Cov(Xᵢ, Xⱼ), Σᵢᵢ = Var(Xᵢ)
From a centered data matrix X_c (n × d): Σ̂ = X_cᵀ X_c / (n − 1).
PCA = eigendecomposition of Σ̂ = SVD of X_c. That's the through-line from §2, §3, and §7 into the unsupervised-learning half of the course.
numpy
np.cov(X, Y) # sample covariance (ddof=1 by default) — returns 2×2
np.corrcoef(X, Y) # correlation matrix
np.cov(data, rowvar=False) # d×d covariance from an n×d data matrix
8. Search algorithms — BFS, DFS, A*
The general framework
All three are the same loop with a different frontier data structure:
frontier ← {start}
explored ← {}
loop:
node ← frontier.remove()
if goal(node): return path
explored.add(node)
for child in expand(node):
if child not in explored ∪ frontier: frontier.add(child)
| Algorithm | Frontier | Expansion order |
|---|---|---|
| BFS | FIFO queue | shallowest first |
| DFS | LIFO stack | deepest first |
| UCS (Dijkstra) | priority queue on g(n) | cheapest path-so-far first |
| Greedy best-first | priority queue on h(n) | best heuristic estimate first |
| A* | priority queue on f(n) = g(n) + h(n) | best estimated total cost first |
Breadth-First Search
- Explores level by level. Finds the shallowest goal.
- Optimal only when all step costs are equal. With varying costs, use UCS.
- Complete (if
bis finite). - Space is the killer: the whole frontier lives in memory,
O(b^d). - Goal test on generation rather than expansion saves one full level of work.
Depth-First Search
- Dives to the deepest node, backtracks on dead ends.
- Not optimal, and not complete in infinite-depth or cyclic spaces (graph-search version with an explored set is complete on finite graphs).
- Space is the win:
O(bm)— only the current path plus siblings. - Iterative Deepening DFS (IDS) = DFS space with BFS optimality: repeatedly run depth-limited DFS with limit 0, 1, 2, … Re-expansion cost is negligible because the last level dominates the node count.
A*
f(n) = g(n) + h(n)
g(n): actual cost from start tonh(n): heuristic estimate of cost fromnto the goalh(goal) = 0
Admissible: h(n) ≤ h*(n) for all n — never overestimates the true remaining cost.
→ A* with tree search is optimal.
Consistent (monotonic): h(n) ≤ c(n, a, n') + h(n') for every successor n' — a triangle inequality on the heuristic.
→ A* with graph search is optimal. Consistency ⟹ admissibility (not the converse). Under consistency, f is non-decreasing along any path, and the first time you expand a node you already have its optimal g.
Optimally efficient: no other optimal algorithm using the same heuristic expands fewer nodes (up to tie-breaking).
Dominance: if h₂(n) ≥ h₁(n) for all n and both are admissible, h₂ dominates and A* expands no more nodes with h₂. A larger admissible heuristic is always at least as good.
Special cases:
h(n) = 0→ A* degenerates to UCS/Dijkstrah(n) = h*(n)→ A* walks straight to the goalg(n) = 0→ greedy best-first (fast, not optimal)
Variants worth naming: IDA* (memory-bounded), weighted A* (f = g + w·h, w > 1 — bounded suboptimality, much faster).
Trace practice
Take any small weighted graph, pick a start and a goal, and hand-trace all three, writing down the frontier contents at every step. If you can do that on a 8–10 node graph without hesitation, question 7 is a Yes.
9. Asymptotic analysis of search algorithms
Notation
b— branching factord— depth of the shallowest goalm— maximum depth of the search tree (may be ∞)C*— cost of the optimal solutionε— minimum step cost (> 0)
The table
| Algorithm | Complete? | Optimal? | Time | Space |
|---|---|---|---|---|
| BFS | Yes (finite b) | Only if step costs are uniform | O(b^d) | O(b^d) |
| Uniform-Cost Search | Yes (ε > 0) | Yes | O(b^(1 + ⌊C*/ε⌋)) | O(b^(1 + ⌊C*/ε⌋)) |
| DFS (tree search) | No | No | O(b^m) | O(bm) |
| DFS (graph search, finite space) | Yes | No | O(b^m) | O(b^m) (explored set) |
| Depth-Limited DFS (limit ℓ) | No (if ℓ < d) | No | O(b^ℓ) | O(bℓ) |
| Iterative Deepening | Yes | Only if uniform costs | O(b^d) | O(bd) |
| Bidirectional BFS | Yes | Only if uniform costs | O(b^(d/2)) | O(b^(d/2)) |
| Greedy best-first | No (tree) / Yes (graph) | No | O(b^m) worst case | O(b^m) |
| A* | Yes | Yes (admissible / consistent) | O(b^d) worst case | O(b^d) — keeps all nodes |
Points that get asked
- Why is IDS
O(b^d)and not worse despite re-expansion? Node counts are dominated by the deepest level. Total generated is(d+1)b⁰ + d·b¹ + … + 1·b^d, which isO(b^d)— a constant factor of roughlyb/(b−1)above BFS. Forb = 10, about 11% overhead. You trade that forO(bd)space instead ofO(b^d). - A's real bottleneck is memory, not time.* It stores every generated node. IDA*, RBFS, and SMA* exist to fix that.
- A time complexity with a good heuristic*: if
|h(n) − h*(n)| ≤ O(log h*(n)), growth is sub-exponential. In general it's exponential in the relative error of the heuristic. - Bidirectional search halves the exponent, which is a much bigger win than any constant-factor optimization — but it requires a well-defined predecessor function and an easily-tested goal state.
- Big-O refresher:
Ois an upper bound,Ωa lower bound,Θa tight bound. Drop constants and lower-order terms.O(b^d)withb=10, d=12is10¹²nodes — this is why heuristics matter.
10. Programming background
The question only asks whether you have worked with Python, R, MATLAB, or Java. The honest bar for succeeding in CS 7641 Machine Learning is higher. Checklist:
Python core
-
Comfortable with list/dict comprehensions,
zip,enumerate, classes, virtualenv/conda - Can read a stack trace and debug someone else's code
numpy
-
Vectorization instead of loops; broadcasting rules;
axis=semantics -
Slicing, boolean masks,
reshape,argsort,linalgmodule
pandas
-
Load CSV, handle missing values,
groupby, merge, one-hot encode
scikit-learn — this is the actual course toolkit
-
fit/predict/transformAPI andPipeline -
train_test_split,cross_val_score,GridSearchCV -
StandardScaler(fit on train only — leakage is a common assignment mistake) - Classifiers used in Assignment 1: decision trees, boosting, k-NN, SVM, neural nets
-
Unsupervised:
KMeans,GaussianMixture,PCA,FastICA,SparseRandomProjection -
learning_curveandvalidation_curve— you will generate dozens of these
matplotlib / seaborn
- Multi-series line plots with labeled axes and legends; subplots; save to file
Warm-up exercise: load a UCI dataset (e.g. Wine or Adult), build a pipeline with scaling + a decision tree, tune max_depth with GridSearchCV, and plot the learning curve and validation curve. If you can do that end-to-end in under an hour, you're ready.
11. Information theory (implied by the prerequisite text)
The preparedness doc doesn't ask a question about this, but the prerequisite paragraph explicitly names information theory, and CS 7641 Machine Learning uses it from the first decision-tree lecture onward.
Entropy — expected surprise, in bits (log base 2):
H(X) = −Σ p(x) log₂ p(x)
Maximized by the uniform distribution (H = log₂ n), zero for a deterministic variable.
Conditional entropy:
H(Y | X) = Σ p(x) H(Y | X = x)
Information gain — the decision-tree splitting criterion:
IG(Y, X) = H(Y) − H(Y | X)
Mutual information:
I(X; Y) = H(X) + H(Y) − H(X,Y) = H(Y) − H(Y|X) ≥ 0
Zero iff X and Y are independent. Unlike correlation, it captures nonlinear dependence.
KL divergence — relative entropy:
D_KL(P ‖ Q) = Σ p(x) log(p(x)/q(x)) ≥ 0
Asymmetric, no triangle inequality (see §4). Cross-entropy loss = H(P) + D_KL(P‖Q); since H(P) is fixed by the data, minimizing cross-entropy = minimizing KL.
Gini impurity — the CART alternative to entropy:
Gini = 1 − Σ p(x)²
Cheaper to compute, behaves very similarly in practice.
Worked micro-example: a node with 9 positives and 5 negatives.
H = −(9/14)log₂(9/14) − (5/14)log₂(5/14)
= −(0.643)(−0.637) − (0.357)(−1.485)
≈ 0.410 + 0.530 = 0.940 bits
12. Reading list, mapped to questions
As given in the official document
Linear algebra — either one:
- Numerical Linear Algebra — Trefethen & Bau. Read Part I (Fundamentals) through Lecture 5.
- Introduction to Linear Algebra, 4th ed. — Gilbert Strang.
Probability and statistics:
- All of Statistics — Larry Wasserman. Read Part I (Probability).
Artificial intelligence (optional):
- Artificial Intelligence: A Modern Approach — Russell & Norvig.
Which reading covers which question
| Question | Primary source |
|---|---|
| Eigenvectors/eigenvalues | Strang Ch. 6, or Trefethen Lectures 24–25 |
| SVD | Trefethen Lectures 4–5 (this is the reason Trefethen front-loads SVD), Strang Ch. 6.7 |
| Distance metrics | Trefethen Lecture 3 (norms); metric axioms are usually assumed rather than taught |
| Bayes Rule | Wasserman Ch. 1 |
| Expectation | Wasserman Ch. 3 |
| Covariance/correlation | Wasserman Ch. 3 (§3.3) |
| BFS / DFS / A* | Russell & Norvig Ch. 3 |
| Asymptotic analysis | Russell & Norvig Ch. 3 (the complexity table is §3.4–3.5) |
| Programming | scikit-learn user guide |
Useful free supplements
- 3Blue1Brown, Essence of Linear Algebra — the eigenvector and change-of-basis episodes are the fastest intuition available
- MIT 18.06 (Strang) lectures — free on OCW/YouTube
- Mitchell, Machine Learning (1997) — the course's own primary text; Ch. 3 (decision trees) and Ch. 6 (Bayesian learning) map directly onto §5 and §11 here
- Berkeley CS188 Pacman projects — hands-on BFS/DFS/UCS/A* implementation
- MacKay, Information Theory, Inference, and Learning Algorithms — free PDF, for §11
13. Self-test with answers
Do these closed-book. Answers follow each question.
1. Find the eigenvalues of [[2, 1], [1, 2]].
det([2−λ, 1; 1, 2−λ]) = (2−λ)² − 1 = λ² − 4λ + 3 = (λ−3)(λ−1). λ = 3, 1. Eigenvectors(1,1)and(1,−1).
2. What are the singular values of [[3, 0], [0, −4]]?
AᵀA = [[9,0],[0,16]], eigenvalues 16 and 9 → σ = 4, 3 (descending). Note σ is the absolute value; the sign of −4 goes into U.
3. Is d(x,y) = (x − y)² on ℝ a valid metric? Justify.
No. It fails the triangle inequality:
d(0,2) = 4 > d(0,1) + d(1,2) = 2.
4. 1 in 1000 people have a condition. A test is 99% sensitive and 98% specific. Given a positive result, what is P(condition)?
P(+) = 0.99(0.001) + 0.02(0.999) = 0.00099 + 0.01998 = 0.02097.P(D|+) = 0.00099 / 0.02097 ≈ **4.7%**.
5. X ~ Uniform(0, 10). Compute E[X] and Var(X).
E[X] = (0+10)/2 = **5**.Var(X) = (10−0)²/12 = 100/12 ≈ **8.33**.
6. If Cov(X,Y) = 0, are X and Y independent?
No. Zero covariance means no linear relationship. Counterexample:
X ~ Uniform(−1,1),Y = X².
7. Which of BFS, DFS, and A* guarantee an optimal solution, and under what conditions?
BFS: optimal only with uniform step costs. DFS: never guaranteed. A*: optimal with an admissible heuristic under tree search, and a consistent heuristic under graph search.
8. State the time and space complexity of iterative deepening and explain why the re-expansion is cheap.
Time
O(b^d), spaceO(bd). The deepest level contains the overwhelming majority of nodes, so re-generating all the shallower levels adds only a constant factor of roughlyb/(b−1).
9. h₁ and h₂ are both admissible and h₂(n) ≥ h₁(n) everywhere. Which should you use?
h₂— it dominates, so A* expands no more nodes with it. Larger admissible heuristics are strictly better.
10. A node has 6 positive and 2 negative examples. Compute its entropy.
H = −0.75 log₂ 0.75 − 0.25 log₂ 0.25 = 0.75(0.415) + 0.25(2) = 0.311 + 0.5 = **0.811 bits**.
11. Why is centering the data required before PCA?
Without centering, the first principal component points toward the data's mean rather than the direction of maximum variance.
X_cᵀX_c / (n−1)is only the covariance matrix whenX_chas zero column means.
12. Write Bayes Rule and identify each term by its ML name.
P(h|D) = P(D|h)P(h)/P(D)— posterior, likelihood, prior, evidence (marginal likelihood).
14. Three-week study plan
Assumes roughly 8–10 hours per week. Compress or stretch as needed.
Week 1 — Linear algebra (Questions 1, 2, and part of 3)
- Days 1–2: vectors, matrices, rank, null space, orthogonality. Strang Ch. 1–3 or Trefethen Lectures 1–3.
- Days 3–4: eigenvalues and eigenvectors. Compute five 2×2 and two 3×3 by hand. Watch the 3Blue1Brown eigenvector episode.
- Days 5–6: SVD. Trefethen Lectures 4–5. Compute one 2×2 by hand end to end, then verify with
np.linalg.svd. - Day 7: implement PCA from scratch with
np.linalg.eighon the covariance matrix, and again with SVD on the centered data. Confirm the components match.
Week 2 — Probability and statistics (Questions 4, 5, 6, plus information theory)
- Days 1–2: sample spaces, conditional probability, independence, Bayes Rule. Wasserman Ch. 1. Do five base-rate problems.
- Days 3–4: random variables, expectation, variance, LOTUS. Wasserman Ch. 3. Memorize the distribution table in §6.
- Day 5: covariance, correlation, covariance matrices. Compute one by hand, verify with
np.cov. - Day 6: entropy, information gain, KL divergence. Hand-compute the information gain of a decision-tree split.
- Day 7: derive the bias–variance decomposition from scratch using linearity of expectation.
Week 3 — Search, complexity, and tooling (Questions 7, 8, 9)
- Days 1–2: Russell & Norvig Ch. 3. Implement BFS, DFS, and UCS on a grid or graph.
- Days 3–4: A*, admissibility, consistency, dominance. Implement A* with Manhattan distance on a grid; compare expanded-node counts against UCS.
- Day 5: reproduce the complexity table in §9 from memory. Verify empirically by counting node expansions.
- Days 6–7: the scikit-learn warm-up from §10 — pipeline, grid search, learning curve, validation curve on a real dataset.
Final check: retake the nine questions. Every one should be a confident Yes with a worked example you can produce on demand.
15. The study plan, worked
Section 14 lists what to do. This section does it — every day of the plan, with the explanation and the finished exercise. All numeric results below were computed and verified, not estimated.
Week 1, Days 1–2 — Vectors, matrices, rank, null space, orthogonality
What it requires: be able to say what rank, null space, and orthogonality mean, and connect them to whether a system has a solution.
The four fundamental subspaces. For A of size m × n:
| Subspace | Lives in | Dimension |
|---|---|---|
Column space C(A) — all Ax | ℝᵐ | r |
Null space N(A) — all x with Ax = 0 | ℝⁿ | n − r |
Row space C(Aᵀ) | ℝⁿ | r |
Left null space N(Aᵀ) | ℝᵐ | m − r |
Rank–nullity theorem: rank(A) + dim N(A) = n. Row rank always equals column rank.
Orthogonality relations: N(A) ⊥ C(Aᵀ) and N(Aᵀ) ⊥ C(A). Those two facts are the whole geometry of least squares.
Worked: rank and null space of a rank-deficient matrix.
A = [ 1 2 3 ]
[ 2 4 6 ]
[ 1 1 1 ]
Row-reduce. R2 − 2·R1 → [0 0 0]. R3 − R1 → [0 −1 −2].
[ 1 2 3 ]
[ 0 −1 −2 ]
[ 0 0 0 ]
Two pivots → rank = 2. Nullity = 3 − 2 = 1. Solve Ax = 0: from row 2, −y − 2z = 0 → y = −2z. From row 1, x + 2(−2z) + 3z = 0 → x = z. So N(A) = span{(1, −2, 1)ᵀ}.
Verify: A(1,−2,1)ᵀ = (1−4+3, 2−8+6, 1−2+1)ᵀ = (0,0,0)ᵀ ✅
Why this matters downstream. A rank-deficient design matrix means the normal equations AᵀA x = Aᵀb have infinitely many solutions — this is exactly multicollinearity in linear regression, and it's why you either regularize (ridge adds λI, making AᵀA + λI invertible) or use the pseudoinverse from SVD.
Orthogonality and projection. The projection of b onto C(A) is p = A(AᵀA)⁻¹Aᵀb. The least-squares solution x̂ = (AᵀA)⁻¹Aᵀb is exactly the x making the residual b − Ax orthogonal to the column space. Orthonormal bases make this trivial: if Q has orthonormal columns, QᵀQ = I and the projection is just QQᵀb.
Norms (needed for §4): ‖x‖₁ = Σ|xᵢ|, ‖x‖₂ = √(Σxᵢ²), ‖x‖∞ = max|xᵢ|. Every norm induces a metric via d(x,y) = ‖x − y‖. That's the bridge between this day and the distance-metric question.
Day 1–2 checkpoint: given a 3×4 matrix, state its rank, the dimension of its null space, and whether Ax = b has zero, one, or infinitely many solutions.
Week 1, Days 3–4 — Eigenvalues and eigenvectors, five 2×2 and two 3×3
What it requires: five 2×2 by hand and two 3×3 by hand. Here they are, all verified against np.linalg.eig.
2×2 problem 1 — distinct real eigenvalues
A = [ 5 4 ] det(A−λI) = (5−λ)(2−λ) − 4 = λ² − 7λ + 6 = (λ−6)(λ−1)
[ 1 2 ]
λ = 6:[−1 4; 1 −4]→x = 4y→v = (4, 1)ᵀλ = 1:[ 4 4; 1 1]→x = −y→v = (1, −1)ᵀ- Check: trace 7 = 6+1 ✅, det 10−4 = 6 = 6·1 ✅
2×2 problem 2 — negative eigenvalues
A = [ 0 1 ] det(A−λI) = (−λ)(−3−λ) + 2 = λ² + 3λ + 2 = (λ+1)(λ+2)
[ −2 −3 ]
λ = −1:[1 1; −2 −2]→v = (1, −1)ᵀλ = −2:[2 1; −2 −1]→2x + y = 0→v = (1, −2)ᵀ- Both eigenvalues negative → this is the matrix form of a stable linear system (
x' = Axdecays).
2×2 problem 3 — complex eigenvalues
A = [ 2 −1 ] λ² − 4λ + 5 = 0 → λ = 2 ± i
[ 1 2 ]
Real matrices can have complex eigenvalues; they arrive in conjugate pairs. Geometrically this is a rotation combined with a scaling — there is no real invariant direction. |λ| = √5 is the scale factor, arg(λ) = arctan(1/2) the rotation angle. Real symmetric matrices can never do this, which is why the spectral theorem is such a strong guarantee.
2×2 problem 4 — defective (repeated eigenvalue, one eigenvector)
A = [ 3 1 ] det(A−λI) = (3−λ)² = 0 → λ = 3 (algebraic multiplicity 2)
[ 0 3 ]
(A − 3I) = [0 1; 0 0] → y = 0 → the null space is only span{(1,0)ᵀ}. Geometric multiplicity 1 < algebraic multiplicity 2 → A is not diagonalizable. numpy returns two identical eigenvector columns here, which is the numerical symptom of the same fact. This is the standard counterexample to "every matrix has a full eigenbasis," and the reason SVD (which always exists) is preferred in practice.
2×2 problem 5 — symmetric, orthogonal eigenvectors
A = [ 6 −2 ] λ² − 15λ + 50 = (λ−10)(λ−5)
[ −2 9 ]
λ = 10:[−4 −2; −2 −1]→y = −2x→v = (1, −2)ᵀλ = 5:[ 1 −2; −2 4]→x = 2y→v = (2, 1)ᵀv₁ · v₂ = 2 − 2 = 0✅ orthogonal, as the spectral theorem guarantees. Both eigenvalues positive → positive definite → this is a legitimate covariance matrix.
3×3 problem 1 — block structure
B = [ 2 0 0 ]
[ 0 3 4 ]
[ 0 4 9 ]
The top-left is a 1×1 block, so λ = 2 with eigenvector (1,0,0)ᵀ. The bottom-right 2×2 gives λ² − 12λ + (27 − 16) = λ² − 12λ + 11 = (λ−11)(λ−1). Eigenvalues: 11, 2, 1. Verified numerically. Block-diagonal matrices let you decompose the problem — worth spotting before grinding out a cubic.
3×3 problem 2 — triangular
C = [ 4 −2 1 ]
[ 0 3 −1 ]
[ 0 0 2 ]
Triangular → eigenvalues are the diagonal: 4, 3, 2. No characteristic polynomial needed.
λ = 4:(C−4I) = [0 −2 1; 0 −1 −1; 0 0 −2]→z = 0,y = 0→v = (1,0,0)ᵀλ = 3:[1 −2 1; 0 0 −1; 0 0 −1]→z = 0,x = 2y→v = (2,1,0)ᵀλ = 2:[2 −2 1; 0 1 −1; 0 0 0]→y = z,2x − 2y + z = 0→2x = y→v = (1,2,2)ᵀ- Verify:
C(1,2,2)ᵀ = (4−4+2, 6−2, 4)ᵀ = (2,4,4)ᵀ = 2·(1,2,2)ᵀ✅
Note the eigenvectors are not orthogonal here — C isn't symmetric.
Day 3–4 checkpoint: you should now be able to spot, before computing, whether a matrix will have real eigenvalues (symmetric), complex ones (rotation-like), a defective spectrum (repeated root with a rank-deficient A − λI), or free eigenvalues (triangular/block).
Week 1, Days 5–6 — SVD by hand, end to end
What it requires: one full 2-column SVD computed by hand, verified with numpy.
The example (the classic non-square case):
A = [ 3 2 ]
[ 2 3 ] (3 × 2)
[ 2 −2 ]
Step 1 — form AᵀA (2×2 is smaller than the 3×3 AAᵀ, so use it):
AᵀA = [ 9+4+4 6+6−4 ] = [ 17 8 ]
[ 6+6−4 4+9+4 ] [ 8 17 ]
Step 2 — eigendecompose it. det([17−λ, 8; 8, 17−λ]) = (17−λ)² − 64. So 17 − λ = ±8 → λ = 25, 9.
Step 3 — singular values. σ₁ = √25 = 5, σ₂ = √9 = 3. Two nonzero singular values → rank(A) = 2.
Step 4 — right singular vectors (eigenvectors of AᵀA):
λ = 25:[−8 8; 8 −8]→x = y→v₁ = (1, 1)ᵀ/√2λ = 9:[ 8 8; 8 8]→x = −y→v₂ = (1, −1)ᵀ/√2
Step 5 — left singular vectors via uᵢ = A vᵢ / σᵢ:
A v₁ = (1/√2)(3+2, 2+3, 2−2)ᵀ = (5, 5, 0)ᵀ/√2
u₁ = A v₁ / 5 = (1, 1, 0)ᵀ/√2
A v₂ = (1/√2)(3−2, 2−3, 2+2)ᵀ = (1, −1, 4)ᵀ/√2
u₂ = A v₂ / 3 = (1, −1, 4)ᵀ/(3√2)
Check ‖u₂‖ = √((1 + 1 + 16)/18) = √(18/18) = 1 ✅ and u₁ · u₂ = (1 − 1 + 0)/6 = 0 ✅
Result:
A = 5 · u₁v₁ᵀ + 3 · u₂v₂ᵀ
Numpy verification (signs may flip — that's the sign ambiguity, not an error):
sigma = [5. 3.]
U = [[-0.7071, 0.2357], [-0.7071, -0.2357], [-0.0000, 0.9428]]
Vt = [[-0.7071, -0.7071], [ 0.7071, -0.7071]]
U @ diag(s) @ Vt reproduces A exactly ✅
Note 0.2357 = 1/(3√2) and 0.9428 = 4/(3√2) — the hand computation matches column for column.
Step 6 — the truncation experiment (this is the point of SVD in ML). Best rank-1 approximation:
A₁ = 5 · u₁v₁ᵀ = [ 2.5 2.5 ]
[ 2.5 2.5 ]
[ 0.0 0.0 ]
Frobenius error ‖A − A₁‖_F = 3.0000, which is exactly σ₂. That's Eckart–Young made concrete: the error of the best rank-k approximation equals √(Σ_{i>k} σᵢ²), and here that's just σ₂. No other rank-1 matrix does better.
Day 5–6 checkpoint: given any small matrix, produce σ, U, V by hand, state the rank, write the rank-1 approximation, and predict its Frobenius error before computing it.
Week 1, Day 7 — PCA from scratch, two ways
What it requires: implement PCA via eigendecomposition of the covariance matrix and via SVD of the centered data, and confirm they agree.
Why they must agree. Let X_c be the mean-centered n × d data matrix. Then:
Σ̂ = X_cᵀ X_c / (n − 1)
Substituting the SVD X_c = UΣVᵀ:
Σ̂ = (VΣᵀUᵀ)(UΣVᵀ)/(n−1) = V (Σ²/(n−1)) Vᵀ
This is exactly the eigendecomposition of Σ̂. So:
- Principal directions = columns of
V= right singular vectors ofX_c - Eigenvalues of
Σ̂=σᵢ²/(n−1) - Scores (projected data) =
X_c V = UΣ
The code:
import numpy as np
from sklearn.datasets import load_wine
X = load_wine().data
Xc = (X - X.mean(0)) / X.std(0) # standardize: center + unit variance
# Route A: eigendecomposition of the covariance matrix
cov = np.cov(Xc, rowvar=False)
w, V = np.linalg.eigh(cov) # eigh: symmetric, returns ascending
idx = np.argsort(w)[::-1] # sort descending
w, V = w[idx], V[:, idx]
# Route B: SVD of the centered data
U, s, Vt = np.linalg.svd(Xc, full_matrices=False)
print(w[:5]) # eigenvalues
print((s**2 / (len(Xc) - 1))[:5]) # should be identical
Actual output on the Wine dataset (13 features, 178 samples):
eigendecomposition eigenvalues : [4.7324 2.5111 1.4542 0.9242 0.8580]
svd s²/(n−1) : [4.7324 2.5111 1.4542 0.9242 0.8580]
max |component_1| difference : 5.97e-16 (machine precision)
variance explained ratio : [0.3620 0.1921 0.1112 0.0707 0.0656]
They agree to floating-point noise. First two components explain 55.4% of variance; first five explain 80.2%.
Three things to internalize from this exercise:
- Standardize before PCA when features have different units. The Wine dataset has proline in the hundreds and hue near 1. Without scaling, proline alone would dominate PC1 because PCA maximizes raw variance. Centering is mandatory; scaling is a modeling choice that is almost always right for heterogeneous features.
- Prefer the SVD route numerically. Forming
XᵀXsquares the condition number.svd(Xc)works onXcdirectly and is whatsklearn.decomposition.PCAactually calls. - Eigenvalue = variance along that component. The explained-variance ratio
λᵢ/Σλⱼis what you plot in a scree plot to pickk. In CS 7641 Machine Learning Assignment 3 you will justify your choice ofkwith exactly this plot, so know what the axis means.
Week 2, Days 1–2 — Conditional probability and five base-rate problems
What it requires: five base-rate problems worked. The pattern is always the same — compute the denominator with the law of total probability, then divide.
Problem 1 — Spam filter
P(spam) = 0.4. The word "free" appears in 30% of spam and 2% of ham. An email contains "free".
P(free) = (0.30)(0.4) + (0.02)(0.6) = 0.120 + 0.012 = 0.132
P(spam | free) = 0.120 / 0.132 = 0.909
≈ 90.9%. A strong likelihood ratio (15:1) plus a near-balanced prior gives a confident posterior — contrast with Problem 2.
Problem 2 — Drug test
5% of a population use a drug. Test is 95% sensitive, 90% specific. Someone tests positive.
P(+) = (0.95)(0.05) + (0.10)(0.95) = 0.0475 + 0.0950 = 0.1425
P(user | +) = 0.0475 / 0.1425 = 1/3
33.3%. A "95% accurate" test leaves you twice as likely to be innocent as guilty, because the 10% false-positive rate applies to a population 19× larger. This is the base-rate fallacy in one line.
Problem 3 — Two machines
Machine A produces 60% of output at a 2% defect rate; machine B produces 40% at 5%. A defective item is found.
P(D) = (0.02)(0.6) + (0.05)(0.4) = 0.012 + 0.020 = 0.032
P(A | D) = 0.012 / 0.032 = 0.375 P(B | D) = 0.625
Machine B makes less output but most of the defects. Prior 60/40 flips to posterior 37.5/62.5.
Problem 4 — Monty Hall, as Bayes
You pick door 1. Host (who knows) opens door 3, revealing a goat.
Prior: P(C₁) = P(C₂) = P(C₃) = 1/3
Likelihood: P(open3 | C₁) = 1/2 (host picks freely between 2 and 3)
P(open3 | C₂) = 1 (host is forced)
P(open3 | C₃) = 0 (host won't reveal the car)
P(open3) = (1/3)(1/2) + (1/3)(1) + 0 = 1/6 + 1/3 = 1/2
P(C₁ | open3) = (1/6)/(1/2) = 1/3
P(C₂ | open3) = (1/3)/(1/2) = 2/3
Switch. The asymmetry lives entirely in the likelihood: the host's constrained behaviour when the car is behind door 2 is what carries the information.
Problem 5 — Naive Bayes, end to end
Training data, 5 documents:
| Doc | Words | Class |
|---|---|---|
| 1 | cheap, buy, now | spam |
| 2 | buy, cheap, cheap | spam |
| 3 | meeting, project, now | ham |
| 4 | project, report, meeting | ham |
| 5 | report, meeting, now | ham |
Priors: P(spam) = 2/5 = 0.4, P(ham) = 3/5 = 0.6.
Vocabulary V = {cheap, buy, now, meeting, project, report}, |V| = 6.
Token counts: spam has 6 tokens (cheap×3, buy×2, now×1); ham has 9 tokens (meeting×3, project×2, now×2, report×2).
With Laplace (add-1) smoothing, P(w|c) = (count(w,c) + 1)/(N_c + |V|):
P(cheap|spam) = 4/12 = 0.3333 P(cheap|ham) = 1/15 = 0.0667
P(buy|spam) = 3/12 = 0.2500 P(buy|ham) = 1/15 = 0.0667
P(now|spam) = 2/12 = 0.1667 P(now|ham) = 3/15 = 0.2000
Classify the new document "cheap buy now":
score(spam) = 0.4 × 0.3333 × 0.2500 × 0.1667 = 0.005556
score(ham) = 0.6 × 0.0667 × 0.0667 × 0.2000 = 0.000533
P(spam | doc) = 0.005556 / (0.005556 + 0.000533) = 0.912
Classify as spam, 91.2% posterior.
Three practical notes this exercise is meant to teach:
- Smoothing is not optional. Without add-1,
P(cheap|ham) = 0sends the entire ham score to zero — one unseen word vetoes a class. - Work in logs. With hundreds of features, the products underflow. Use
log P(c) + Σ log P(wᵢ|c)and compare log-scores. - The independence assumption is wrong and it works anyway. "cheap" and "buy" clearly co-occur. Naive Bayes gets miscalibrated probabilities but often the right
argmax, which is why it survives as a baseline.
Week 2, Days 3–4 — Expectation, variance, and the distribution table derived
What it requires: memorize the distribution table. Memorization sticks better after deriving a few, so here are the derivations.
Bernoulli(p). X ∈ {0,1}.
E[X] = 1·p + 0·(1−p) = p
E[X²] = 1²·p + 0²·(1−p) = p (since X² = X for a 0/1 variable)
Var = p − p² = p(1−p)
Maximized at p = 0.5 — a fair coin is the most uncertain Bernoulli, which is also why entropy peaks there.
Binomial(n,p). Write X = Σᵢ Xᵢ as a sum of n iid Bernoullis.
E[X] = Σ E[Xᵢ] = np (linearity — no independence needed)
Var(X) = Σ Var(Xᵢ) = np(1−p) (independence needed here)
This decomposition trick — rewrite a complicated variable as a sum of indicators — is the single most useful move in applied probability.
Geometric(p), number of trials until the first success. Condition on the first trial:
E[X] = 1 + (1−p)·E[X] → E[X](1 − (1−p)) = 1 → E[X] = 1/p
That's the tower property doing real work.
Uniform(a,b).
E[X] = ∫ₐᵇ x/(b−a) dx = (b² − a²)/(2(b−a)) = (a+b)/2
E[X²] = (b³ − a³)/(3(b−a)) = (a² + ab + b²)/3
Var = (a² + ab + b²)/3 − (a+b)²/4 = (b−a)²/12
Exponential(λ). Integrate by parts:
E[X] = ∫₀^∞ x λe^(−λx) dx = 1/λ
E[X²] = 2/λ² → Var = 2/λ² − 1/λ² = 1/λ²
Memoryless: P(X > s+t | X > s) = P(X > t).
Poisson(λ). E[X] = Var(X) = λ. Mean equals variance is the diagnostic — if your count data has variance far above the mean, you have overdispersion and Poisson is the wrong model.
The one identity to drill: Var(X) = E[X²] − (E[X])². Every derivation above is an application of it.
LOTUS in practice. To get E[X²] you never need the distribution of X². Just sum x²·p(x). Same for E[e^X], E[log X], and any loss function.
Jensen's inequality, and why it matters. For convex g, E[g(X)] ≥ g(E[X]). Consequence: the average of squared errors exceeds the square of the average error, and E[log X] ≤ log E[X] — the latter is the inequality that produces the ELBO in variational inference and the E-step bound in EM.
Day 3–4 checkpoint: state mean and variance for all seven distributions in §6 from memory, then derive any two of them cold.
Week 2, Day 5 — Covariance by hand, then with numpy
What it requires: compute one covariance matrix by hand and verify with np.cov.
Data — 4 observations, 2 features:
X = [1, 2, 3, 4] Y = [2, 4, 5, 4]
x̄ = 2.5 ȳ = 3.75
Deviations and products:
| i | xᵢ−x̄ | yᵢ−ȳ | (xᵢ−x̄)² | (yᵢ−ȳ)² | product |
|---|---|---|---|---|---|
| 1 | −1.50 | −1.75 | 2.25 | 3.0625 | 2.625 |
| 2 | −0.50 | +0.25 | 0.25 | 0.0625 | −0.125 |
| 3 | +0.50 | +1.25 | 0.25 | 1.5625 | 0.625 |
| 4 | +1.50 | +0.25 | 2.25 | 0.0625 | 0.375 |
| Σ | 5.00 | 4.75 | 3.500 |
Sample covariance matrix (divide by n−1 = 3):
[ 5.00/3 3.50/3 ] [ 1.6667 1.1667 ]
Σ̂ = [ 3.50/3 4.75/3 ] = [ 1.1667 1.5833 ]
Correlation:
ρ = 1.1667 / (√1.6667 × √1.5833) = 1.1667 / (1.2910 × 1.2583) = 1.1667 / 1.6246 = 0.718
Identical to the population-convention answer in §7, because the n vs n−1 factor cancels in the ratio.
Verification:
np.cov(X, Y) # → [[1.6667, 1.1667], [1.1667, 1.5833]]
np.corrcoef(X, Y) # → [[1.0, 0.7181], [0.7181, 1.0]]
Why n−1. The sample mean x̄ is itself estimated from the data, so deviations from it are systematically too small — dividing by n underestimates variance. Bessel's correction fixes the bias by accounting for the one degree of freedom spent on x̄. np.cov uses ddof=1 by default; np.std uses ddof=0. Mixing them silently is a classic source of wrong numbers.
The scaling trap. Covariance magnitude depends on units. Convert X from metres to centimetres and the covariance grows 100×, while ρ doesn't move. This is exactly why PCA on unstandardized heterogeneous features is meaningless, and it connects Day 5 back to Day 7 of Week 1.
Week 2, Day 6 — Information gain of a decision-tree split
What it requires: hand-compute the information gain of a split. Using the canonical 14-example PlayTennis dataset (9 positive, 5 negative), all values verified numerically.
Root entropy:
H(S) = −(9/14)log₂(9/14) − (5/14)log₂(5/14)
= −(0.6429)(−0.6374) − (0.3571)(−1.4854)
= 0.4098 + 0.5305 = 0.9403 bits
Candidate split: Outlook.
| Value | Pos | Neg | n | Entropy |
|---|---|---|---|---|
| Sunny | 2 | 3 | 5 | −0.4log₂0.4 − 0.6log₂0.6 = 0.9710 |
| Overcast | 4 | 0 | 4 | 0 (pure) |
| Rain | 3 | 2 | 5 | 0.9710 |
H(S | Outlook) = (5/14)(0.9710) + (4/14)(0) + (5/14)(0.9710) = 0.6935
IG(Outlook) = 0.9403 − 0.6935 = 0.2467 bits
All four candidates:
| Attribute | Partition (pos, neg) | H(S given A) | Information gain |
|---|---|---|---|
| Outlook | (2,3) (4,0) (3,2) | 0.6935 | 0.2467 ← winner |
| Humidity | (3,4) (6,1) | 0.7885 | 0.1518 |
| Wind | (6,2) (3,3) | 0.8922 | 0.0481 |
| Temperature | (2,2) (4,2) (3,1) | 0.9111 | 0.0292 |
ID3 splits on Outlook at the root, then recurses on the Sunny and Rain branches. Overcast is already pure and becomes a leaf immediately.
Five things this computation is teaching:
- Entropy of a pure node is 0; of a 50/50 node is 1 bit. Those are the anchors — if a hand computation falls outside
[0, 1]for a binary target, you made an arithmetic error. - Information gain is always ≥ 0. Conditioning never increases expected entropy. If you compute a negative IG, recheck the weighting.
- The weights matter.
H(S|A)is a weighted average by branch size, not a plain average. Overcast's zero entropy only helps in proportion to its 4/14 share. - IG is biased toward high-cardinality attributes. An ID column would split every example into its own pure branch and score maximum gain while generalizing not at all. Gain ratio (C4.5) divides by the split's intrinsic information
−Σ (|Sᵥ|/|S|) log₂(|Sᵥ|/|S|)to correct this. Expect this as an exam question. - Gini gives nearly the same tree. For the root,
Gini = 1 − (9/14)² − (5/14)² = 1 − 0.4133 − 0.1276 = 0.4592. It's cheaper (no logarithm) and ranks splits almost identically — which is whysklearndefaults to it and why the criterion choice rarely changes your Assignment 1 conclusions.
Week 2, Day 7 — Bias–variance decomposition, derived
What it requires: derive it from scratch using linearity of expectation. This is the most quoted result in the course and the one most often recited without understanding.
Setup. True relationship y = f(x) + ε where E[ε] = 0 and Var(ε) = σ². We fit f̂(x) on a random training set D; f̂ is therefore itself random. Fix a test point x.
Goal: decompose E[(y − f̂(x))²], where the expectation is over both the noise ε and the draw of D.
Step 1 — insert and subtract f(x):
E[(y − f̂)²] = E[(f + ε − f̂)²]
= E[(f − f̂)²] + 2·E[ε(f − f̂)] + E[ε²]
Step 2 — kill the cross term. ε is independent of the training set, and E[ε] = 0:
E[ε(f − f̂)] = E[ε] · E[f − f̂] = 0
And E[ε²] = Var(ε) + (E[ε])² = σ². So:
E[(y − f̂)²] = E[(f − f̂)²] + σ²
Step 3 — decompose the remaining term. Let f̄ = E[f̂], the average prediction over all possible training sets. Insert and subtract it:
E[(f − f̂)²] = E[(f − f̄ + f̄ − f̂)²]
= (f − f̄)² + 2(f − f̄)·E[f̄ − f̂] + E[(f̄ − f̂)²]
The middle term vanishes because E[f̄ − f̂] = f̄ − E[f̂] = 0, and (f − f̄) is a constant with respect to D.
Result:
E[(y − f̂(x))²] = (f(x) − E[f̂(x)])² + E[(f̂(x) − E[f̂(x)])²] + σ²
= Bias² + Variance + Irreducible
Reading the three terms:
| Term | Meaning | Cause | Fix |
|---|---|---|---|
| Bias² | How far the average model is from the truth | Model too simple / wrong hypothesis class | More capacity, better features, less regularization |
| Variance | How much the model wobbles across training sets | Model too flexible relative to n | More data, regularization, bagging, less capacity |
| σ² | Label noise | Inherent | Nothing — this is your error floor |
Diagnosing from curves — the practical payoff.
- Learning curve (error vs training-set size): high train error and high validation error converging to a plateau → high bias. Low train error with a persistent large gap that shrinks as
ngrows → high variance. - Validation curve (error vs model complexity): train error falls monotonically; validation error is U-shaped. The minimum is the bias–variance sweet spot.
How this maps to specific algorithms in the course:
- Decision tree depth ↑ → bias ↓, variance ↑
- k-NN
k↑ → bias ↑, variance ↓ (k=1is minimum bias, maximum variance) - SVM
C↑ → bias ↓, variance ↑ - Neural net width/epochs ↑ → bias ↓, variance ↑
- Bagging / random forests attack variance by averaging decorrelated high-variance learners; bias stays roughly constant
- Boosting attacks bias by sequentially fitting the residuals of weak (high-bias) learners
Caveat worth knowing. The classic U-shaped curve is not universal — very overparameterized models can show "double descent," where test error falls again past the interpolation threshold. The decomposition above still holds; the assumption that variance grows monotonically with parameter count is what breaks.
Week 3, Days 1–2 — BFS, DFS, UCS implemented
What it requires: implement all three on a grid and observe the behavioural differences.
The test environment: a 10×10 grid, start (0,0), goal (9,9), 4-connected moves, unit costs, with two walls forming a snake — a vertical wall at x=3 for y ∈ [0,6] and another at x=6 for y ∈ [3,9]. That leaves 86 reachable cells and forces a detour, so a naive heuristic can't just walk diagonally.
Shared skeleton. All three algorithms are the same loop; only the frontier structure changes.
def search(start, goal, frontier_pop, frontier_push):
frontier = init([start]); came = {start: None}; explored = set()
while frontier:
node = frontier_pop()
if node in explored: continue
explored.add(node)
if node == goal: return reconstruct(came, goal)
for child in neighbors(node):
if child not in explored:
came[child] = node
frontier_push(child)
return None
- BFS —
collections.deque,popleft(). Mark nodes as seen when generated, not when expanded, or you duplicate work. - DFS — a plain list,
pop(). Same code, different end of the list. That one-character difference is the whole algorithm. - UCS —
heapqkeyed ong(n). Requires the "skip if already expanded" guard because a node can sit in the heap multiple times with different costs.
Measured results:
| Algorithm | Nodes expanded | Path length | Optimal? |
|---|---|---|---|
| BFS | 86 | 28 | ✅ |
| DFS | 44 | 36 | ❌ |
| UCS | 86 | 28 | ✅ |
What the numbers are telling you:
- BFS and UCS are identical here. With uniform step costs,
g(n)= depth, so the priority queue orders nodes exactly the way the FIFO queue does. UCS only earns its extra machinery when edge costs vary. - DFS expanded fewer nodes but returned a worse path — 36 steps instead of 28. It got lucky on effort and unlucky on quality. Change the neighbour ordering and both numbers move unpredictably. DFS offers no guarantees about either.
- BFS and UCS expanded all 86 reachable cells because the goal is in the far corner — the frontier had to sweep the entire space. This is the
O(b^d)space problem made concrete: for a real problem withb=10, d=12, "sweep everything shallower than the goal" means 10¹² nodes. - DFS's real advantage is memory, not expansions. Its frontier held at most a few dozen nodes along one path; BFS's frontier held a growing fringe.
The bug to watch for: in graph search, add nodes to the explored set (or a "seen" set) so cycles don't cause infinite loops. Tree search without an explored set will loop forever on a grid, since (0,0) → (1,0) → (0,0) is a legal path.
Week 3, Days 3–4 — A* and the node-expansion comparison
What it requires: implement A* with Manhattan distance and compare expansions against UCS.
Implementation. Identical to UCS except the priority key:
heapq.heappush(frontier, (g[child] + h(child), g[child], child))
# ^^^^^^^^^^^^^^^^^^ f(n) = g(n) + h(n)
The second tuple element (g) is a tie-breaker; without it Python tries to compare the coordinate tuples, which works but breaks the moment your states aren't orderable.
Measured results on the same grid:
| Configuration | Heuristic | Expanded | Path | Admissible? |
|---|---|---|---|---|
| UCS | h = 0 | 86 | 28 | — |
| A* | Euclidean √(Δx²+Δy²) | 77 | 28 | ✅ |
| A* | Manhattan ` | Δx | + | Δy |
| Weighted A* | 2 × Manhattan | 59 | 28 | ❌ |
Reading the table — this is the entire lesson of A:*
h = 0reduces A to UCS exactly.* 86 expansions, identical behaviour. A* is not a different algorithm; it's UCS with an informed priority.- Manhattan dominates Euclidean on a 4-connected grid. Both are admissible (neither can overestimate when diagonal moves are illegal), but Manhattan is always ≥ Euclidean, so by the dominance theorem it expands no more nodes — 74 vs 77, confirmed. Rule: among admissible heuristics, always pick the largest.
- The savings are real but modest here (86 → 74, about 14%) because the walls make the Manhattan estimate badly wrong in the detour region — the heuristic doesn't know about obstacles. On an open grid the same heuristic would cut expansions dramatically. Heuristic quality, not heuristic existence, drives the win.
- Weighted A (
w=2) expanded only 59 nodes* — a 31% saving over UCS — and on this particular map still happened to find the 28-step path. But2hcan overestimate, so the optimality guarantee is gone; weighted A* only promises a solution within a factorwof optimal. Getting the optimal answer once is not evidence the guarantee holds.
Admissibility vs consistency, checked concretely. Manhattan distance on a unit-cost grid is consistent: moving one step changes h by at most 1, and the step cost is exactly 1, so h(n) ≤ c(n,n') + h(n') always holds. That's why the graph-search version above is safe to write with a simple "skip if expanded" guard. With an admissible-but-inconsistent heuristic, you would need to reopen closed nodes when a cheaper path to them is discovered.
Constructing admissible heuristics — the standard recipe. Relax the problem. For the 8-puzzle:
- Relax "a tile can move anywhere" → misplaced-tile count
h₁ - Relax "a tile can move to any adjacent square, occupied or not" → Manhattan distance
h₂ h₂ ≥ h₁everywhere, soh₂dominates. Any exact solution to a relaxed problem is an admissible heuristic for the original — that's the general theorem.max(h₁, h₂, …)of several admissible heuristics is admissible and dominates all of them.
Week 3, Day 5 — The complexity table, derived and measured
What it requires: reproduce §9's table from memory, then verify empirically. Here is where each entry comes from.
Counting nodes in a uniform tree. A tree with branching factor b has bᵏ nodes at depth k. Levels 0 through d total:
1 + b + b² + … + b^d = (b^(d+1) − 1)/(b − 1) = O(b^d)
The last level dominates; every complexity entry in the table is a variation on this sum.
- BFS
O(b^d)time and space. It expands every node shallower than the goal, and the frontier at depthdholdsO(b^d)nodes simultaneously. - DFS
O(b^m)time,O(bm)space. Worst case it explores the entire tree to depthm. But it only stores the current path (mnodes) plus the unexpanded siblings at each level (b−1each) →O(bm). That's linear, not exponential. This is DFS's entire reason to exist. - UCS
O(b^(1+⌊C*/ε⌋)). UCS expands nodes in order ofg, so it processes everything with cost< C*. With minimum step costε, the effective depth is⌊C*/ε⌋. When all costs equal 1, this collapses toO(b^d)and UCS = BFS. The+1accounts for UCS expanding the goal only after popping it, one level later than BFS. - IDS
O(b^d)time,O(bd)space. Derived below. - Bidirectional
O(b^(d/2)). Two frontiers of depthd/2meeting in the middle:2b^(d/2)≪b^d. Forb=10, d=12, that's2×10⁶instead of10¹²— six orders of magnitude, far more than any constant-factor optimization can buy.
The IDS re-expansion question, measured. IDS re-runs depth-limited DFS at limits 0, 1, …, d. Nodes at depth i are generated (d + 1 − i) times:
IDS nodes = (d+1)b⁰ + (d)b¹ + (d−1)b² + … + 1·b^d
BFS nodes = b⁰ + b¹ + … + b^d
Computed for b = 10, d = 5:
BFS generated : 111,111
IDS generated : 123,456
ratio : 1.111×
11% overhead to trade O(b^d) space for O(bd) space. As b grows the overhead shrinks toward b/(b−1); at b=2 it's 2×, still only a constant. The intuition: the deepest level is generated once, and it contains more nodes than every shallower level combined.
The measured completeness/optimality claims from the grid experiment above:
| Claim in the table | Observed |
|---|---|
| BFS optimal under uniform costs | 28-step path ✅ |
| DFS not optimal | 36-step path ✅ |
| UCS optimal | 28-step path ✅ |
A* optimal with admissible h | 28 steps with both Euclidean and Manhattan ✅ |
Dominant admissible h expands fewer nodes | Manhattan 74 < Euclidean 77 ✅ |
h = 0 ⟹ A* = UCS | both 86 ✅ |
Day 5 checkpoint: write out the nine-row table from memory, then justify each O(·) in one sentence.
Week 3, Days 6–7 — The scikit-learn warm-up, run
What it requires: load a dataset, build a scaling + decision-tree pipeline, tune with GridSearchCV, and plot learning and validation curves. Here is the exercise completed on the Wine dataset (178 samples, 13 features, 3 classes).
The code:
from sklearn.datasets import load_wine
from sklearn.model_selection import (train_test_split, GridSearchCV,
learning_curve, validation_curve, StratifiedKFold)
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeClassifier
X, y = load_wine(return_X_y=True)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3,
random_state=42, stratify=y)
pipe = Pipeline([("scale", StandardScaler()),
("clf", DecisionTreeClassifier(random_state=42))])
grid = {"clf__max_depth": [1, 2, 3, 4, 5, 6, 8, 10, None],
"clf__min_samples_leaf": [1, 2, 5, 10],
"clf__criterion": ["gini", "entropy"]}
cv = StratifiedKFold(5, shuffle=True, random_state=42)
gs = GridSearchCV(pipe, grid, cv=cv, scoring="accuracy", n_jobs=-1).fit(Xtr, ytr)
Results:
best params : criterion='gini', max_depth=3, min_samples_leaf=1
best CV accuracy : 0.8950
held-out test : 0.9630
Learning curve (fixed at the best estimator, 5-fold CV):
| Training size | Train acc | CV acc | Gap |
|---|---|---|---|
| 9 | 1.000 | 0.606 | 0.394 |
| 27 | 1.000 | 0.830 | 0.170 |
| 45 | 0.996 | 0.838 | 0.158 |
| 63 | 0.990 | 0.895 | 0.095 |
| 81 | 1.000 | 0.855 | 0.145 |
| 99 | 0.994 | 0.903 | 0.091 |
Reading it: train accuracy pinned near 1.0 while CV accuracy climbs and the gap narrows from 0.39 to 0.09 — the signature of a variance-limited model. The curves are still converging at n=99, so more data would help. If the two curves had flattened together at a mediocre value, that would be bias instead, and more data would be wasted effort. Note the non-monotonic dip at n=81: with 178 samples the CV estimates are noisy, so read the trend, not individual points.
Validation curve over max_depth:
| max_depth | Train acc | CV acc | Gap |
|---|---|---|---|
| 1 | 0.673 | 0.646 | 0.028 |
| 2 | 0.944 | 0.871 | 0.073 |
| 3 | 0.992 | 0.895 | 0.097 |
| 4 | 0.998 | 0.878 | 0.120 |
| 5 | 1.000 | 0.878 | 0.122 |
| 6 | 1.000 | 0.878 | 0.122 |
| 8+ | 1.000 | 0.878 | 0.122 |
Reading it — this is the bias–variance derivation from Week 2 Day 7, made visible:
- Depth 1 is underfitting. Train 0.673, CV 0.646, tiny gap. One split cannot separate three classes. High bias, low variance.
- Depth 3 is the sweet spot. CV accuracy peaks at 0.895.
- Depth 4+ is overfitting. Train hits 1.000 while CV drops to 0.878 and the gap widens to 0.122.
- Curves flatten past depth 6 because the tree stops growing — with 124 training samples and clean class structure, it achieves purity around depth 5 and further depth allowance is inert. Not every hyperparameter range produces movement; say so in your writeup rather than pretending the plateau is a finding.
Five habits this exercise is drilling — all of which are graded in CS 7641 Machine Learning:
- Scale inside the
Pipeline, never before the split.StandardScalerfitted on the full dataset leaks test-set statistics into training. Inside a pipeline,GridSearchCVrefits the scaler on each CV fold correctly. Data leakage is the most common silent error in Assignment 1. - Stratify the split.
stratify=ypreserves class proportions. On a 3-class dataset of 178 samples an unstratified split can badly skew a fold. - Never tune on the test set. Select with cross-validation on the training data; touch the held-out set exactly once, at the end. Here CV said 0.895 and test said 0.963 — the test set happened to be easy, which is precisely why you don't tune against it.
- Report the gap, not just accuracy. The train–CV gap is your empirical variance estimate and the thing your analysis should discuss.
- Fix
random_stateeverywhere. Splits, CV shuffling, and the estimator. Your results must reproduce, and grading rewards being able to explain a number rather than regenerate a different one.
Extension for the last day: swap DecisionTreeClassifier for KNeighborsClassifier, SVC, MLPClassifier, and AdaBoostClassifier, keeping the pipeline and curve code identical. That is structurally the whole of Assignment 1 — five learners, two datasets, learning and validation curves, and an analysis of what each curve says about bias and variance. If you can do it now, you start the course ahead.
Final self-assessment
Go back to the nine questions in §1. For each one, you should be able to produce, without notes:
- A characteristic polynomial and its eigenvectors ✅ (§15 W1 D3–4 — seven worked matrices)
- A full SVD by hand plus its rank-1 truncation error ✅ (§15 W1 D5–6)
- All four metric axioms and a counterexample for each ✅ (§4)
- A base-rate posterior computed correctly ✅ (§15 W2 D1–2 — five problems)
- Mean and variance of seven standard distributions, derived ✅ (§15 W2 D3–4)
- A covariance matrix by hand, with the right denominator ✅ (§15 W2 D5)
- BFS, DFS, and A* traced and implemented ✅ (§15 W3 D1–4)
- The complexity table with a justification per row ✅ (§15 W3 D5)
- An end-to-end scikit-learn pipeline with tuned hyperparameters and diagnostic curves ✅ (§15 W3 D6–7)
That's nine Yes answers, each backed by something you've actually done rather than read.
Source: Team Lead & Staff Engineer ·
TeamLeadStaffEngineer.md· updated 2026-08-03 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Team Lead & Staff Engineer Interview Prep Plan
Table of Contents
Part I — Interview Mechanics
- What Actually Gets Scored
- Self-Assessment: Find Your Gaps First
- The 8-Week Plan
- Track A — Coding
- Track B — System Design (Core)
- Track C — System Design (Your Specialization Edge)
- Track D — Architecture Deep Dive on Past Work
- Track E — Leadership & Behavioral
- Track F — The Rounds People Don't Prepare For
- Hard Loops: Company-by-Company Breakdown
- Above-and-Beyond Differentiators
- Questions to Ask Directors
- Mock Interview Schedule
- Resource List
Part II — Deep Technical Domains
- AI, GenAI, LLM & Agentic Systems
- 15.1 What the 2026 loop tests · 15.2 Model fundamentals · 15.3 Retrieval & RAG · 15.4 Agentic systems · 15.5 Evaluation · 15.6 Serving & inference · 15.7 Cost engineering · 15.8 Safety & governance · 15.9 Classical ML · 15.10 Design questions
- Context, Prompt & Graph Engineering
- 16.1 The distinction · 16.2 Context pipeline · 16.3 Window management · 16.4 Prompting as engineering · 16.5 Graph engineering & GraphRAG
- Advanced Backend: Streaming, Reactive, APIs
- 17.1 Kafka & Flink · 17.2 Java reactive, Reactor, WebFlux, virtual threads · 17.3 GraphQL & Netflix DGS · 17.4 gRPC & binary protocols · 17.5 API decision matrix · 17.6 Distributed transactions
- Real-Time Delivery, CDN, Caching & Edge
- 18.1 WebSockets · 18.2 SSE · 18.3 HTTP caching · 18.4 CDN architecture · 18.5 Application cache layers
- Compute: Serverless, Containers, Kubernetes, OS & Kernel
- 19.1 Serverless · 19.2 Containers & kernel primitives · 19.3 Kubernetes · 19.4 OS & kernel fundamentals
- Storage, Scaling & Data Platform
- 20.1 DynamoDB · 20.2 Cassandra/Scylla · 20.3 MongoDB · 20.4 OpenSearch · 20.5 Relational at scale · 20.6 Lakehouse · 20.7 Decision table
- Security: OAuth, Zero Trust, AppSec & Network
- 21.1 OAuth/OIDC · 21.2 Zero Trust · 21.3 AppSec · 21.4 Vulnerability management & patching · 21.5 SDN/SD-WAN/SASE · 21.6 Compliance
- Frontend & Full-Stack for Leads
- 22.1 React · 22.2 State layers, Redux, thunk vs saga · 22.3 Cross-cutting frontend architecture
- The Team Leadership Operating Playbook
- 23.1 Unblocking · 23.2 Growing people · 23.3 PR review · 23.4 Planning & scheduling · 23.5 Product partnership · 23.6 Promotions & retention · 23.7 Hiring · 23.8 Communication · 23.9 Metrics
- AI as a Cross-Cutting Force
- 24.1 Leadership frame · 24.2 Layer by layer · 24.3 Questions to have answers to
- Rapid-Fire Drill Bank
- Revised Study Calendar (12 Weeks)
- Final Calibration
Part III — Gap Closure
- Observability & SRE (Full Depth)
- 28.1 SLO engineering & burn-rate alerting · 28.2 OpenTelemetry & the three pillars · 28.3 Chaos engineering · 28.4 Load & performance testing · 28.5 Incident management
- Architecture Discipline: DDD, Team Topologies & Decision-Making
- 29.1 Domain-Driven Design · 29.2 Microservices vs modular monolith · 29.3 Team Topologies · 29.4 Decision machinery (DACI, ADRs, C4, one-way doors)
- Delivery Engineering: CI/CD, Testing Strategy & Experimentation
- 30.1 Trunk-based development · 30.2 Feature flags · 30.3 Testing strategy & contract testing · 30.4 Release engineering · 30.5 Experimentation platform
- Standards & Frameworks Reference Card
- DORA · SPACE · DX Core 4 · DevEx · SRE · Well-Architected · 12-Factor · FinOps · Team Topologies · SLSA · NIST CSF · NIST AI RMF/ISO 42001 · WCAG · C4
- Multi-Region, DR & Business Continuity
- The Offer Stage: Closing Above and Beyond
- Updated Weekly Integration
Part IV — Patterns, Contracts, and the Human Layer
- Design Patterns & Code Architecture
- 35.1 SOLID with mature caveats · 35.2 The GoF subset that appears · 35.3 Enterprise patterns (Repository, Unit of Work, ACL) · 35.4 Hexagonal/Clean, CQRS, anti-patterns, refactoring vocabulary · 35.5 How it shows up in interviews
- SLA vs SLO vs SLI — the Contractual Layer
- SLA remedies & fine print · dependency SLA composition · OLAs · per-tenant SLAs · STAR ↔ SCOR mapping
- The Question Playbook, by Interviewer
- 37.1 Recruiter · 37.2 Hiring manager · 37.3 Peers & future reports · 37.4 Director/skip · 37.5 VP/CTO · 37.6 Product partner · 37.7 Bar raiser · 37.8 Universal closers · 37.9 Reverse due-diligence red flags
- Signals of Seasonality — the Unasked-For Essentials
- 38.1 Executive communication (BLUF) · 38.2 Handling unknowns · 38.3 Scar-tissue stories · 38.4 "It depends" done right · 38.5 Whiteboard & remote mechanics · 38.6 Operator literacies (budget, vendors, glue work, former peers, legacy) · 38.7 Follow-up etiquette · 38.8 Drills 53–60
- Deployment & Progressive Delivery (Consolidated Deep Dive)
- 39.1 Strategy matrix (rolling, blue/green, canary, shadow, rings) · 39.2 Traffic-shifting mechanics · 39.3 Automated canary analysis · 39.4 Canary vs A/B · 39.5 State, data & rollback · 39.6 The pipeline as a system · 39.7 Drills 61–66
Part V — Expanded Reference (full depth: code, case studies, primary sources)
- Distributed Systems & Performance Foundations
- 40.1 Latency numbers, Little's Law, queueing, USL, tail-at-scale, benchmarking discipline · 40.2 Paxos, Raft, ZAB — mechanics and production use · 40.3 Replication, consistency models, time (TrueTime/HLC), CRDTs · 40.4 Consistent hashing, jump/Maglev/rendezvous, range partitioning, resharding
- LLM Serving, Inference & the Economics of Tokens
- 41.1 Prefill vs decode · 41.2 KV cache math, GQA, PagedAttention, prefix caching · 41.3 Continuous batching + scheduler implementation · 41.4 FlashAttention, speculative decoding, quantization, parallelism · 41.5 Serving stacks · 41.6 Cost engineering with worked math · 41.7 LLM observability
- Architecture Patterns, Fully Worked
- 42.1 CQRS — the adoption ladder, Level 1 & Level 4 code, projections, the five eventual-consistency fixes, TAO/Venice/Netflix case studies · 42.2 Event Sourcing — event store schema, optimistic concurrency, snapshots, upcasting, crypto-shredding for GDPR, why Kafka isn't an event store · 42.3 Sagas — orchestration vs choreography, compensation code, Temporal/Cadence/Step Functions · 42.4 Transactional Outbox — SQL, Debezium config, polling vs CDC · 42.5 Repository & Hexagonal, concretely
- Worked System Design Answers
- 43.1 Distributed rate limiter (full: algorithms table, Redis Lua, two-tier lease architecture, Stripe/Cloudflare/Envoy references, follow-ups) · 43.2 RAG with document-level access control (full: filtered-ANN recall problem, two-layer authz, contextual chunking, RRF, eval suite, degradation matrix)
- Worked Design Answers — AI & LLM Systems
- 44.1 LLM gateway (routing, escalation, two-tier caching, failover, chargeback) · 44.2 Multilingual semantic search (index topology, language-specific pitfalls, vector scaling math, eval without judgments) · 44.3 Agent platform with sandboxed tools (four security gates, lethal trifecta, loop termination) · 44.4 Eval pipeline gating CI · 44.5 Cost attribution across 40 teams
- Worked Design Answers — The Classics
- News feed · Notifications · Job scheduler · Metrics pipeline · Ad click aggregation · Payments ledger · Ride dispatch · Object storage/file sync · Ticketing under contention · Multi-region active-active KV
- Storage Engines & Databases, Expanded
- 46.1 LSM vs B-tree, compaction strategies, tombstones, RocksDB tuning · 46.2 Postgres MVCC, vacuum, XID wraparound, index types, reading query plans, PgBouncer · 46.3 DynamoDB single-table design · 46.4 Choosing a database
- Retrieval, Search & Ranking, Expanded
- 47.1 Inverted index, Lucene segments, BM25 derivation · 47.2 HNSW/IVF-PQ/DiskANN comparison · 47.3 Multi-stage ranking funnel, LTR, features · 47.4 NDCG/MRR, interleaving, position bias, IPS
- Leadership Scenarios — Full Worked Answers
- Toxic reviewer · Missed commitments · PM overcommits · Impossible date · Duplicate teams · AI review-queue debt · Reliability budget to a VP · Not-ready promotion · Post-reorg morale · Down-leveled offer
- Complete Drill Bank — Answer Key — all 66 drills answered
- Closing Note on Using This Document
- Gap Register & Expansion Queue
Part VI — The Engineering Management Workshop
- Team Architecture & Organizational Design
- 52.1 The Spotify model and why it failed (squads/tribes/chapters/guilds, the four failure modes, three ways to answer) · 52.2 Alternatives compared: two-pizza + STL, Team Topologies, handbook-first, context-not-control, Shape Up, feature crews · 52.3 Microservices & cloud native as org decisions
- Sizing, Estimation & Assigning Work
- 53.1 Planning poker, t-shirt, affinity, GitLab weights, Shape Up appetite, Monte Carlo forecasting · 53.2 The Goodhart critique of story points · 53.3 Breaking down an epic — full worked example · 53.4 Assigning work: skill matrix, bus factor, glue work
- Jira & Azure DevOps — Making the Tool Tell the Truth
- 54.1 Hierarchy, DoR/DoD · 54.2 Config practices per tool (workflows, components, WIP limits, automation, area vs iteration paths, branch policies) · 54.3 Metrics to read and to refuse
- The GitLab Workshop
- Handbook-first · DRI · Iteration & MVC · weights, milestones, async defaults · the honest critique
- Google, Microsoft, Amazon & Netflix — What Each Proved
- Project Oxygen & Project Aristotle · two-pizza/STL/PR-FAQ/six-pagers/bar raiser · stack-ranking reversal & 1ES · context-not-control, informed captain, keeper test
- Management Workshop — Interview Q&A, Multiple Framings
- Seven core questions, each answered three ways: screen / depth / story
- Workshop Drills (67–86)
Part VII — Answers to Everything Left Open
- Questions to Ask — What You're Listening For
- Every question from §12 and §37 with: why it works · the green flag · the red flag · your follow-up · what you do with the answer. Covers hiring manager, director/skip-level, peers and future reports, VP/CTO, the universal closers, and the reverse due-diligence probe table.
- Stated-But-Unexplained — The Answers
- 60.1 Agent determinism & replay (full trace schema, three replay levels, what you lose without it, three-register answer) · 60.2 Idempotency & effectively-once · 60.3 Blast radius & cells · 60.4 Backpressure vs shedding vs admission control · 60.5 Feature stores & point-in-time correctness · 60.6 Sticky sessions starving the canary · 60.7 HNSW delete degradation · 60.8 Composition over inheritance
1. What Actually Gets Scored
Senior interviews test whether you can solve a problem. Staff and TL interviews test whether you can pick the problem, frame it, and get other people to solve it with you. Different bar, different prep.
Shared signals (Staff and TL)
| Signal | What "senior" looks like | What "staff/lead" looks like |
|---|---|---|
| Scope | Owns a service | Owns a problem space across teams |
| Ambiguity | Given a spec, executes | Given a vague goal, produces the spec |
| Judgment | Picks a good option | Explains the 3 options, why the others lose, and the reversal criteria |
| Influence | Convinces their team | Convinces peers/leads who don't report to them |
| Multiplier | Delivers | Others got faster because of them |
| Risk | Handles known risk | Names the risk nobody else named |
Track-specific
Team Lead adds: performance conversations, hiring bar, delivery predictability, morale under pressure, stakeholder/PM negotiation, headcount and prioritization tradeoffs.
Staff adds: technical strategy over 12–24 months, cross-org standard-setting, deprecation/migration leadership, design review authority, "make the hard call in a room of senior people."
The single most common failure
Candidates tell senior-level stories in a staff-level interview. The story is about what they built, not how the org changed. Fix this in Track E before anything else.
2. Self-Assessment: Find Your Gaps First
Do this in one sitting, week 0. Score 1–5, honestly.
Coding
- Can I solve a medium in 20 min, clean, with tests, while talking?
- Can I do it in a shared editor with no autocomplete?
- Have I done a hard graph/DP problem in the last 30 days?
System design
- Can I do capacity math out loud without freezing (QPS, storage, bandwidth, memory)?
- Can I name 3 consistency models and when each is acceptable?
- Can I design for multi-region without hand-waving the write path?
- Can I draw and defend a rollback/migration strategy?
Deep dive
- Do I have a 45-min narrative of my hardest project with numbers, alternatives, and regrets?
- Can I whiteboard my current system from memory at 3 zoom levels?
Leadership
- Do I have 10 stories that cover conflict, failure, influence, mentoring, and prioritization?
- Can I tell any of them in 3 minutes without rambling?
- For TL: can I role-play a performance conversation without getting soft or harsh?
Anything scored 1–3 gets calendar time in the plan below. Anything at 4–5, don't over-study it.
3. The 8-Week Plan
Assumes ~12–15 hrs/week. Compress to 5 weeks by dropping Weeks 3 and 6 and doubling volume.
Week 1 — Foundation + story mining
- Build the story portfolio (Track E). This is the long pole; start it first.
- Fundamentals refresh: latency numbers, CAP/PACELC, quorum math, consistent hashing.
- 10 coding problems (easy/medium warm-up, arrays, hashmaps, two pointers).
- Write your deep-dive narrative outline (Track C).
Week 2 — Design core + coding volume
- 5 classic system designs, written out (rate limiter, URL shortener, notification system, distributed job scheduler, metrics pipeline).
- 15 coding problems: trees, graphs, BFS/DFS, heaps.
- Rewrite 3 stories from Week 1 in the SCOR format (Section 8).
Week 3 — Storage + streaming internals
- Deep dive: LSM vs B-tree, Kafka internals, Cassandra/DynamoDB partitioning, Postgres MVCC and replication.
- 3 designs: news feed, chat/messaging, ad click aggregation.
- 12 coding problems: intervals, binary search, sliding window.
- First mock: system design, external interviewer.
Week 4 — Reliability, scale, multi-region
- Topics: cell-based architecture, blast radius, circuit breakers, backpressure, graceful degradation, SLO/error budgets, idempotency and the outbox pattern.
- 3 designs: payments ledger, ride-hailing dispatch, multi-region active-active KV store.
- 12 coding problems: DP, backtracking, tries.
- Mock: behavioral/leadership.
Week 5 — Your specialization (the differentiator)
- Full Track C content: search, retrieval, ranking, RAG, agentic platforms, vector index scaling.
- Write 2 designs you could teach: "multilingual semantic search at 10K QPS" and "enterprise RAG with eval harness."
- 10 coding problems, mixed hard.
- Mock: deep dive on your own past work.
Week 6 — Leadership under pressure
- Role-play drills: underperformer, two senior engineers in conflict, PM pushing scope, director wants a date you can't commit to.
- Write a real design doc (5 pages) and a written critique of someone else's.
- Practice the "design doc review" and "code review" round formats.
- Mock: people-management round (TL) or cross-org influence round (Staff).
Week 7 — Company-specific
- Pick your top 3 targets. Study their loop format (Section 10), eng blog, open source, public architecture talks.
- One full-loop simulation in a single day: 2 coding + 1 design + 1 behavioral, back to back, no breaks. This tests stamina, which is what actually breaks people.
- Prepare per-company question lists and 30/60/90 plans.
Week 8 — Polish and taper
- Re-run only your weakest two rounds.
- Trim every story to 3 minutes.
- Light coding daily (2 problems, timed) — maintenance, not growth.
- Sleep. Do not cram new material in the last 3 days; it costs recall on the material you already have.
4. Track A — Coding
Staff and TL loops still have coding rounds at Google, Meta, Stripe, Databricks, Uber, and most AI labs. The bar isn't harder problems — it's cleaner code, better tests, and no flailing.
Volume target
- ~70–90 problems total, not 400. Depth over count.
- Distribution: 20% easy (warm-up/speed), 65% medium, 15% hard.
Pattern checklist (don't move on until each is automatic)
- Hash map / frequency counting
- Two pointers, sliding window
- Binary search — including "search on the answer"
- BFS / DFS / topological sort
- Union-find
- Heaps and top-K
- Intervals (merge, sweep line)
- Prefix sums and difference arrays
- Backtracking with pruning
- DP: 1D, 2D, knapsack, LIS, edit distance
- Tries
- Monotonic stack
Staff-specific coding expectations
- Talk about the API before the algorithm. Name types, name the contract.
- Write tests unprompted. Even 3 assertions. This is a differentiator at every level above senior.
- State complexity before coding, verify after.
- Handle the "make it production" follow-up: what breaks at 100× input, where does it go concurrent, what's the failure mode.
- Don't over-abstract. Staff candidates lose points for building a factory when a function was asked for.
Drill format
Timed, 35 min, out loud, in a plain editor. Record yourself once a week and watch it. Painful, effective.
5. Track B — System Design (Core)
5.1 Fundamentals you must have memorized
Latency numbers (order of magnitude is enough):
- L1 ~1 ns, main memory ~100 ns, SSD random read ~100 µs, network round trip same DC ~0.5 ms, cross-region ~50–150 ms, disk seek ~10 ms.
Capacity math you should do in 60 seconds:
- DAU → QPS (peak ≈ 3–5× average)
- Bytes/record × records/day → storage/year
- Working set → cache size → node count
- Read:write ratio → replication and sharding decision
Distributed systems core:
- CAP, and why PACELC is the more useful framing
- Consistency models: linearizable, sequential, causal, read-your-writes, eventual
- Quorum: R + W > N, and what sloppy quorums buy you
- Consensus: Raft leader election, log replication, why you don't want consensus in the hot path
- Consistent hashing + virtual nodes; when range partitioning beats it
- Idempotency keys, exactly-once as "at-least-once + dedupe"
- Outbox pattern, CDC, dual-write problem
- Backpressure, load shedding, admission control
- Rate limiting: token bucket, leaky bucket, sliding window counter, distributed rate limiting
Storage internals:
- LSM tree (write amplification, compaction, bloom filters) vs B-tree
- Kafka: partitions, ISR, consumer groups, rebalancing, retention, compaction
- DynamoDB/Cassandra: partition key design, hot partitions, LWT cost
- Postgres: MVCC, vacuum, index types, logical replication
- Object storage: S3 consistency, multipart, lifecycle, why it's the default data lake
Reliability:
- SLI/SLO/error budget, and how error budget changes release policy
- Circuit breaker, bulkhead, timeout + retry with jitter, retry storms
- Graceful degradation tiers (what do you turn off first)
- Blast radius and cell-based architecture
- Blue/green, canary, shadow traffic, feature flags, dark launch
Migration and rollout — heavily weighted at staff level, rarely prepared:
- Dual-write + backfill + read-verify + cutover + cleanup
- Strangler fig pattern
- Reversibility: what's the rollback at each phase
- How do you prove correctness during migration (shadow compare, sampled diffing)
5.2 Classic designs to write out (not just read)
- Distributed rate limiter
- Notification/fan-out system
- News feed (push vs pull vs hybrid)
- Chat / messaging with delivery guarantees
- Distributed job scheduler with exactly-once semantics
- Metrics/observability pipeline (ingest → aggregate → query)
- Ad click aggregation (dedupe, late events, watermarks)
- Payments ledger with idempotency and double-entry
- Ride-hailing dispatch (geo-indexing, matching)
- Object storage / file sync (Dropbox-style)
- Multi-region active-active KV store
- Ticketing/inventory with contention (Ticketmaster-style)
5.3 The staff-level design interview structure
Most candidates do requirements → boxes → done. Do this instead:
- Clarify and scope (5 min). Who's the user, what's the SLA, what's out of scope. Write the non-functional requirements as numbers.
- Capacity math (3 min). Out loud. This alone separates candidates.
- API contract (3 min). Before any boxes.
- Data model + partitioning key (5 min). The partition key choice is where most designs live or die.
- High-level architecture (10 min).
- Deep dive on the hardest 1–2 components (15 min). Ask the interviewer which one they want.
- Failure modes, degradation, operations (7 min). What pages, what's the runbook, what's the blast radius.
- Tradeoffs and what you'd do differently at 10× (5 min).
Say out loud, at least twice: "The alternative here was X; I'm not choosing it because Y; if Z changed I'd revisit." That sentence is the staff signal.
6. Track C — System Design (Your Specialization Edge)
Search, retrieval, ranking, and agentic systems are your moat. Most interviewers can't go deep here, which means you control the room — but only if you've rehearsed it.
6.1 Search & retrieval
- Inverted index internals, segment merges, refresh vs flush, near-real-time search
- OpenSearch/Elasticsearch: shard sizing math, replica strategy, hot/warm/cold tiers, index lifecycle, alias-based blue/green reindex
- Query understanding: tokenization, analyzers per language, stemming vs lemmatization, CJK segmentation, transliteration
- Multilingual: shared vs per-language index, cross-lingual embeddings, language detection failure modes, script normalization
- Relevance: BM25, learning-to-rank, hybrid (lexical + dense) with reciprocal rank fusion
- Evaluation: NDCG, MRR, recall@k, offline/online correlation, judgment collection
6.2 Vector search
- HNSW: graph structure,
MandefConstruction/efSearchtradeoffs, memory formula (roughlyd × 4 bytes × Nfor float32 plus graph overhead), why deletes are expensive - IVF-PQ vs HNSW: memory vs recall vs build time
- Billion-scale: sharding by ID vs by cluster, routing, replica fan-out, cold start
- Filtered vector search: pre-filter vs post-filter and the recall cliff
- Index rebuild strategy without downtime
6.3 Recommendation systems
- Multi-stage: candidate generation → light ranker → heavy ranker → re-ranking/diversity/business rules
- Feature store: online/offline parity, point-in-time correctness, training/serving skew
- Embedding freshness, two-tower architectures, ANN retrieval for candidates
- Cold start (user and item), exploration vs exploitation, bandits
- Feedback loops and popularity bias — name this unprompted, it's a strong signal
- Evaluation: offline replay, interleaving, A/B tests, guardrail metrics, novelty/diversity metrics
6.4 RAG and LLM systems
- Chunking strategies and why fixed-size is usually wrong
- Hybrid retrieval + cross-encoder reranking, latency budget allocation
- Context assembly, citation grounding, hallucination guardrails
- Eval harness: golden set, LLM-as-judge with its failure modes, regression gates in CI
- Serving: KV cache, continuous batching, speculative decoding, quantization tradeoffs
- Cost model: tokens per request × price, caching layers, model routing (cheap model first, escalate)
6.5 Agentic platforms
- Tool routing and schema design, why tool descriptions are the real prompt
- Sandboxing and permission boundaries for tool execution
- Multi-step orchestration, retries, and loop-termination guarantees
- Observability: trace per step, token/cost attribution, replay
- Eval: task-level success rate, not per-step accuracy
- Failure containment: what does the agent do when the tool is down or the output is malformed
6.6 Designs to have ready to whiteboard
- Multilingual semantic search over 500M docs, 10K QPS, p99 < 200 ms
- Personalized recommendation serving with a 50 ms budget
- Enterprise RAG with document-level access control
- Online A/B testing and interleaving platform
- LLM gateway with routing, caching, rate limiting, and cost attribution
- Agent execution platform with sandboxed tools and full tracing
7. Track D — Architecture Deep Dive on Past Work
Usually 45–60 minutes on a system you built. Underprepared by almost everyone, and it's the round directors weight most heavily.
Prepare three zoom levels of your primary system
- 30 seconds: what it does, who uses it, what it's worth.
- 5 minutes: the architecture, the two hardest problems, the outcome with numbers.
- 45 minutes: component-level detail, data flow, failure modes, the incident, the migration, the thing you'd redo.
The checklist your narrative must hit
- What was the business problem and how was success measured
- What were the constraints (latency, cost, team size, deadline, legacy)
- Which alternatives you seriously evaluated and the specific reason each lost
- What you got wrong and what it cost
- What you'd change with what you know now
- Where you were the decision-maker vs where you influenced someone else's decision
Numbers to have memorized
QPS, p50/p99, data volume, cost, team size, timeline, and the delta on the metric you moved. If you don't have exact numbers, use defensible estimates and say they're estimates.
The trap
Interviewers will poke at a design decision to see if you get defensive. The winning move: "That's a fair criticism — here's the constraint that drove it, and here's what I'd need to change to do it your way." Confidence without brittleness.
8. Track E — Leadership & Behavioral
8.1 Build a story portfolio, not answers to questions
Write 10–12 stories. Tag each with the competencies it covers. In the interview you map the question to a story, not to a memorized answer.
Stories you need:
- The hardest technical decision you owned
- A decision you reversed
- A project that failed or got cancelled
- Conflict with a peer or another team
- Disagreement with your manager or a director
- Influencing without authority across teams
- Mentoring someone from struggling to strong
- Prioritization: what you chose not to do and why
- A major incident you led
- A migration or deprecation you drove
- Delivering under an unreasonable deadline
- Tech debt vs feature pressure
Team Lead adds: 13. Handling an underperformer 14. A hiring decision (including one you got wrong) 15. Managing team morale through a bad quarter 16. Pushing back on a stakeholder's scope or timeline
8.2 Use SCOR, not STAR
STAR under-serves staff-level stories because it has no slot for judgment.
- S — Situation: context and constraints, 20 seconds
- C — Complication: why it was actually hard, what made it non-obvious
- O — Options: the 2–3 real alternatives and why each lost
- R — Result + Reflection: the number, and what you'd do differently
The O is the whole game. It's what makes a story sound like judgment instead of a resume bullet.
8.3 Calibration rules
- 3 minutes per story. Time yourself. Nobody has ever lost an offer for being too concise.
- Use "I" for your decisions and "we" for team execution. Getting this ratio wrong reads as either credit-stealing or passenger.
- Every story needs one number.
- If a story has no failure or regret in it, it isn't a staff story.
8.4 Team Lead role-play drills (practice out loud, with a person)
- Your strongest engineer is toxic in code review. Handle it.
- An engineer has missed three commitments in a row. Run the conversation.
- A PM promises a date to a customer without asking you. Respond.
- A director asks you to cut testing to hit a date. Respond.
- Two senior engineers deadlocked on a framework choice, and it's blocking the sprint.
- You inherit a team with low morale after a reorg. First 30 days?
For each: state your first move, the information you'd gather, and the line you wouldn't cross. That three-part structure makes vague answers concrete.
8.5 Staff-specific influence drills
- Two teams are building the same thing. Neither wants to stop. You have no authority over either.
- You believe the org's chosen architecture is wrong. You've been overruled once already.
- You need three teams to adopt a standard that makes their next quarter slower.
9. Track F — The Rounds People Don't Prepare For
- Design doc review. Given a 4-page doc, critique it live. Practice: write one, then critique a real RFC (Rust RFCs, Kubernetes KEPs, Kafka KIPs are all public and excellent training material).
- Code review round. Given a PR, find correctness, security, and maintainability issues. Practice on open-source PRs. Prioritize your comments — blocking vs nit — that prioritization is the signal.
- Debugging round. A repo with failing tests; find and fix. Practice: clone something unfamiliar, break it, fix it under time.
- Incident simulation. Live "the graphs look like this, what now." Practice the loop: stop the bleeding → mitigate → diagnose → communicate → postmortem. Say "I'd mitigate before I diagnose" out loud; many candidates skip straight to root cause.
- Take-home + presentation. Scope small, ship clean, write a README with tradeoffs and what you'd do with more time. The README is what gets graded at staff level.
- Product/business sense. "How would you prioritize this roadmap?" Have an opinion about your users and your metrics.
10. Hard Loops: Company-by-Company Breakdown
Formats shift; verify with your recruiter. This is the shape as of the most recent public reporting.
Google — Staff (L6) / TLM
- 2 coding rounds (still LeetCode-style, medium-hard), 1–2 system design, 1 "Googleyness & Leadership."
- L6 requires demonstrated cross-team scope. Hiring committee reads a packet — your interviewers must be able to write down your scope. Give them quotable lines.
- Design rounds go deep on data modeling and failure handling.
- Prep weight: coding 30%, design 40%, leadership 30%.
Meta — E6 / Engineering Manager
- IC: 2 coding (two problems per 45-min round — speed matters), 1 system design, 1 "Jedi" behavioral.
- EM: system design, people management, project retrospective, sometimes coding.
- E6 needs org-level impact stories. "I built X" is E5. "I changed how three teams do X" is E6.
- Prep weight: coding speed is the top failure cause; drill 2-problems-in-45-min explicitly.
Amazon — Principal (L7) / SDM
- Leadership Principles dominate. Expect 10–16 LP questions across the loop, plus a Bar Raiser.
- Every LP answer needs data. "Dive Deep" means they will ask for the number three layers down.
- Also: system design, and for L7 a "technical vision" discussion.
- Prep weight: build 2 stories per LP, 16 LPs. This is the single most story-intensive loop in the industry.
Stripe
- Integration round (build against a real API), debugging round (fix a broken repo), system design, and a values/behavioral round.
- Practical over algorithmic. Your editor fluency and debugging method are visible.
- Prep weight: debugging and pragmatic coding 50%.
Netflix — Senior/Staff
- Culture-memo alignment is a real filter: context not control, freedom and responsibility, "keeper test."
- Senior-heavy org, so expect very deep technical conversation and expect to justify autonomy.
- Prep weight: deep dive 40%, culture 30%, design 30%.
Databricks / Snowflake / Confluent
- Hard distributed systems internals plus genuinely hard coding.
- Expect questions on query execution, storage formats, consistency, and concurrency.
- Prep weight: storage/streaming internals 50%.
Uber / Lyft / DoorDash
- High-scale system design with real domain constraints (geo, matching, dispatch, pricing).
- Coding is medium-hard, design is the differentiator.
Shopify (Toronto)
- "Life Story" interview — a structured walk through your career. Prepare a narrative arc, not a list of jobs.
- Pair programming in a real repo, technical deep dive.
OpenAI / Anthropic / AI labs
- Practical coding, often agentic (use the tools, work in a real codebase), take-home with a follow-up deep dive, and a strong emphasis on judgment and safety-mindedness.
- They test whether you can move fast in unfamiliar code. Practice: contribute a real fix to an open-source repo you've never seen, timed.
Nvidia / Cisco / systems companies
- Performance, concurrency, memory, and profiling. Expect C++/systems questions if the role is close to the metal.
11. Above-and-Beyond Differentiators
These are what make a director remember you.
- Bring an artifact. A one-page architecture diagram or a design doc you can share (sanitized). Almost nobody does this.
- Write a 30/60/90 plan for their team and send it after the onsite. For a TL role this is close to decisive. Structure: first 30 = listen and map (name what you'd read, who you'd meet, what you'd measure), 60 = one visible fix, 90 = a strategy proposal.
- Know their public engineering output. Their eng blog, conference talks, open source. Reference it specifically: "In your post on X you mentioned Y — how did that land?"
- Speak in operational metrics. p99, error budget, DORA metrics, cost per query, on-call load. Leads who talk about cost and toil sound senior instantly.
- Name your own weaknesses before they find them. "The gap in my background is Z; here's how I'd close it in the first quarter."
- Have a technical opinion. Not a hot take — a defended position on something in their domain. Directors hire people with a point of view.
- Send a follow-up that adds value, not thanks. One paragraph continuing the design discussion, or the diagram you sketched, cleaned up.
- Prepare a diagnostic question set — the questions you'd ask the team in week one. Sharing this signals you already think like the lead.
12. Questions to Ask Directors
Skip the generic ones. These probe real things and signal seniority.
About the role
- What does success look like at 6 months, and who decides?
- Is this role backfilling someone, or is it new scope? What happened to the previous lead?
- What's the split between technical direction and people management you actually expect?
About the team
- What's the team's biggest source of toil right now?
- How much of the roadmap is committed vs discretionary?
- Where does the team's on-call pain come from?
About the org
- How do technical decisions that span teams get made here?
- What's the last significant architectural decision, and how was it reached?
- What's the thing about this org you'd change if you could?
About you as a manager (ask the director)
- How do you like to be disagreed with?
- What's your escalation threshold — when do you want me to bring you in?
- What did the last person who succeeded in a role like this do differently?
The high-leverage closer
- "Is there anything in my background that gives you hesitation? I'd rather address it now than leave it open."
13. Mock Interview Schedule
Real mocks with real humans, not solo practice. Minimum 6 before your first real onsite.
| Week | Mock type | Source |
|---|---|---|
| 3 | System design | Paid platform or a senior peer |
| 4 | Behavioral/leadership | Peer who's a manager |
| 5 | Deep dive on your own work | Someone outside your domain |
| 6 | People-management role-play (TL) or influence round (Staff) | Manager friend |
| 7 | Full loop, one day | Mixed |
| 8 | Weakest round, repeat | Best available |
Rules: record everything. Watch at 1.5×. Note filler words, rambling, and the moment you lost the interviewer. Fix one thing per mock.
14. Resource List
System design
- Designing Data-Intensive Applications — Kleppmann (chapters 5–9 are the core)
- Database Internals — Petrov (for storage-engine depth)
- Company engineering blogs: Uber, Netflix, Meta, Discord, Cloudflare, Stripe
- Public design docs: Kubernetes KEPs, Kafka KIPs, Rust RFCs (for the design-review round)
Coding
- NeetCode 150 for pattern coverage
- Timed sessions in a plain editor, not an IDE
Leadership
- The Staff Engineer's Path — Reilly (the definitive staff-scope framing)
- Staff Engineer — Larson
- An Elegant Puzzle — Larson (for TL/EM org questions)
- The Manager's Path — Fournier (TL chapter specifically)
Your domain
- HNSW paper (Malkov & Yashunin), FAISS docs
- Elasticsearch/OpenSearch internals docs on shards, segments, and lifecycle
- Recent RAG and agent evaluation literature — pick 5 papers and know them well enough to critique
PART II — Deep Technical Domains
15. AI, GenAI, LLM & Agentic Systems
15.1 What the 2026 loop actually tests
The content shifted hard. Classical ML theory (backprop, CNNs, gradient descent) is now maybe a quarter of technical rounds; the rest is RAG architecture, evaluation, agents, and production concerns. The four things a modern loop probes that a 2022 loop never did:
- System design on top of a model you don't control
- Judgment about retrieval and evaluation
- Whether you can build and debug the integration by hand
- Production sense: cost, latency, and the quiet ways these systems fail
Definitions are free now. Judgment is what's being bought.
15.2 Model fundamentals you must hold cold
- Transformer: attention (Q/K/V), multi-head, positional encoding (RoPE, ALiBi), why context length is quadratic in attention and what mitigates it (FlashAttention, sliding window, sparse attention)
- Decoder-only vs encoder-decoder vs encoder-only (and why BERT-family still wins for reranking)
- Tokenization: BPE, why token counts differ across languages (directly relevant to multilingual work — non-Latin scripts can cost 2–4× the tokens)
- Prefill vs decode: prefill is compute-bound, decode is memory-bandwidth-bound. This single distinction explains most serving architecture.
- KV cache: size formula ≈
2 × layers × heads × head_dim × seq_len × batch × bytes_per_param. Know that it, not weights, is what actually limits your batch size. - MoE: sparse activation, expert routing, why parameter count ≠ compute cost
- Fine-tuning ladder: prompting → few-shot → RAG → LoRA/QLoRA → full SFT → preference tuning (RLHF, DPO). Know the cost and the "when."
- Quantization: FP16/BF16 → FP8 → INT8 → INT4; GPTQ, AWQ, GGUF. Quality/latency/memory tradeoffs.
- Distillation: use production traffic from a large model to train a small one. The best cost lever nobody mentions.
- Sampling: temperature, top-p, top-k, min-p, repetition penalty, and why greedy isn't always right
- Structured output: JSON mode, constrained decoding / grammar-based sampling, tool-call schemas
15.3 Retrieval & RAG (beyond the basics)
Chunking
- Fixed-size is the default and usually wrong. Know: recursive character splitting, semantic chunking, structure-aware (headings, code blocks), parent-document retrieval, late chunking (embed the full doc, then pool per chunk), contextual retrieval (prepend an LLM-generated summary of the doc to each chunk)
- Overlap tradeoff: recall vs index bloat vs duplicate context
Retrieval
- Hybrid: BM25 + dense, fused with Reciprocal Rank Fusion (
1/(k+rank), k≈60). Know why RRF beats score normalization. - Query transformation: rewriting, decomposition into sub-queries, HyDE (embed a hypothetical answer), step-back prompting
- Multi-hop retrieval and when it's needed vs when it's just latency
- Late interaction (ColBERT) — better recall, much higher index cost
- Access-control-aware retrieval: filter at query time, never post-filter (post-filtering leaks existence and destroys recall)
Reranking
- Cross-encoder rerankers on top-k (k=50–200) from first-stage retrieval
- LLM rerankers: better, slower, expensive; use for the top 10
- The latency budget conversation: retrieval 30 ms, rerank 80 ms, generation 800 ms — know where your budget actually goes
Failure modes to name unprompted
- Retrieval succeeded, generation ignored it (groundedness failure)
- Retrieval failed silently and the model confabulated confidently
- Chunk boundaries split the answer
- Stale index vs live source of truth
- "Lost in the middle" — relevant context in the middle of a long window gets underweighted
15.4 Agentic systems
Patterns
- ReAct (reason → act → observe loop), Plan-and-Execute (decompose upfront, then run), Reflexion (self-critique and retry), router/dispatcher, supervisor with sub-agents, handoff/swarm
- Single agent with good tools beats multi-agent in most production cases. Have the opinion; it's a maturity signal.
Architecture components
- Planner, tool registry, executor, memory store, trace/observability layer
- MCP (Model Context Protocol) as the emerging standard for tool exposure — know it, know that tool descriptions are prompt surface area
Failure modes to design against (name these in the interview)
- Tool misuse → strict input validation, sandboxed execution
- Infinite loops → max iterations, total token budget, cycle detection
- Prompt injection via tool output → sanitize before re-injection, treat all tool output as untrusted
- Hallucinated tool calls → validate against the registered schema, reject and retry
- Scope creep / unintended side effects → permission scoping per tool, human-in-the-loop for destructive actions
- Non-idempotent retries → idempotency keys on every side-effecting tool, compensating actions for rollback
Memory
- Short-term (conversation buffer), long-term (vector or structured store), episodic (past task traces), semantic (facts/entities)
- Compaction and summarization strategies; when to drop vs summarize vs offload to a sub-agent
Determinism and debuggability
- Full trace per step: inputs, tool calls, outputs, tokens, cost, model version
- Replay capability — you cannot debug agents without it
- Checkpointing for long-running workflows
15.5 Evaluation (the round most candidates fail)
- Separate retrieval eval from generation eval. Recall@k and NDCG for retrieval; groundedness/faithfulness and answer relevance for generation. Conflating them is the classic mistake.
- Golden set: 100–500 hand-labeled examples covering the head and the ugly tail. Build it before you build the system.
- LLM-as-judge: cheap and scalable, but know its biases — position bias, verbosity bias, self-preference bias. Mitigate with pairwise comparison, randomized order, and rubric-based scoring with explicit criteria.
- Pointwise vs pairwise vs reference-based grading; when each is appropriate
- Agent eval is task-level success rate, not per-step accuracy. Also: trajectory efficiency (steps to completion), cost per successful task.
- Regression gates in CI: eval suite runs on prompt/model/retrieval changes; block merge on regression beyond a threshold
- Online: A/B, user thumbs, escalation rate, task abandonment
- Red teaming: jailbreaks, injection, PII leakage, refusal calibration
- Drift: model version changes underneath you. Pin versions, re-run evals on every provider update.
15.6 Serving & inference infrastructure
- Continuous (in-flight) batching — the core throughput lever
- PagedAttention / vLLM: KV cache paging, prefix caching for shared system prompts
- Speculative decoding: draft model + verification
- Tensor parallelism (within a node) vs pipeline parallelism (across nodes); when you need either
- Metrics that matter: TTFT (time to first token), TPOT (time per output token), throughput (tokens/sec), GPU utilization, queue depth
- GPU memory math: weights + KV cache + activations + fragmentation. Know how to size a deployment.
- Autoscaling GPUs: cold start is minutes, not seconds. Provisioned floor + queue + graceful degradation.
- Serving stacks: vLLM, TGI, SGLang, TensorRT-LLM; managed (Bedrock, Vertex, Azure OpenAI)
- Common design question: "Design an inference batching system for a single GPU handling up to 100 synchronous requests, maximizing utilization under latency constraints." Practice this one specifically — it's asked verbatim at multiple labs.
15.7 Cost engineering
- Unit economics: cost per request = (input tokens × input price) + (output tokens × output price), amortized over cache hit rate
- Levers, ranked by impact: prompt caching → model routing (cheap first, escalate on low confidence) → context trimming → semantic caching → distillation → batch API
- Track cost per feature, per team, per customer. Attribution is a real engineering problem.
- Budget guardrails: per-tenant token quotas, circuit breakers on spend, alerting on cost-per-request drift
- The lead-level version: token spend is now a line item leadership asks about. Being fluent here differentiates you immediately.
15.8 Safety, guardrails, governance
- OWASP LLM Top 10: prompt injection, insecure output handling, training data poisoning, model DoS, supply chain, sensitive info disclosure, insecure plugin design, excessive agency, overreliance, model theft
- Direct vs indirect prompt injection. Indirect (via retrieved docs, tool output, web content) is the dangerous one and the one interviewers probe.
- Output handling: treat model output as untrusted input. Never eval it, never pass it to a shell, escape before rendering.
- PII: detection and redaction on ingress and egress; data residency for model calls
- Excessive agency: scope tool permissions to the minimum; require confirmation for irreversible actions
- Governance: model cards, approved-model registry, audit logging of prompts and outputs, retention policy
- Regulatory awareness: EU AI Act risk tiers, sector rules (this matters a lot in legal/financial domains)
15.9 Classical ML you still need
- Feature engineering, leakage (especially temporal leakage), point-in-time correctness
- Class imbalance, calibration (Platt, isotonic), threshold selection by business cost
- Metrics: precision/recall/F1, AUC-ROC vs AUC-PR (PR for imbalanced), NDCG for ranking
- Training/serving skew, feature store as the fix
- Model lifecycle: registry, versioning, shadow deploy, canary, rollback
- Drift: data drift vs concept drift, detection (PSI, KS test), retraining triggers
15.10 AI design questions to rehearse
- Design a RAG system for customer support with document-level access control
- Design an LLM gateway: routing, caching, rate limiting, cost attribution, failover across providers
- Design an inference batching system maximizing GPU utilization under a latency SLA
- Design an agent platform with sandboxed tool execution and full replay
- Design an evaluation pipeline that gates prompt and model changes in CI
- Design a multilingual semantic search system with LLM-based query understanding
- Design a system to detect and mitigate hallucination in production
- Design cost attribution for LLM spend across 40 teams
16. Context, Prompt & Graph Engineering
16.1 The distinction that matters
Prompt engineering is how you ask. Context engineering is what the model knows, sees, and remembers at the moment it acts. The 2026 framing: prompt engineering is table stakes; context engineering is the multiplier. Prompts control interaction design (format, tone, reasoning strategy, decomposition). Context controls knowledge infrastructure (definitions, entity relationships, access policies, lineage, freshness).
The symptom that means you've outgrown prompting: your prompt template has grown past ~2,000 tokens of business rules and exceptions, and each new edge case adds a paragraph. That's knowledge encoded in the wrong place.
16.2 The context pipeline
- Source curation — which systems are authoritative, and what's explicitly stale
- Retrieval strategy — how the right slice reaches the model at the right time
- Ranking — freshness, authority, proximity to the task
- Compression — summarization, extraction, dropping the irrelevant
- Assembly — ordering (put critical instructions at the start and end, not the middle), delimiters, structure
- Feedback — measure what context was actually used and prune what wasn't
16.3 Context window management
- Budget the window explicitly: system instructions / tools / retrieved context / history / output reserve. Write the budget down.
- "Lost in the middle" — recall degrades for content in the middle of long contexts. Order matters more than most people assume.
- Context rot: more context is not better. Past a point, added context reduces accuracy.
- Compaction strategies: rolling summarization, hierarchical summarization, structured state extraction (keep a JSON state object rather than raw history)
- Sub-agent isolation: give each sub-agent only the slice it needs. Prevents context pollution and cuts cost.
- Tool output truncation with a "fetch more" affordance rather than dumping everything
16.4 Prompt engineering as an engineering discipline
- Version prompts in git, not in a database field
- Prompts get tests. A prompt change without an eval run is an unreviewed deploy.
- Techniques worth knowing precisely: few-shot (and how example selection matters more than example count), chain-of-thought, self-consistency (sample n, majority vote), decomposition, role assignment, output schema constraints
- Anti-patterns: negative instructions ("don't do X" underperforms "do Y"), stacked contradictory rules, examples that conflict with instructions
- Prompt injection defense at the prompt layer is weak. Defense belongs at the architecture layer.
16.5 Graph engineering
When a graph beats a vector index
- Multi-hop reasoning ("which customers are affected by an outage in the service that depends on X")
- Relationship-heavy domains: org structures, supply chains, citations, entitlements, fraud rings
- When explanation matters — a traversal path is auditable, a cosine similarity isn't
Core concepts
- Property graph (Neo4j, Cypher) vs RDF/triple store (SPARQL); Gremlin/TinkerPop as the portable traversal language
- Entity resolution and deduplication — the hardest part of any real knowledge graph
- Ontology/schema design, and the governance problem of who owns it
- Traversal cost, supernode problem, index-free adjacency
- Graph databases at scale: Neo4j, Amazon Neptune, TigerGraph, JanusGraph; graph queries on top of relational (recursive CTEs) when the graph is small
GraphRAG
- Build a graph from documents (entity + relation extraction with an LLM), cluster into communities, generate community summaries
- Query time: local search (entity neighborhood) vs global search (community summaries) — know when each applies
- Cost reality: graph construction is expensive and brittle. Have an opinion on when it's worth it (high-value, stable corpora with relational questions) and when it isn't (large, churning document sets with lookup-style questions).
- Hybrid: vector retrieval for candidates, graph traversal for expansion and verification
Agent graphs (different thing, same word)
- LangGraph-style state machines: nodes, edges, conditional routing, cycles
- Checkpointing for resumability, human-in-the-loop interrupts
- DAG vs cyclic: cycles allow retry/reflection, and require explicit termination conditions
- Why an explicit graph beats a free-form agent loop in production: it's testable, observable, and bounded
17. Advanced Backend: Streaming, Reactive, APIs
17.1 Streaming systems
Kafka (depth beyond the basics)
- Exactly-once: idempotent producer (PID + sequence numbers) + transactions +
read_committedconsumers. Know that it's exactly-once within Kafka, not end-to-end. - ISR,
min.insync.replicas,acks=all, unclean leader election and the durability tradeoff - Log compaction vs retention; compacted topics as changelog/state
- Partition count as a scaling and ordering decision; ordering is per-partition only
- Consumer group rebalancing: eager vs cooperative sticky; static membership to avoid rebalance storms
- Consumer lag as the primary health metric; lag-based autoscaling (KEDA)
- KRaft (ZooKeeper removal), tiered storage
- Schema Registry: Avro/Protobuf/JSON Schema, compatibility modes (backward, forward, full, transitive) — a real design decision
Flink / stream processing
- Event time vs processing time vs ingestion time
- Watermarks: how late data is handled, allowed lateness, side outputs for the truly late
- Windows: tumbling, sliding, session, global
- State backends (heap vs RocksDB), checkpointing (Chandy-Lamport), savepoints for upgrades
- Exactly-once sinks via two-phase commit
- Kafka Streams vs Flink vs Spark Structured Streaming — know the tradeoff (operational simplicity vs power vs batch/stream unification)
Patterns
- CDC with Debezium; the dual-write problem and the outbox pattern as its fix
- Backfill and reprocessing: the Lambda vs Kappa architecture argument, and how you actually replay
- Dead letter queues, poison pill handling, retry topics with backoff tiers
- Idempotent consumers — required, always
17.2 Java reactive & Spring
Reactive Streams spec
Publisher/Subscriber/Subscription/Processor- Backpressure via
request(n)— this is the whole point; if you can't explain demand signalling, you don't know reactive - Backpressure strategies: buffer, drop, latest, error
Project Reactor
MonovsFlux; cold vs hot publishers- Operators:
map/flatMap/concatMap/flatMapSequential(concurrency and ordering differ — a classic interview question) publishOnvssubscribeOn— which part of the chain moves threads- Schedulers:
parallel,boundedElastic,single,immediate - Context propagation (
Context/ContextView), and why MDC logging breaks in reactive - Error handling:
onErrorResume,onErrorMap,retryWhenwith backoff - Testing with
StepVerifierandVirtualTimeScheduler
Spring WebFlux
- Netty event loop model; never block on an event-loop thread — this is the #1 production bug in reactive Spring
- WebFlux vs Spring MVC: when reactive actually wins (high-concurrency I/O-bound, streaming, many slow downstreams) and when it's pure cost (CPU-bound, simple CRUD, small team)
- R2DBC vs JDBC; reactive Redis, Mongo, Cassandra drivers
WebClientvsRestClient; connection pooling and timeouts
Virtual threads (Loom, JDK 21+) — have a strong opinion here
- Virtual threads give you scalability with blocking-style code. For many services, this removes the main reason to adopt reactive.
- Pinning:
synchronizedblocks and native calls pin a virtual thread to a carrier thread. UseReentrantLock. - Reactive still wins for: true streaming semantics, backpressure across a network boundary, complex async composition.
- The lead-level answer: "For a new service on JDK 21+, I'd default to virtual threads and structured concurrency. I'd choose reactive only for streaming with real backpressure requirements." That framing lands well.
17.3 GraphQL & Netflix DGS
- Schema design: nullability as a contract, interfaces vs unions, pagination (Relay cursor connections), error handling (errors array vs union result types)
- N+1 and DataLoader — batching and per-request caching. Expect to be asked to explain the fix in detail.
- Federation (Apollo Federation v2): subgraphs,
@key, entity resolution across services, supergraph composition,@requires/@provides/@external - Netflix DGS: annotation-driven (
@DgsComponent,@DgsQuery,@DgsData), codegen from SDL,DgsDataLoader, Spring Boot integration, instrumentation hooks, federation support - Security & cost control: query depth limiting, complexity/cost analysis, persisted queries (APQ), disabling introspection in prod, field-level authorization
- Caching is the hard part — no HTTP cache semantics. Solutions: persisted queries with GET + CDN, response cache keyed on query hash + variables + auth context,
@cacheControlhints - Subscriptions over WebSocket (graphql-ws) or SSE
- When not to use GraphQL: internal service-to-service (use gRPC), simple CRUD with one consumer, file uploads, when your team has no schema governance capacity
17.4 gRPC & binary protocols
- Protobuf wire format: field numbers as the contract, varint encoding, why you never reuse a field number,
reserved - Compatibility rules: adding optional fields is safe, changing types isn't, renaming is fine (names aren't on the wire)
- HTTP/2: multiplexing, header compression (HPACK), flow control, and why head-of-line blocking still exists at the TCP layer (hence HTTP/3 and QUIC)
- Four call types: unary, server streaming, client streaming, bidirectional
- Deadlines and cancellation propagation — gRPC does this properly and REST usually doesn't. Big talking point.
- Interceptors for auth, tracing, retries; metadata as the header equivalent
- Load balancing: client-side (
pick_first,round_robin), lookaside LB, xDS/Envoy integration. Note that L4 load balancers break gRPC because of long-lived HTTP/2 connections — a favorite gotcha question. - gRPC-Web and Connect for browser clients; grpc-JSON transcoding via Envoy for REST compatibility
- Alternatives and when: Thrift (legacy, Meta), Avro (schema-in-data, Kafka-friendly), Cap'n Proto / FlatBuffers (zero-copy, very low latency), MessagePack (schemaless binary JSON)
- Schema governance:
buffor lint and breaking-change detection in CI. Mentioning this signals you've run this at scale.
17.5 API decision matrix (have this ready)
| Need | Choose |
|---|---|
| Public API, broad client support, cacheable | REST + HTTP caching |
| Internal service-to-service, low latency, strong typing | gRPC |
| Many client-driven shapes, mobile bandwidth constraints, aggregation across services | GraphQL (federated) |
| Streaming server→client | SSE (simple) or gRPC server streaming |
| Bidirectional real-time | WebSocket or gRPC bidi |
| Event-driven decoupling | Kafka / event bus |
17.6 Distributed transaction patterns
- Saga: orchestration (central coordinator, easier to reason about) vs choreography (event-driven, less coupling, harder to debug)
- Compensating transactions and why they're not rollbacks
- Outbox and inbox patterns
- Event sourcing + CQRS: append-only log, projections, snapshots, replay. Know the costs — schema evolution of events is brutal, and most teams don't need it.
- Idempotency keys as a first-class API concept
18. Real-Time Delivery, CDN, Caching & Edge
18.1 WebSockets
- Handshake: HTTP Upgrade → 101 Switching Protocols; frames, masking, ping/pong keepalive
- Scaling: connections are stateful, so you need either sticky routing or a shared pub/sub backplane (Redis, NATS, Kafka) to fan out across nodes
- Resource math: ~10–50 KB per connection; a single node handles 10k–100k connections depending on buffer tuning. Know
ulimit -n, ephemeral port exhaustion, and conntrack table limits. - LB gotchas: idle timeouts kill connections silently. Configure keepalive shorter than the LB timeout.
- Reliability: reconnect with exponential backoff + jitter, resume via sequence number / last-message-id, at-least-once delivery with client-side dedupe
- Presence and fan-out patterns: room-based sharding, topic routing
18.2 Server-Sent Events
- Unidirectional server→client over plain HTTP, auto-reconnect built in,
Last-Event-IDfor resume - Works with existing HTTP infrastructure (proxies, auth, compression) — the reason it beat WebSockets for LLM token streaming
- HTTP/1.1 6-connection-per-origin limit is the classic gotcha; HTTP/2 multiplexing solves it
- Buffering by intermediate proxies breaks SSE — disable proxy buffering explicitly (
X-Accel-Buffering: nofor nginx)
| WebSocket | SSE | Long polling | WebTransport | |
|---|---|---|---|---|
| Direction | Bidi | Server→client | Both (inefficient) | Bidi |
| Protocol | Custom over TCP | Plain HTTP | HTTP | HTTP/3 (QUIC) |
| Auto-reconnect | Manual | Built-in | N/A | Manual |
| Infra friendliness | Poor | Excellent | Good | Emerging |
| Best for | Chat, collab, games | Token streaming, notifications, progress | Legacy fallback | Low-latency multiplexed streams |
18.3 HTTP caching (know the headers exactly)
Cache-Control:max-age,s-maxage,public/private,no-cache(revalidate) vsno-store(never store),immutablestale-while-revalidateandstale-if-error— the two directives that most improve real-world availability- Validators:
ETag(strong vs weak) +If-None-Match,Last-Modified+If-Modified-Since Varyand whyVary: *orVary: User-Agentdestroys your hit rate- Cache key design: which query params, headers, and cookies participate. Over-inclusive keys are the #1 cause of poor hit rates.
18.4 CDN architecture
- PoP hierarchy, origin shield / tiered caching to protect the origin
- Purge: hard purge vs soft purge (serve stale while refetching) vs surrogate keys / cache tags for group invalidation. Tag-based invalidation is the answer for content with complex dependencies.
- Cache stampede / thundering herd mitigation: request coalescing at the edge, jittered TTLs, probabilistic early expiration (XFetch), lock-and-refresh
- Edge compute: Cloudflare Workers, Lambda@Edge / CloudFront Functions, edge KV. Use for auth checks, A/B assignment, personalization at the edge, header rewriting.
- Personalization vs caching: split the page (cacheable shell + dynamic fragments), or use edge-side includes / streaming SSR
- Security at the edge: WAF, bot management, rate limiting, DDoS absorption via anycast
- Cache poisoning: unkeyed input in the cache key is the vulnerability class to know
18.5 Application caching layers
Layer the answer in interviews — most candidates only name Redis.
- Browser / HTTP cache
- CDN / edge
- API gateway response cache
- In-process cache (Caffeine, Guava) — nanoseconds, but per-instance, so consistency is the tradeoff
- Distributed cache (Redis, Memcached)
- Database buffer pool / materialized views
Patterns: cache-aside (most common), read-through, write-through (consistency, higher write latency), write-behind (throughput, risk of loss), refresh-ahead
Redis specifics
- Eviction policies:
allkeys-lru,volatile-ttl,noeviction— and what happens when you pick wrong - Cluster mode, hash slots, and why multi-key ops break across slots (hash tags fix this)
- Hot key mitigation: client-side local cache, key splitting, read replicas
- Pipelining vs Lua scripts vs transactions
- Persistence: RDB vs AOF, and that Redis is not a database
- Redis vs Memcached: data structures and persistence vs raw simplicity and multithreaded throughput
Invalidation
- TTL-only (simple, stale windows) vs event-driven (correct, complex) vs versioned keys (change the key, never invalidate)
- Negative caching to protect against lookup floods on missing keys
- The cardinal rule to state out loud: cache invalidation bugs are consistency bugs, so choose the staleness you can tolerate before choosing a cache.
19. Compute: Serverless, Containers, Kubernetes, OS & Kernel
19.1 Serverless
- Lambda cold start anatomy: download code → init runtime → run init code → invoke. Reduce with smaller packages, lazy imports, provisioned concurrency, SnapStart (JVM).
- Memory setting controls CPU allocation — undersizing memory to save money often costs more because duration rises
- Concurrency: reserved vs provisioned; account-level limits; throttling behavior and how it interacts with SQS/Kinesis event sources
- VPC-attached Lambdas: ENI setup used to dominate cold start; know the current hyperplane ENI model
- Connection management: never open a DB connection per invocation — RDS Proxy or a data API
- Orchestration: Step Functions (state machine, retries, error handling), EventBridge (routing/schema registry), SQS/SNS fan-out, DLQs everywhere
- Anti-patterns: chatty function-to-function calls, long-running jobs, functions as a monolith, distributed transaction spaghetti
- Decision framework: Lambda for spiky, event-driven, short work. Fargate for containerized services with variable load and no cluster ops. EKS/K8s when you need scheduling control, multi-tenancy, or portability. EC2 for GPU, specialized hardware, or extreme cost optimization at steady scale.
- Cost crossover: serverless wins below roughly 30–40% steady utilization; always-on compute wins above it. Be able to do this math out loud.
19.2 Containers & the kernel underneath
- Namespaces:
pid,net,mnt,uts,ipc,user,cgroup— isolation - cgroups v2 — resource limits (CPU, memory, IO, pids)
- Union filesystems (OverlayFS), copy-on-write, layer caching
- Security primitives: seccomp profiles, Linux capabilities (drop
ALL, add back what you need), AppArmor/SELinux, read-only root filesystem, non-root user - Runtimes:
runc(default),gVisor(userspace kernel, stronger isolation),Firecracker(microVM — what Lambda and Fargate run on),Kata - Image hygiene: multi-stage builds, distroless/scratch base, pinned digests not tags, SBOM generation, vulnerability scanning in CI
19.3 Kubernetes
Control plane
- API server (the only thing that talks to etcd), etcd (Raft, watch semantics), scheduler, controller-manager, kubelet, kube-proxy
- The reconciliation loop is the whole mental model: declared state vs observed state, controllers converging continuously
Scheduling & resources
- Requests vs limits; QoS classes (Guaranteed, Burstable, BestEffort) and eviction order
- CPU limits cause CFS throttling — a top production surprise. Many teams set CPU requests and no CPU limits deliberately. Have an opinion.
- Memory limits cause OOMKill, not throttling — memory is incompressible
- Affinity/anti-affinity, taints/tolerations, topology spread constraints, PodDisruptionBudgets
- Priority classes and preemption
Autoscaling
- HPA (metrics-driven pod count), VPA (right-sizing, conflicts with HPA on the same metric), Cluster Autoscaler vs Karpenter (Karpenter provisions right-sized nodes directly — the modern answer), KEDA (event-driven scaling on queue depth, Kafka lag, etc.)
Networking
- CNI plugins; flat pod network requirement
- Service types (ClusterIP, NodePort, LoadBalancer), kube-proxy modes (iptables → IPVS → eBPF/Cilium and why iptables degrades at scale)
- Ingress vs the Gateway API (Gateway API is the direction of travel)
- NetworkPolicy for east-west segmentation — the zero-trust building block inside the cluster
- Service mesh: sidecar (Istio/Linkerd) vs ambient/sidecarless; mTLS, retries, circuit breaking, traffic splitting. Know the latency and resource cost — mesh is not free.
Stateful & storage
- StatefulSets, PVCs, CSI drivers, StorageClass, volume expansion
- Operators/CRDs for stateful systems; when running a database on K8s is reasonable and when it isn't
Delivery
- Helm vs Kustomize; GitOps with Argo CD or Flux; progressive delivery (Argo Rollouts, Flagger) with canary + automated rollback on SLO breach
Debugging checklist to recite
CrashLoopBackOff → check logs and previous logs, init containers, probe config, missing config/secret
OOMKilled → memory limit, actual usage, JVM heap vs container limit mismatch
Pending → resource requests unschedulable, node selectors, taints, PVC unbound
ImagePullBackOff → registry auth, tag typo, rate limits
Slow but healthy → CPU throttling, noisy neighbor, probe misconfiguration, DNS (ndots:5 causing 5 lookups per query — a classic)
19.4 OS & kernel fundamentals
- Process vs thread vs coroutine; context switch cost (~1–5 µs); scheduler basics (CFS, and EEVDF in newer kernels)
- Memory: virtual memory, page tables, TLB, page cache, major vs minor faults, huge pages, NUMA locality, swap and why you disable it for latency-sensitive services, the OOM killer and
oom_score_adj - I/O models: blocking, non-blocking, multiplexed (
select/poll/epoll/kqueue), async (io_uring), and zero-copy (sendfile,splice,mmap) - Durability: page cache vs
fsync, write barriers, and why "the write returned" doesn't mean "the data is safe" - Networking stack: three-way handshake, TIME_WAIT,
SO_REUSEADDR, backlog queues (somaxconn, accept queue overflow), Nagle vsTCP_NODELAY, congestion control (CUBIC vs BBR), receive/send buffer autotuning, MTU and PMTU discovery, ephemeral port and conntrack exhaustion - eBPF: safe in-kernel programs for observability (
bpftrace, bcc), networking (Cilium), and security (Tetragon, Falco). Knowing eBPF is a strong staff-level signal. - Performance methodology: USE (Utilization, Saturation, Errors) for resources; RED (Rate, Errors, Duration) for services. State the method before the tool.
- Tooling:
perf+ flame graphs,strace,ltrace,tcpdump,ss,vmstat,iostat,pidstat,bpftrace - JVM in containers (if relevant): container-aware heap sizing, GC choice (G1 default, ZGC/Shenandoah for low pause), JFR, async-profiler, why
-Xmxat 100% of the container limit gets you OOMKilled
20. Storage, Scaling & Data Platform
20.1 DynamoDB
- Access patterns first, schema second. Say this before anything else; it's the answer they're listening for.
- PK/SK design, composite keys, single-table design and its real tradeoff (query efficiency vs comprehension cost)
- GSIs (eventually consistent, own capacity, projections matter) vs LSIs (strongly consistent, 10 GB per partition limit, must be created at table creation)
- Sparse indexes as a query pattern
- Limits: 400 KB item, 10 GB per partition key for LSI, 1 MB per query result page
- Hot partitions and adaptive capacity; write sharding by suffixing the key
- Capacity: on-demand vs provisioned + auto-scaling; throttling and exponential backoff with jitter
- Transactions (
TransactWriteItems, 100-item limit, 2× cost), condition expressions for optimistic concurrency - Streams → Lambda for CDC; TTL for expiry; PITR; global tables (multi-region active-active, last-writer-wins — know that conflict resolution is not configurable)
- Cost model: RCU/WCU, and that a badly modeled table costs 10× a well-modeled one
20.2 Cassandra / ScyllaDB
- Ring topology, vnodes,
NetworkTopologyStrategy, replication factor per DC - Tunable consistency:
ONE/QUORUM/LOCAL_QUORUM/ALL;R + W > RFfor strong consistency;LOCAL_QUORUMis the practical default multi-DC choice - Repair mechanisms: hinted handoff, read repair, anti-entropy repair (and that skipping repair causes resurrection of deleted data past
gc_grace_seconds) - Partition key design; wide partitions and the size ceiling (~100 MB / 100k rows as a rule of thumb)
- Tombstones — deletes create markers, range scans over tombstones cause timeouts. This is the Cassandra gotcha; know it.
- Compaction strategies: STCS (write-heavy), LCS (read-heavy, higher write amp), TWCS (time series)
- LWT (Paxos) is 4× the round trips — use sparingly
- Scylla: C++ rewrite, shard-per-core, same API, much better tail latency
- When Cassandra: high write throughput, multi-DC active-active, known query patterns, linear scale. When not: ad hoc queries, joins, strong transactional needs.
20.3 MongoDB
- Modeling: embed for one-to-few and read-together; reference for one-to-many, unbounded growth, or independent access. 16 MB document limit forces the decision.
- Replica sets: primary election (Raft-like), oplog, write concern (
w:majority,j:true), read concern (local/majority/linearizable/snapshot), read preference, causal consistency sessions - Sharding: shard key is nearly irreversible — evaluate cardinality, frequency, and monotonicity. Monotonic keys create a hot shard; hashed sharding fixes distribution but kills range queries.
- Chunk balancing, jumbo chunks, zone sharding for data residency
- Indexes: compound index ESR rule (Equality, Sort, Range ordering), partial, TTL, text, wildcard; covered queries
- Aggregation pipeline,
$lookuplimitations (no sharded-collection joins in older versions, no true optimizer) - Change streams for CDC; multi-document transactions exist but cost you the reason you picked Mongo
20.4 OpenSearch / Elasticsearch (operational depth)
- Node roles: master, data (hot/warm/cold/frozen), ingest, coordinating, ML
- Shard sizing: 10–50 GB per shard as a working range; over-sharding is the most common cluster killer
- JVM heap: 50% of RAM, under ~32 GB to keep compressed object pointers; the other half goes to the OS page cache for Lucene
- Mapping:
keywordvstext, dynamic mapping explosion,index: falsefor unqueried fields,doc_valuesfor aggregations, disable_sourceonly if you understand the consequences - Search phases: query-then-fetch; deep pagination cost;
search_after+ PIT instead offrom/size;scrollis legacy - Refresh vs flush vs merge;
refresh_intervaltuning for bulk indexing (set to-1during bulk loads) - Zero-downtime reindex: build new index → alias swap → verify → drop old. Rehearse this; it's a common design answer.
- ILM/rollover for time-series, snapshot/restore, cross-cluster replication and cross-cluster search
- Failure modes: circuit breaker trips, thread pool rejections (
search/writequeues), unbalanced shards, mapping conflicts, split-brain (pre-7.x quorum config) - k-NN: Lucene HNSW vs nmslib/faiss engines,
ef_search/ef_construction/m, scalar and product quantization, disk-based vector search for memory relief, filtered kNN and the pre/post-filter recall tradeoff
20.5 Relational at scale
- Postgres: MVCC, tuple bloat, autovacuum tuning, transaction ID wraparound (the incident that takes down unmonitored clusters)
- Index types: B-tree, GIN (JSONB, full text), GiST, BRIN (huge append-only tables), partial and covering (
INCLUDE) indexes - Isolation levels and the anomalies each permits (dirty read, non-repeatable read, phantom, write skew); Postgres
REPEATABLE READis snapshot isolation, which allows write skew — a great depth question - Connection pooling: PgBouncer transaction pooling and what it breaks (prepared statements, session state)
- Partitioning (declarative, by range/list/hash), and partition pruning
- Replication: streaming (physical) vs logical; replica lag and how the app handles read-after-write (sticky-to-primary for N seconds, or read-your-writes tokens)
- Sharding: Vitess, Citus, or application-level; the resharding problem is why you delay this as long as possible
- Online schema change:
pt-online-schema-change/gh-ost(MySQL),CREATE INDEX CONCURRENTLY, expand-contract migration pattern
20.6 Analytics & lakehouse
- Columnar formats: Parquet/ORC, row groups, column pruning, predicate pushdown, compression codecs
- Table formats: Iceberg (snapshot isolation, hidden partitioning, time travel, schema evolution), Delta Lake, Hudi. Iceberg is the default answer in 2026.
- Small file problem and compaction; partition evolution
- Warehouses: Snowflake (micro-partitions, clustering keys, warehouse sizing and auto-suspend for cost), BigQuery (slots, partitioning + clustering), Redshift
- Medallion architecture (bronze/silver/gold), dbt for transformation, incremental models
- Data contracts, quality gates, lineage — increasingly a lead's responsibility because AI systems consume this data
20.7 Storage decision table
| Access pattern | Store |
|---|---|
| Known key, single-digit ms, huge scale | DynamoDB / Cassandra |
| Complex queries, transactions, joins | Postgres / MySQL |
| Flexible documents, evolving schema | MongoDB |
| Full-text + facets + relevance | OpenSearch |
| Semantic / similarity | Vector index (HNSW) |
| Relationship traversal, multi-hop | Graph DB |
| Time series, high write, range queries | Timescale / InfluxDB / Cassandra TWCS |
| Analytics over history | Iceberg on object storage + query engine |
| Ephemeral, sub-ms | Redis |
| Immutable log, replay, fan-out | Kafka |
21. Security: OAuth, Zero Trust, AppSec & Network
21.1 OAuth 2.0 / 2.1 and OIDC
Grants and which are alive
- Authorization Code + PKCE — the only correct choice for any public client (SPA, mobile, CLI). PKCE now recommended for confidential clients too.
- Client Credentials — service-to-service
- Device Authorization — TVs, CLIs, constrained input
- Dead: Implicit (token in URL fragment, leaks), Resource Owner Password Credentials (the app sees the password)
Tokens
- Access token (what you present), refresh token (what you exchange), ID token (OIDC only — an assertion about who, not an authorization to do)
- JWT vs opaque: JWT is stateless and fast but hard to revoke; opaque needs introspection but gives you instant revocation. Common answer: short-lived JWTs (5–15 min) + refresh with rotation.
- Validation checklist: signature against JWKS,
iss,aud,exp,nbf,algallowlist,kidhandling and key rotation - Refresh token rotation with reuse detection — if an old refresh token is replayed, revoke the whole family
- Sender-constrained tokens: DPoP or mTLS-bound tokens. Prevents a stolen bearer token from being usable. This is the "above and beyond" answer.
- Token exchange (RFC 8693) for delegation and on-behalf-of flows in service chains
Vulnerabilities to name
- Redirect URI wildcards / open redirect chaining
- Missing or unbound
state→ CSRF on the callback - Mix-up attacks in multi-IdP setups (validate
issin the response) - Tokens in URLs (referrer leakage, logs, browser history)
alg: noneand algorithm confusion (RS256 verified as HS256 with the public key as the secret)- Overly broad scopes; scope != permission — do authorization in your own layer
Adjacent
- OIDC vs SAML: SAML for legacy enterprise SSO, OIDC for everything new; SCIM for user provisioning/deprovisioning
- Sessions:
HttpOnly,Secure,SameSite=Lax|Strict,__Host-prefix, back-channel logout - Authorization models: RBAC → ABAC → ReBAC (Zanzibar/OpenFGA/SpiceDB). Know when relationship-based access control is the right answer (nested resources, sharing graphs) — this is a strong staff-level signal.
21.2 Zero Trust
- Principles: never trust based on network location; verify explicitly per request; assume breach; least privilege; continuous evaluation
- NIST SP 800-207 model: Policy Decision Point + Policy Enforcement Point, policy engine, trust algorithm; every request is authenticated, authorized, and encrypted
- Identity-aware proxy (the BeyondCorp pattern) replacing the VPN perimeter
- Device posture as an input signal: managed device, patch level, disk encryption, EDR present
- Workload identity: SPIFFE/SPIRE, cloud IAM roles for service accounts (IRSA/Workload Identity). No long-lived static credentials anywhere.
- Microsegmentation: NetworkPolicy in K8s, security groups, service mesh authorization policies
- mTLS everywhere with automated cert rotation
- Just-in-time and just-enough access; break-glass with audit
- ZTNA vs VPN, and SASE as the packaging (SD-WAN + SWG + CASB + ZTNA + FWaaS)
- The honest lead answer: zero trust is a multi-year program, and the sequencing is identity → device → workload → network → data. Say the sequencing.
21.3 Application security
- OWASP Top 10 (web), OWASP API Security Top 10 (BOLA/IDOR is #1 and the one that actually gets exploited), OWASP LLM Top 10
- SSRF and cloud metadata (169.254.169.254) — enforce IMDSv2, egress allowlists, and URL validation that resolves DNS before fetching
- Injection beyond SQL: NoSQL, LDAP, command, template (SSTI), header injection
- Insecure deserialization; XXE; path traversal; mass assignment
- Crypto: never roll your own; AES-GCM for symmetric, envelope encryption with KMS, Argon2id/bcrypt for passwords, constant-time comparison, proper IV/nonce handling
- Supply chain: SBOM (SPDX/CycloneDX), SLSA levels, artifact signing (sigstore/cosign), dependency confusion, typosquatting, lockfile pinning, provenance attestation
- Secrets: Vault or cloud secret manager, dynamic short-lived credentials, rotation automation,
gitleaks/trufflehogin CI, and secrets scanning on history - Pipeline: SAST + SCA + DAST + IaC scanning (Checkov, tfsec) + container scanning (Trivy/Grype) + admission control (OPA Gatekeeper, Kyverno)
- Threat modeling with STRIDE and data flow diagrams. Producing a threat model for a design is a genuine differentiator — almost no candidates offer one unprompted.
21.4 Vulnerability management & patching
- CVE identifies, CVSS scores severity, EPSS predicts exploitation probability, CISA KEV lists what's actually being exploited. The mature prioritization answer is: KEV first, then EPSS × exposure, then CVSS. Saying "we patch all criticals in 7 days" without exposure context is the junior answer.
- Patch SLA tiers keyed to severity and internet exposure and data sensitivity
- Golden base image pipeline: rebuild on upstream CVE, redeploy immutably, never patch in place
- Runtime posture: drift detection, unauthorized process alerts (Falco/Tetragon)
- Zero-day response runbook: inventory (what do we run, where) → exposure assessment → mitigate (WAF rule, feature flag, network block) → patch → verify → communicate. Practice narrating this; incident rounds love it.
- The lead-level point: your patching velocity is bounded by your inventory accuracy. Most orgs fail at step one.
21.5 Network, SDN & SD-WAN
- SDN: separation of control plane and data plane, centralized controller, programmable forwarding (OpenFlow historically; today mostly vendor controllers and eBPF/XDP in the datacenter)
- Overlays: VXLAN, GENEVE, and why overlays exist (multi-tenancy, L2 over L3, mobility)
- SD-WAN: application-aware path selection across MPLS/broadband/LTE, dynamic failover on jitter/loss, centralized policy, zero-touch provisioning. The business case is MPLS cost replacement plus direct-to-cloud breakout.
- SASE: SD-WAN converged with security services delivered from the cloud edge
- Cloud networking: VPC/subnet design, NAT gateways and egress cost, Transit Gateway vs peering, PrivateLink for private service access, VPC endpoints to keep traffic off the internet, egress filtering as a data exfiltration control
- Load balancing layers: L4 (NLB, fast, connection-level) vs L7 (ALB/Envoy, routing, retries, header-based); global with anycast + GeoDNS
- DNS: resolution path, TTL strategy for failover, health-check-based routing, split-horizon, DNSSEC. DNS is the most common cause of "the whole thing is down."
- TLS: 1.3 handshake (1-RTT, 0-RTT with replay risk), cipher suites, cert lifecycle automation (ACME/cert-manager), OCSP stapling, mTLS for service identity
- DDoS: volumetric (L3/4, absorb with anycast + scrubbing) vs application-layer (L7, needs WAF + rate limiting + bot detection)
21.6 Compliance as engineering constraints
SOC 2, ISO 27001, GDPR/PIPEDA, HIPAA, PCI-DSS, data residency. What a lead actually needs: know which controls turn into engineering work (access reviews, audit logging, encryption at rest and in transit, retention and deletion, change management evidence, vendor review) and how to build them once rather than per-audit.
22. Frontend & Full-Stack for Leads
You won't be asked to build a UI, but you will be asked to make architecture decisions and review frontend work credibly.
22.1 React
- Rendering model: reconciliation, the key prop and why index-as-key breaks lists, StrictMode double-invocation in dev
- Hooks: rules of hooks and why (call order),
useEffectdependency traps, cleanup functions,useMemo/useCallbackas targeted optimizations rather than defaults,useReffor non-rendering state - Concurrent React:
startTransition,useDeferredValue, Suspense boundaries, streaming SSR with selective hydration - Server Components: what runs where, the
"use client"boundary, why RSC reduces bundle size, serialization constraints across the boundary - Performance: virtualization for long lists, route-based code splitting, bundle analysis, avoiding context-induced re-render cascades (split contexts or use a store)
- Error boundaries; hydration mismatch causes
22.2 State layers — the question behind the question
The lead-level insight: most state problems are caused by treating server data and UI state as the same thing. They have different lifecycles, different invalidation rules, and different owners.
The ladder (climb only as far as you need):
- Local
useState - Lifted state
- Context (for low-frequency, wide-read values: theme, auth, locale — not for hot state)
- Global client store (Zustand, Jotai, Redux Toolkit)
- Server cache library (TanStack Query, SWR, RTK Query)
Redux specifics (they will ask)
- Store, actions, reducers, immutability; Redux Toolkit + Immer removed most of the boilerplate objection
- Middleware layer: thunk for simple async (fire, await, dispatch), saga for complex orchestration with cancellation, concurrency control, and long-running flows, observable for stream-heavy needs. Default to thunk; reach for saga only when you need cancellation and choreography.
- Normalization with entity adapters; memoized selectors (
reselect) to prevent re-render storms - The honest opinion to voice: a large share of Redux stores in production are hand-rolled server caches. Moving server state to TanStack Query and keeping Redux for genuine UI state usually deletes half the store.
Vue (if the stack is Vue)
- Composition API vs Options API;
refvsreactiveand the unwrapping rules - Reactivity via Proxy in Vue 3 (vs
Object.definePropertyin Vue 2 — explains why Vue 2 couldn't detect array index and property additions) computedvswatchvswatchEffect- Pinia over Vuex; Nuxt for SSR/SSG
- Team-level tradeoff: Vue has a gentler ramp and stronger conventions; React has a deeper hiring pool and ecosystem. Frame it as a hiring and maintenance decision, not a taste one.
22.3 Cross-cutting frontend architecture
- Rendering strategy matrix: CSR (app-like, SEO-irrelevant) / SSR (dynamic + SEO) / SSG (static content) / ISR (mostly static, periodic refresh) / streaming SSR (large pages, perceived speed)
- Core Web Vitals: LCP (loading), INP (interactivity — replaced FID), CLS (stability). Know RUM vs lab measurement and that field data is what ranks.
- Micro-frontends and Module Federation: legitimate when independent teams deploy independently at real scale; usually a net cost otherwise. Having the skeptical-but-informed take is the senior signal.
- Design systems and tokens; accessibility (WCAG 2.2 AA, semantic HTML, keyboard navigation, focus management, ARIA only when semantics fail)
- Frontend security: XSS and why
dangerouslySetInnerHTMLneeds sanitization, CSP with nonces, CSRF and SameSite, clickjacking headers, third-party script risk - Build: Vite/esbuild/Turbopack, monorepos (Nx, Turborepo), incremental builds
- Testing: unit → component (Testing Library, test behavior not implementation) → E2E (Playwright) → visual regression. Have a stance on the ratio.
- Streaming AI UIs: SSE token rendering, optimistic states, "thinking" indicators, partial markdown parsing, citation rendering, and undo for agent-initiated actions
23. The Team Leadership Operating Playbook
This is where TL and Staff candidates most often sound generic. Everything below should become a concrete story or a concrete practice you can describe in 90 seconds.
23.1 Unblocking — your highest-leverage daily activity
- Classify every blocker: information (find the answer), decision (make it or force it), dependency (escalate or route around), skill (pair or reassign). Each has a different fix; treating them all the same is why teams stay stuck.
- Track decision latency. If a decision has sat more than 48 hours, that's your failure, not the team's.
- The 24-hour rule: nobody is blocked overnight without a named owner and a next action.
- Escalation is a tool, not a failure. Have an explicit ladder and use it early rather than heroically absorbing the delay.
23.2 Growing people
- Maintain a skill matrix: each engineer × each competency (domain, systems design, code quality, communication, ownership, mentoring). Assign work to close gaps deliberately, not by who's free.
- Stretch assignments at roughly 70% known / 30% new. More than that and you're setting up a failure.
- Delegation ladder: do it → do it and tell me → propose then do → decide and inform → own it entirely. Move people up one rung at a time and say out loud which rung they're on.
- Feedback with SBI (Situation, Behavior, Impact): specific, timely, behavioral. Praise publicly, correct privately.
- 1:1s: their agenda first, career every fourth one, never a status meeting. If your 1:1s are status updates you've lost the only private channel you have.
- Growth plans mapped to the next level's rubric, with named artifacts as evidence, not adjectives.
- Underperformance sequence: name the gap early → clarify expectations in writing → measurable plan with support → review → decide. The rule is no surprises at review time. If someone is surprised, that's a management failure.
23.3 PR review as a leadership lever
- Review SLA: first response within 4 business hours. Publish it. Review latency is usually the largest hidden cost in cycle time.
- Small PRs: under ~400 lines. Review quality collapses past that; large PRs get rubber-stamped.
- Comment taxonomy — adopt prefixes:
blocking:/suggestion:/nit:/question:/praise:. Cheap to introduce, disproportionately improves review culture, and removes the ambiguity that causes friction between seniors. - Automate everything mechanical: formatter, linter, import order, coverage thresholds. Humans should never comment on style.
- Review order: correctness → design and boundaries → tests → readability → nits. Say this ordering out loud in an interview.
- Rotate reviewers to spread context and prevent single-owner bottlenecks; pair-review for onboarding.
- Author obligations: PR description explaining why, test evidence, risk and rollback note, screenshots for UI.
- Anti-patterns to name: rubber-stamping, bikeshedding, the one-person gate, review used as territorial defense, and "LGTM" on a 2,000-line PR.
- AI-era shift: when AI generates a large share of the code, the bottleneck moves from writing to reviewing, and review queues back up in month two of adoption. Concrete counters: require authors to explain AI-generated code as their own, label AI-assisted PRs, monitor review queue depth and merge time as first-class metrics, and raise test coverage requirements on generated code.
23.4 Planning, estimation & scheduling
- Capacity math: headcount × available days − on-call − interviews − support rotation − meetings − holidays. Commit to 60–70% of that. Teams that commit to 100% miss every time.
- The three-bucket budget: features / reliability + tech debt / keep-the-lights-on. Publish the split (a common healthy target is 60/25/15) and defend the middle bucket explicitly with data, not vibes.
- Estimation: prefer historical cycle time distribution over story points. Forecast with percentiles ("85% confidence we finish by the 22nd"), not single dates. This one habit makes you sound more senior than almost anything else.
- Break down until the largest item is under a week; anything bigger is unestimated risk in disguise.
- Dependency mapping across teams and explicit critical path; renegotiate early, not at the deadline.
- Scope management: cut scope, not quality. Have the "what would we drop" conversation at the halfway mark, not the week before.
- On-call: sustainable rotation (never fewer than 6 people), page budget (if you're paging more than ~2×/week per person, fix the system), interrupt-shield rotation so the rest of the team gets flow time, and a toil budget with a cap.
- Roadmap sequencing: what unlocks what, what's reversible vs one-way-door, what can be a spike instead of a commitment.
23.5 Product partnership
- Your job in discovery is to supply options with costs, not verdicts: "A is two weeks; B is six but makes C nearly free; here's what I'd pick and why."
- Own the non-functional requirements. PMs almost never write latency, availability, or cost targets — if you don't, nobody will.
- Translate platform investment into product language: "this cuts our checkout p99 by 300 ms, which historically moves conversion by X."
- Know the product's north star metric and how your systems affect it. A lead who can't name it looks disconnected.
- Push back with data and alternatives, never with "that's not possible."
23.6 Promotions, calibration & retention
- Promotions are won two quarters early by assigning work that generates evidence at the target level. Waiting until packet season is how good engineers get stuck.
- Build the packet continuously: scope, impact with numbers, cross-team evidence, partner quotes, artifacts (design docs, incident reports, mentorship outcomes).
- Calibration: you'll argue for your person in a room of peers. Come with artifacts and comparisons to the rubric, not adjectives.
- For someone not ready: be specific about the gap and the timeline. Vagueness here is the cruelest thing a lead does.
- Retention: know each person's actual motivator (scope, learning, money, title, flexibility, teammates). Recognize before they ask. Most regretted attrition is visible three months out if you're paying attention.
23.7 Hiring
- Write the scorecard before the loop; interview to it; give written feedback within 24 hours
- Structured, consistent questions across candidates — otherwise you're measuring rapport
- Know how to say no to a "fine" candidate and articulate why. Bar defense is a lead responsibility.
- Onboarding: named buddy, a shipped change in week one, a 30/60/90 with explicit success criteria
23.8 Communication & meetings
- Written-first culture: design docs, RFCs, ADRs (architecture decision records). Decisions that aren't written down get relitigated.
- Meeting audit: no agenda, no meeting; no decision, shorter meeting. Replace status meetings with async updates.
- Managing up: a monthly one-pager to your director with progress, risks named early, and asks. Directors remember the leads who surface risk before it becomes news.
- Incident comms: fixed cadence updates, audience-appropriate detail, no speculation, and a blameless postmortem with action items that have owners and dates.
23.9 Metrics a lead should watch
- DORA: deployment frequency, lead time for changes, change failure rate, failed deployment recovery time, rework rate
- The 2026 caveat you should raise unprompted: DORA alone is now considered insufficient in AI-heavy teams. Where AI generates a large share of committed code, deployment frequency and lead time become misleading; recent industry data shows AI adoption improving throughput while degrading stability (higher change failure rates). Pair DORA with AI attribution, code durability, and a quality guardrail.
- DevEx/SPACE: flow state, cognitive load, satisfaction, interruption count
- Review queue depth, time-to-first-review, merge time
- Pages per person per week, alert noise ratio, toil percentage
- Escaped defect rate, incident MTTR, repeat-incident rate
24. AI as a Cross-Cutting Force
The section that makes you sound like you're operating in 2026 rather than 2022. Every topic above changes when a model is in the loop. Be able to speak to each.
24.1 The leadership frame
The current data is genuinely two-sided, and saying so is the credible position:
- Teams with high AI adoption report meaningfully better productivity, roadmap time, and satisfaction than ad hoc adopters.
- At the same time, AI adoption correlates with higher change failure rates — throughput up, stability down. DORA's own reporting has flagged this direction.
- Adoption is a change management problem, not a tooling problem. Uniform usage across a team is the hard part; individuals go rogue and gains don't compound.
Your interview answer should be: adopt aggressively, instrument honestly, and pair every speed gain with a quality guardrail. Name the risk that AI raises output volume while lowering the average reviewer's understanding of the code. Almost nobody says this, and it reads as judgment rather than enthusiasm.
24.2 Layer by layer
Coding & review — The bottleneck moves from writing to reviewing. Invest in tests, type systems, contracts, and static analysis, because those are the checks that scale when volume rises.
Testing — AI is good at generating edge cases and property-based tests; humans still own assertions about business rules. Mutation testing becomes more valuable because coverage numbers inflate cheaply.
System design — An LLM in the request path changes everything: latency budgets go from milliseconds to seconds, cost per request becomes variable, and outputs become non-deterministic. Design consequences: aggressive timeouts, streaming to mask latency, semantic caching, model fallbacks, and a graceful degradation path to a non-AI experience. Say "what happens when the model provider is down" before they ask.
APIs — Tool and function schemas are now a public API surface consumed by models. Version them, document them for model consumption, and treat MCP servers with the same rigor as any external API. Tool descriptions are prompt surface: a sloppy description is a production bug.
Data — Retrieval quality is a data quality problem. Lineage, freshness, ownership, and access control become model-visible, which means data governance stops being a compliance checkbox and becomes a correctness requirement.
Search & recsys — Hybrid retrieval, LLM-based query understanding, LLM reranking on the top-k, generated summaries as a new surface. Each of these needs new evaluation; your existing NDCG harness doesn't measure whether the generated summary was faithful.
Frontend — Streaming token UIs over SSE, partial rendering, latency masking, thinking states, citation UI, confidence display, and undo for agent-initiated actions. New UX primitives, new accessibility questions.
Security — Prompt injection is the new XSS, and indirect injection (via retrieved documents, tool output, scraped pages) is the dangerous variant. Treat model output as untrusted input everywhere. Scope agent credentials to least privilege with short-lived tokens. Agent identity becomes a real IAM problem.
Infrastructure — GPU capacity planning, inference autoscaling with minutes-long cold starts, cost per token as an SLO, and provider-diversity as a resilience strategy.
Observability — Traces must carry prompts, model version, token counts, tool calls, latency per step, and cost. Evals belong in CI. Standard error-rate monitoring misses the failure mode that matters: confidently wrong output with a 200 status code.
Incident response — AI failures are silent and probabilistic. You need eval regression alerts and output quality monitoring, not just error rates and latency graphs.
On-call & toil — AI is genuinely good at log triage, correlation, and runbook drafting. Keep human decision authority on mitigation. Automate the reading, not the deciding.
24.3 Questions you should have answers to
- How has AI changed how your team works? (specifics and numbers, not enthusiasm)
- How do you prevent AI from degrading code quality? (the guardrail answer: review capacity, test requirements, AI attribution in metrics)
- How do you measure AI ROI? (token cost vs cycle time vs change failure rate — the three-way tradeoff)
- Where do not you use AI? (having a crisp answer here is the strongest signal in the set)
- How do you handle an engineer whose AI-assisted output they can't explain?
- How would you roll AI tooling out to a skeptical team of 12?
25. Rapid-Fire Drill Bank
Answer each out loud in under 90 seconds. If you can't, that's your study list.
AI/LLM
- Why is prefill compute-bound and decode memory-bandwidth-bound?
- Your RAG answers are confidently wrong. Walk the diagnosis.
- When would you fine-tune instead of improving retrieval?
- How do you evaluate an agent that takes 20 steps?
- How do you defend against indirect prompt injection?
- Cut LLM spend 60% without hurting quality. What's the order of moves?
Context/Graph 7. Your prompt is 3,000 tokens of business rules. What do you do? 8. When does a knowledge graph beat a vector index? 9. How do you budget a 128k context window for an agent?
Streaming/Reactive/APIs
10. Explain exactly-once in Kafka and what it does not cover.
11. flatMap vs concatMap — behavior and when each is wrong.
12. Do virtual threads make WebFlux obsolete?
13. How do you fix N+1 in GraphQL, and why doesn't caching solve it?
14. Why does an L4 load balancer break gRPC?
15. Watermarks in Flink — what problem do they solve?
Caching/Edge 16. Prevent a cache stampede on a hot key. Three approaches. 17. Invalidate cached content with complex dependencies. How? 18. SSE or WebSocket for streaming LLM tokens, and why?
Compute/K8s/OS
19. A pod is healthy but slow. Diagnose in order.
20. Why might removing CPU limits improve latency?
21. Serverless or containers for this workload — walk your math.
22. Where does fsync fit in a durability guarantee?
23. How would you use eBPF to debug intermittent latency?
Storage 24. Design a DynamoDB table for these five access patterns. 25. Why did our Cassandra range query start timing out? 26. Pick a Mongo shard key for this workload and defend it. 27. Reindex OpenSearch with zero downtime. 28. Postgres write skew under REPEATABLE READ — what happens and how do you prevent it?
Security 29. Why PKCE for a confidential client? 30. Design token revocation with stateless JWTs. 31. A critical CVE drops in a library you use. Walk the first 4 hours. 32. Explain zero trust to a director in 60 seconds, then give the sequencing. 33. What's the difference between CVSS and EPSS, and which drives your patching?
Frontend 34. Half our Redux store is server data. What's the migration and why? 35. Our INP is bad. Diagnose. 36. When are micro-frontends worth it?
Leadership 37. Your best engineer's PR comments are demoralizing juniors. First move? 38. Review queue depth doubled after AI tooling rollout. What do you do? 39. Your director wants a date you can't commit to. 40. Make the case for 25% reliability investment to a product-focused VP. 41. Two teams are building the same service. You have no authority over either. 42. An engineer wants promotion; they're one level of scope short. Run the conversation.
26. Revised Study Calendar (12 Weeks)
Part I's 8-week plan covers the interview mechanics. This 12-week version folds in the Part II domains. Run Part I's tracks in parallel throughout — story portfolio and coding never stop.
| Week | Primary focus | Deliverable |
|---|---|---|
| 1 | Story portfolio + fundamentals + deep-dive outline | 6 stories written in SCOR |
| 2 | Distributed systems core + 5 classic designs | 5 written designs |
| 3 | Storage deep dive (§20) — Dynamo, Cassandra, Mongo, Postgres | Storage decision table from memory |
| 4 | Streaming + reactive + APIs (§17) | Kafka/Flink design + gRPC vs GraphQL matrix |
| 5 | AI/LLM/agents (§15) | 3 AI system designs written out |
| 6 | Context/prompt/graph engineering (§16) + evaluation | An eval harness design + context budget doc |
| 7 | Compute, K8s, OS/kernel (§19) | Debug checklist + a real perf investigation writeup |
| 8 | Security (§21) + a threat model of your own system | STRIDE threat model, 2 pages |
| 9 | Caching, CDN, real-time (§18) + frontend (§22) | Rendering + caching decision matrices |
| 10 | Leadership playbook (§23) + role-play drills | 16 stories total; 6 role-plays completed |
| 11 | AI cross-cutting (§24) + company-specific prep (§10) | Per-company question lists + 30/60/90 plans |
| 12 | Full-loop simulations, taper, polish | 2 full loops; every story under 3 minutes |
Weekly constants regardless of focus:
- 8–10 timed coding problems
- 1 mock interview from week 3 onward
- 1 story rewritten or tightened
- 1 design written out longhand, not just read
27. Final Calibration
Three things separate the candidates who get offers at this level from the ones who get "strong but not quite."
- They name the tradeoff before being asked. Every technical statement carries its cost. "I'd use X; it costs Y; I'd revisit if Z."
- They have a point of view and hold it under pressure without being brittle. Directors are hiring someone who will disagree with them productively.
- They talk about people and systems in the same breath. The strongest answer to a technical question at staff/TL level ends with how the team would operate it, review it, and be on call for it.
Everything in this document is in service of those three.
Part I written first; Part II appended with deep technical domains, leadership playbook, and the AI cross-cutting layer. Append further sections as prep progresses.
PART III — Gap Closure: SRE, Architecture Discipline, Delivery & Standards
Added after a coverage audit of Parts I–II. These are the topics most likely to be probed that the earlier parts under-served.
28. Observability & SRE (Full Depth)
28.1 SLO engineering — beyond the definitions
- SLI selection: measure at the point closest to the user (load balancer, not the app), ratio of good events to total events
- SLO targets: derived from user tolerance and business need, not aspiration. 99.9% = 43 min/month of budget; 99.99% = 4.3 min. Know these numbers cold.
- Error budget policy as a contract: budget healthy → ship fast; budget exhausted → freeze features, fund reliability. The policy only works if leadership signed it before the budget ran out. Say that.
- Burn-rate alerting — the answer to "how do you alert on an SLO":
- Alert on rate of budget consumption, not raw error rate
- Multi-window, multi-burn-rate: e.g., page when burn rate ≥ 14.4× over 1 h (budget gone in ~2 days) AND ≥ 14.4× over 5 min (still happening); ticket at 6× over 6 h; low-priority at 1× over 3 days
- Why two windows: the long window confirms significance, the short window confirms it's current — kills both false pages and stale pages
- Composite SLOs for user journeys spanning services; dependency SLOs and the "you can't be more available than your hard dependencies" math (three 99.9% serial dependencies ≈ 99.7%)
- Latency SLOs on percentiles: p99 per-request, and why averaged percentiles are meaningless (you cannot average p99s across instances — aggregate the histograms)
28.2 OpenTelemetry & the three pillars
- OTel as the vendor-neutral standard: API vs SDK vs Collector; OTLP protocol; semantic conventions
- Collector pipeline: receivers → processors (batch, tail sampling, redaction) → exporters. The Collector is where you enforce cardinality limits, sampling policy, and PII scrubbing centrally.
- Traces: spans, context propagation (W3C
traceparent), baggage; head sampling vs tail sampling (keep all errors and slow traces, sample the boring ones — requires buffering at the collector) - Metrics: counters/gauges/histograms; cardinality is the cost model — a label with unbounded values (user ID, request ID) will melt Prometheus. This is the #1 practical observability interview question.
- Exemplars: linking a histogram bucket to an example trace — the modern debugging workflow
- Logs: structured (JSON), trace-ID correlation, sampling noisy logs, cost tiers (hot/warm/cold retention)
- Wide events / observability 2.0 framing (Honeycomb-style): one rich event per request beats three disconnected pillars. Worth having as an opinion.
- Continuous profiling (Parca, Pyroscope, async-profiler) as the emerging fourth signal
28.3 Chaos engineering & resilience validation
- Principles: steady-state hypothesis → inject fault → measure blast radius → automate. Start in staging, graduate to production with guardrails and an abort switch.
- Fault menu: instance kill, AZ failure, dependency latency injection, packet loss, DNS failure, certificate expiry, clock skew, disk full, region evacuation
- Tooling: AWS FIS, Gremlin, Chaos Mesh, Litmus; game days as the organizational practice
- The lead framing: chaos engineering is cheap insurance against the failure modes your architecture claims to handle. "We believe we survive an AZ loss" is a hypothesis until tested.
- DiRT/game day narrative: run one, and have the story — what broke that you didn't expect. That story is interview gold.
28.4 Load & performance testing
- Types: load (expected peak), stress (find the breaking point), soak (leaks and degradation over hours), spike (sudden 10×)
- Tooling: k6, Locust, Gatling, JMeter; distributed load generation
- Methodology: define the SLO first, model realistic traffic (open vs closed workload models — closed models hide queueing collapse; know this distinction, it's a sophisticated signal), ramp gradually, watch saturation not just latency
- Coordinated omission — naive clients under-report tail latency when the system stalls. Naming this marks you as someone who has actually done performance work.
- Capacity planning: headroom targets (run at ≤60–70% at peak), scaling limits inventory, and load tests as regression gates before major launches
28.5 Incident management (formalized)
- Roles: incident commander, ops lead, comms lead, scribe — IC is a coordination role, not the best debugger
- Severity matrix with objective triggers; who can declare (anyone), who can page whom
- Mitigate before diagnose: rollback, feature-flag off, shed load, fail over — in that order of preference
- Status cadence: sev1 every 30 min, written, no speculation
- Blameless postmortem structure: timeline, contributing factors (plural — no single root cause), what went well, action items with owners and due dates, and a review loop that checks whether past action items actually shipped
- Repeat-incident rate as the metric that tells you whether postmortems are theater
29. Architecture Discipline: DDD, Team Topologies & Decision-Making
29.1 Domain-Driven Design (the parts interviews touch)
- Bounded context — the single most useful concept: an explicit boundary within which a model and its language are consistent. Service boundaries should follow bounded contexts, not entities. "We split by noun (users, orders) instead of by context" is the standard microservices failure; being able to say why is the standard staff answer.
- Ubiquitous language: the team and the code use the domain's words. Translation layers at context boundaries (anti-corruption layer) protect your model from an upstream's mess — name the ACL pattern when discussing integrations with legacy systems.
- Context mapping relationships: customer–supplier, conformist, shared kernel, ACL, open host service
- Aggregates: consistency boundaries; one transaction per aggregate; references by ID across aggregates. This maps directly to "how do you keep microservices consistent" — the aggregate boundary is where strong consistency ends and sagas begin.
- Event storming as the workshop technique to discover boundaries — mentioning you'd run one to decompose a monolith is a concrete, credible answer to a vague decomposition question
- Strategic vs tactical DDD: the strategic half (contexts, maps, language) is the valuable half at lead level; the tactical patterns (repositories, value objects) are implementation detail
29.2 Microservices vs modular monolith — the 2026 position
- The pendulum has swung: the defensible default for most teams is a modular monolith with enforced internal boundaries (module APIs, no cross-module DB access), extracting services only when a specific force demands it: independent scaling, independent deploy cadence for separate teams, fault isolation, polyglot needs, or regulatory separation
- Extraction criteria checklist and the strangler-fig mechanics (Part I §5.1) — connect these explicitly
- Distributed systems tax to recite: network failure modes, versioned contracts, distributed tracing, eventual consistency, integration testing pain, on-call fan-out
- Conway's law both directions: your architecture will mirror your org chart, so use the inverse Conway maneuver — reshape teams to get the architecture you want
29.3 Team Topologies (know this cold — it's the current lingua franca of eng-leadership interviews)
- Four team types: stream-aligned (the default — owns a slice of value end to end), platform (reduces cognitive load of stream teams via self-service), enabling (temporarily uplifts capability, then leaves), complicated-subsystem (deep specialist domain)
- Three interaction modes: collaboration (high bandwidth, temporary, for discovery), X-as-a-Service (clean consumption, for stable interfaces), facilitating (coaching)
- Cognitive load as the sizing principle: a team's scope is bounded by what it can hold in its collective head. Too many domains → errors, slow onboarding, burnout. This gives you a principled answer to "how would you split this 15-person team" — split by cognitive load and stream alignment, not by layer.
- Platform-as-product: internal platforms need product management, adoption metrics, and paved roads, not mandates. "Golden path, not golden cage."
- Anti-patterns to name: shared "DevOps team" as a bottleneck, layer-based teams (frontend team / backend team / DB team) forcing every feature through three backlogs, enabling teams that never leave
29.4 Decision-making machinery
- One-way vs two-way doors (make reversible decisions fast, irreversible ones carefully) — attribute it, use it constantly
- DACI/RAPID: Driver, Approver, Contributors, Informed. The point is one named approver — decisions with committee approval don't get made. Offer DACI when asked "how do you resolve cross-team disagreement."
- RFC/design-doc process: async written proposal → comment window → decision recorded. ADRs (architecture decision records) as the lightweight log: context, decision, consequences, status. "Decisions not written down get relitigated" — say it.
- Disagree and commit: the full version — dissent is documented, a revisit trigger is defined, and commitment afterward is genuine
- C4 model for architecture communication: Context → Container → Component → Code. Draw system-design answers at the container level and say you're doing so; it signals structured communication.
- Rubrics for build-vs-buy: total cost of ownership including operations and hiring, differentiation test ("is this our business?"), exit cost, vendor risk
30. Delivery Engineering: CI/CD, Testing Strategy & Experimentation
30.1 Branching & integration
- Trunk-based development as the high-performance default: short-lived branches (<1 day), merge to main continuously, incomplete work behind flags. Long-lived feature branches correlate with slow lead time and merge hell — DORA's research backs this and interviewers know it.
- GitFlow: legacy; defensible only for versioned/shipped software with parallel supported releases
- Merge queue (GitHub merge queue, Bors-style) for high-traffic repos; monorepo vs polyrepo tradeoffs (atomic cross-cutting changes and shared tooling vs build tooling investment — Bazel/Nx/Turborepo)
30.2 Feature flags as a platform capability
- Flag types with different lifecycles: release flags (short-lived, deleted after rollout), ops flags (kill switches, permanent), experiment flags (owned by the A/B platform), permission flags (entitlements)
- Progressive rollout: 1% → 5% → 25% → 50% → 100% with automated rollback on metric regression
- Flag debt is real: stale flags are dead code paths with untested interactions. Policy: every release flag has an owner and an expiry; CI warns on expired flags. Naming flag hygiene is an experienced-operator signal.
- Testing with flags: test both sides of live flags; combinatorial explosion means you prioritize by traffic reality, not exhaustiveness
30.3 Testing strategy (the coherent version)
- Pyramid vs trophy: classic pyramid (many unit, some integration, few E2E) vs Kent C. Dodds' trophy (weight integration tests, since they catch the most bugs per maintenance dollar). Have a position: unit tests for logic and algorithms, integration tests as the workhorse, E2E for a handful of critical journeys only. Flaky E2E suites that everyone retries are worse than no E2E suite — they train the team to ignore red.
- Contract testing (Pact / consumer-driven contracts): consumers publish expectations, providers verify in CI. This is the answer to "how do you test 40 microservices without a full-environment integration suite" — near-mandatory knowledge for a lead in a microservices org. Bi-directional contracts and schema-based alternatives (Protobuf + buf breaking-change checks, OpenAPI diff) for the lighter-weight version.
- Test doubles taxonomy (stub, mock, fake, spy) and the classicist-vs-mockist stance: over-mocked tests that verify implementation rather than behavior are the most common test-suite disease
- Property-based testing (Hypothesis, jqwik) for parsers, codecs, invariant-heavy logic; mutation testing (PIT) to audit whether coverage means anything — both punch above their weight as "above and beyond" mentions
- Test data management: builders/factories over fixtures, ephemeral databases (Testcontainers — name it), golden files for snapshot-style verification
- Non-functional gates in CI: performance budgets, security scans, accessibility checks
30.4 Release engineering
- Deployment strategies matrixed by risk: rolling (default), blue/green (instant rollback, 2× capacity), canary (metric-gated, needs traffic), shadow (risk-free validation, needs idempotent downstream handling)
- Automated canary analysis (Kayenta-style): compare canary vs baseline cohort on SLIs, promote or roll back without a human
- Deploy ≠ release: deployment is moving bits, release is exposing users, decoupled by flags. This one sentence resolves half of all release-process interview questions.
- Database changes ride the expand–contract pattern (expand schema → migrate code → contract) so every deploy stays backward compatible and rollback-safe
- Rollback discipline: every change has a tested rollback or an explicit "roll-forward only" designation with justification; artifact immutability (deploy the same artifact through every environment)
30.5 Experimentation platform
- A/B fundamentals: randomization unit (user vs session vs request — and why crossing units invalidates results), power analysis before launching (know that detecting a 1% lift on a low-traffic surface may take months — saying this prevents the classic underpowered-experiment failure)
- Guardrail metrics alongside success metrics: latency, error rate, retention, revenue-per-user — a win on the target metric that trips a guardrail is a loss
- Pitfalls to name: peeking (sequential testing or fixed horizons as fixes), multiple comparisons, novelty effects, network effects and interference (switchback tests for marketplaces), Simpson's paradox in segment analysis
- Interleaving for ranking changes (from your search background — much more sensitive than A/B for relevance; connect it)
- CUPED / variance reduction as an advanced mention
- Org layer: experiment review, a shared metrics dictionary, and the discipline that ship/no-ship decisions cite the experiment readout
31. Standards & Frameworks Reference Card
The shared vocabulary of eng-leadership conversations. For each: what it measures, when to invoke it, and its known failure mode. Interviewers use these as shorthand — fluency here is table stakes; knowing the critiques is the differentiator.
| Framework | What it is | Invoke when | Failure mode / critique |
|---|---|---|---|
| DORA (five keys) | Deployment frequency, lead time, change failure rate, failed-deployment recovery time, rework rate | Baseline delivery health; before/after for process changes | Team-level comparison is explicitly warned against; gameable; misleading when AI writes 30–70% of code — pair with quality and AI-attribution signals |
| SPACE | Satisfaction, Performance, Activity, Communication, Efficiency — pick metrics across ≥3 dimensions | When someone proposes measuring productivity with a single number | It's a framework for choosing metrics, not a metric set — people cite it without operationalizing it |
| DX Core 4 | Speed, effectiveness, quality, business impact — an opinionated, benchmarkable synthesis of DORA+SPACE+DevEx | Exec reporting; when DORA alone is getting gamed | Newer; benchmarks vendor-dependent |
| DevEx (flow, cognitive load, feedback loops) | Developer-experience lens on productivity | Diagnosing why delivery is slow (interruptions, wait states, tooling friction) | Survey-heavy; needs pairing with system data |
| Google SRE | SLI/SLO/error budgets, toil caps (<50%), blameless postmortems | Reliability conversations; on-call design | Cargo-culted error budgets without the leadership contract behind them |
| AWS Well-Architected | Six pillars: operational excellence, security, reliability, performance efficiency, cost optimization, sustainability | Architecture reviews — use the pillars as a review checklist | Checklist compliance ≠ good architecture; vendor-flavored |
| 12-Factor App | Config in env, stateless processes, disposability, dev/prod parity, logs as streams | Assessing an app's cloud-readiness; container migration | Dated in places (e.g., its config story predates secret managers); still the shared baseline vocabulary |
| FinOps (inform → optimize → operate) | Cloud financial ops: visibility/allocation → rightsizing/commitments → continuous governance | Cost conversations; unit economics (cost per request/customer/token) | Becomes a reporting function with no engineering teeth if leads don't own unit costs |
| Team Topologies | Four team types, three interaction modes, cognitive-load-bounded team scope | Org design, platform strategy, "how would you restructure this" | Applied as labels without changing interaction modes |
| SLSA | Supply-chain integrity levels for build provenance | Supply-chain security posture; artifact signing roadmap | Levels adopted on paper without verifying provenance at deploy time |
| NIST CSF 2.0 | Govern, Identify, Protect, Detect, Respond, Recover | Structuring a security program conversation with leadership | High-level; needs mapping to concrete controls |
| NIST AI RMF / ISO 42001 | AI risk management framework / AI management-system standard | AI governance questions — increasingly asked in regulated industries; directly relevant to legal-tech | Early-maturity; audits vary widely |
| WCAG 2.2 AA | Accessibility conformance target | Frontend quality bars; legal exposure (ADA, AODA — note AODA applies in Ontario) | Treated as launch-gate checklist instead of design input |
| C4 model | Four zoom levels for architecture diagrams | Any whiteboard moment — narrate which level you're drawing | None serious; just underused |
| RFC 2119 keywords | MUST/SHOULD/MAY in specs | Writing API contracts and internal standards | Trivial, but using it correctly in docs reads as rigor |
How to deploy these in interviews: never recite a framework as an answer. Use it as scaffolding — "I'd look at this through the Well-Architected reliability pillar: …" — then immediately get concrete. Framework name-dropping without specifics is a negative signal; framework + critique + concrete application is a strong one.
32. Multi-Region, DR & Business Continuity
- RTO (how long until service restored) and RPO (how much data you can lose) — every DR conversation starts by extracting these two numbers from the business, because they set the budget. Know the cost curve:
- DR strategy ladder, cheapest to most expensive: backup & restore (RTO hours–days) → pilot light (core data replicated, minimal compute idle) → warm standby (scaled-down full stack) → multi-site active-active (RTO ~0, RPO ~0, 2×+ cost and permanent engineering complexity)
- Active-active realities: conflict resolution (last-writer-wins vs CRDTs vs single-writer-per-key), data residency constraints pinning users to regions, global load balancing (GeoDNS + health checks, anycast), and the write-path question — global strong consistency (Spanner-style, pay latency) vs regional writes with async replication (pay conflicts)
- Failover discipline: untested failover is fiction. Regular game-day region evacuations; runbooks with decision criteria for who declares failover and at what threshold (the hard part is deciding to fail over, not the mechanics)
- Failback is harder than failover — resynchronization and split-brain reconciliation; plan it explicitly
- Dependency audit: your real RTO is the max of your critical dependencies' RTOs — including your identity provider, DNS, and secrets manager, the three everyone forgets
- Backup hygiene: 3-2-1 rule, immutable/air-gapped copies (ransomware), and restore testing — a backup that's never been restored is a hope, not a backup
- Chaos-test the DR claims (ties to §28.3)
33. The Offer Stage: Closing Above and Beyond
Prep doesn't end at the last interview. This stage has the highest ROI per hour of anything in this document.
- Debrief dynamics: offers are calibrated in a packet review. Your interviewers write feedback within a day — the follow-up notes from Part I §11 land before that happens; that's why they matter.
- Level negotiation before compensation negotiation. Level determines the band; arguing dollars within the wrong band is fighting the wrong battle. If you got down-leveled, ask what evidence was missing and whether additional signal (another conversation, a work sample) can revisit it — sometimes it can.
- Components and their flexibility, most to least: sign-on bonus > equity > base > annual bonus target. Multi-year sign-ons can bridge an equity gap.
- Equity literacy: RSUs vs options, vesting schedules and cliffs, refresh policy (ask — a great initial grant with no refreshers inverts by year 3), for private companies: strike price, latest 409A/preferred spread, exercise windows, liquidity outlook
- Competing timelines: it is normal and expected to ask companies to align decision dates. Urgency created by a real competing offer is the strongest negotiation position; manufactured urgency is transparent.
- Negotiate warmly and factually: enthusiasm for the role + specific ask + reason. Adversarial negotiation with your future director is winning the battle and poisoning the well.
- What's negotiable beyond comp: start date, level-review timeline in writing, team placement, conference/education budget, remote arrangements
- Toronto-relevant mechanics: know whether the number is CAD or USD, cross-border payroll structures (some US companies employ in Canada via subsidiaries or EORs with different equity treatment), and that Ontario employment agreements deserve a read of the termination clause before signing — non-competes are largely unenforceable in Ontario but the termination language matters
- References: line them up before offers; brief each on the role and the two things you want emphasized
34. Updated Weekly Integration
Fold Part III into the 12-week calendar (§26) as follows, rather than extending the timeline:
- Week 2 adds §28 SLO math and burn-rate alerting alongside distributed-systems core (they're the same conversation)
- Week 4 adds §30 delivery engineering (trunk-based, flags, contract testing) — it pairs naturally with the streaming/API week's CI concerns
- Week 7 adds §28.2–28.4 (OTel, chaos, load testing) to the compute/K8s week — instrument what you deploy
- Week 8 adds §32 DR (it's the availability half of the security week)
- Week 10 adds §29 Team Topologies + DDD + decision machinery to the leadership week — these ARE the leadership vocabulary
- Week 11 adds §31: rehearse invoking two frameworks with critiques per mock answer
- Week 12 adds §33 offer-stage prep once onsites are scheduled
The drill bank (§25) gains these; same 90-second rule:
- Design burn-rate alerts for a 99.9% SLO. Why two windows?
- A label explosion took down Prometheus. What happened and what's the policy fix?
- How do you test 40 microservices without a full integration environment?
- Deploy vs release — explain the distinction and what it buys you.
- Split this 15-person team. Walk your reasoning. (Answer with cognitive load + stream alignment, not layers)
- When is a modular monolith the right answer, and what forces an extraction?
- Your experiment won its target metric and tripped a latency guardrail. Ship it?
- The business says "we can't lose any data and can't be down." What do you say next? (Extract RTO/RPO, show the cost curve)
- Walk me through an untested assumption in your current architecture and how you'd chaos-test it.
- You've been down-leveled in an offer. What do you do?
Part III added after coverage audit: SRE/observability depth, architecture and org-design vocabulary (DDD, Team Topologies), delivery engineering, the standards reference card, DR, and offer-stage strategy.
PART IV — Patterns, Contracts, and the Human Layer
35. Design Patterns & Code Architecture
The staff-level framing to internalize before any of the content: patterns are vocabulary, not virtue. You get points for naming the pattern a design already implies, for using the shared name to speed up a conversation, and for knowing when a pattern is overkill. You lose points for pattern-driven design — introducing machinery a simpler construct would serve. Interviewers at this level are often testing for the restraint, not the catalog.
35.1 SOLID — with the mature caveats
- S — Single Responsibility: one reason to change. The useful test is "who asks for changes to this module" — if two stakeholders, split it. The failure mode of over-applying it: a codebase of 40-line classes where no behavior lives anywhere.
- O — Open/Closed: extend without modifying — via polymorphism, strategy, composition. Honest caveat: speculative extension points are debt; make things extensible when the second use case arrives, not before.
- L — Liskov Substitution: subtypes must honor the base type's contract (preconditions can't strengthen, postconditions can't weaken). The classic violation to cite:
Square extends Rectangle. In practice this is why you prefer composition over deep inheritance. - I — Interface Segregation: many narrow interfaces over one fat one; clients shouldn't depend on methods they don't use. Directly maps to API design and gRPC service decomposition.
- D — Dependency Inversion: depend on abstractions; high-level policy shouldn't import low-level detail. This is the principle hexagonal architecture is built on — connect them out loud.
- The seasoned add: SOLID is object-oriented vocabulary. Be able to translate — in functional style, S and D become pure functions and effects-at-the-edges; in Go, small interfaces defined at the consumer.
35.2 The GoF subset that actually appears
Don't memorize 23; hold the ~10 that come up, each with the one-line "when":
- Strategy — swap an algorithm at runtime; the answer to most "if/else on type" smells
- Factory Method / Abstract Factory — creation behind an interface; in modern code mostly subsumed by DI containers
- Builder — many optional parameters, immutable results; standard in Java/fluent APIs
- Adapter — reshape an interface you don't own; the code-level anti-corruption layer
- Facade — one simple front over a messy subsystem; the pattern behind every good SDK
- Decorator — layer behavior without inheritance; how middleware, interceptors, and reader/writer wrappers work
- Observer — event listeners; know its distributed cousin is pub/sub and its failure mode is hidden coupling and ordering assumptions
- Template Method vs Strategy — inheritance vs composition for varying a step; prefer strategy
- Chain of Responsibility — middleware pipelines, servlet filters, gRPC interceptors
- Singleton — name it as an anti-pattern in application code (global state, test hostility); acceptable as managed single instances via a DI container
- Concurrency honorable mentions: producer–consumer, thread pool, future/promise, actor model (Akka/Erlang framing), and immutability as the pattern that makes the rest unnecessary
35.3 Enterprise patterns (Fowler's PoEAA — the interview-relevant slice)
- Repository — collection-like abstraction over persistence; domain code speaks
findByX, not SQL. The nuance that shows experience: with a capable ORM, a generic repository over it is often a pointless extra layer ("repository over Repository"); the pattern earns its keep when it isolates the domain from the persistence model, enables test doubles, or fronts multiple stores. Say the critique. - Unit of Work — track changes, commit atomically; what an ORM session/EntityManager already is
- Specification — composable query predicates as objects; useful when business rules about selection must be reused and combined
- Data Mapper vs Active Record — separation vs convenience; Active Record couples domain to schema and is fine for CRUD apps, painful past that
- DTOs and mapping boundaries — never let persistence entities leak into API contracts; version DTOs, not tables
- Anti-corruption layer — reappears from DDD (§29.1); at code level it's adapters + translators at the boundary of a legacy or third-party model
- Transaction Script vs Domain Model — the honest answer that simple workflows deserve simple procedural code; rich domain models pay off only with rich invariants
35.4 Application architecture styles
- Hexagonal / Ports & Adapters: domain core with ports (interfaces) implemented by adapters (DB, HTTP, queue). The point: the domain is testable without infrastructure, and infrastructure is swappable. Clean and Onion architecture are the same idea with different diagrams — say so; it defuses terminology quizzes.
- The dependency rule: source dependencies point inward, toward policy. Frameworks, DBs, and delivery mechanisms are details at the edge.
- CQRS: separate write model from read model. The ladder of adoption: separate handlers → separate models → separate stores. Most teams need step one; step three plus event sourcing is a specialized commitment (§17.6). Being explicit about the ladder is the senior signal.
- Vertical slice architecture — organize by feature, not by layer; the modern counterpoint to strict layered/clean layouts, and a genuinely good default for product teams. Having both positions and a preference is exactly the kind of opinion interviews reward.
- Anti-patterns to name fluently: anemic domain model (data classes + service-layer procedures pretending to be DDD), god object, big ball of mud, distributed monolith (microservices with synchronous chains and a shared DB — the most expensive anti-pattern of the last decade), golden hammer, premature abstraction (wrong abstraction is costlier than duplication — the "rule of three")
- Resilience patterns (connect back to §5.1 rather than restate): circuit breaker, bulkhead, retry-with-jitter, timeout budgets, fallback, load shed — implemented today in mesh/middleware (Envoy, Resilience4j) rather than hand-rolled
- Refactoring vocabulary: strangler fig (again — it's the answer at code and system level), branch by abstraction (refactor safely on trunk without long-lived branches — pairs with §30.1), parallel change/expand-contract, seams (Feathers) for getting legacy code under test, characterization tests before touching untested code
35.5 How this shows up in interviews
- Code review rounds: naming the smell + the pattern-shaped fix + the simpler non-pattern fix, and recommending the simpler one when warranted
- Design rounds: "this boundary is a port; the Kafka consumer is just an adapter" — one sentence that reframes your whole diagram as testable
- Behavioral: a story about removing an abstraction is rarer and stronger than one about adding one
36. SLA vs SLO vs SLI — the Contractual Layer
§28 covered the engineering; this is the business-facing half a lead is expected to own.
- SLI — the measurement (e.g., fraction of requests under 300 ms). SLO — the internal target on that measurement (99.9% monthly). SLA — the external contract: an SLO subset promised to customers with remedies attached (service credits, termination rights). The one-liner: SLIs are what you measure, SLOs are what you aim for, SLAs are what you'll pay for.
- Always set the SLA looser than the SLO. Internal 99.95%, contractual 99.9% — the gap is your reaction buffer. Promising your SLO as your SLA means every internal miss is a customer credit.
- Read the fine print like an operator: measurement window (monthly vs quarterly changes everything), exclusions (scheduled maintenance, force majeure, customer-caused), who measures (their monitoring or yours), claim process (credits usually require the customer to file — which tells you how often they pay out)
- Your dependencies' SLAs bound your own. Compose them: if your product SLA is 99.9% but you synchronously depend on a vendor offering 99.5%, you've promised something you can't structurally deliver — you need redundancy, degradation, or a renegotiated promise. Walking this math is a genuinely impressive interview moment.
- OLA (operational level agreement) — the internal-team version of an SLA; useful vocabulary when discussing platform teams' commitments to stream teams (ties to §29.3's platform-as-product)
- Cloud SLA literacy: know that a typical 99.99% SLA credit caps at a fraction of the monthly bill — SLA credits are not insurance, they're an apology. Your availability architecture, not the vendor's SLA, is what protects revenue.
- Per-tenant/enterprise SLAs create engineering requirements: tenant-level SLI measurement, priority routing or isolation for premium tiers, and reporting. If sales sells a custom SLA, engineering inherits a custom observability requirement — say this; it's the lead-level insight.
- Error-budget-based SLA management: alert internally at SLO burn long before SLA breach; the SLA breach postmortem is a business event with comms, credits, and an exec readout, not just an engineering one.
On STAR vs SCOR: the doc teaches SCOR (§8.2) because it adds the Options slot. If an interviewer explicitly asks for STAR (Amazon often frames it that way), map cleanly: Situation → S+C, Task/Action → your chosen option and execution (compress the alternatives into one sentence — "we weighed X and Y and chose Z because…"), Result → R plus reflection. Same story, reshaped on demand. Practicing one story in both formats once is enough.
37. The Question Playbook, by Interviewer
§12 gave the director set. This is the full persona-by-persona version. The strategic frame first:
Your questions are scored. Every interviewer reports what you asked. Questions do three jobs at once: (1) gather real decision data, (2) demonstrate seniority by what you think to ask about, and (3) let the interviewer talk about themselves — which measurably improves how they remember the conversation. Ask questions only someone who has operated at the level could ask. Never ask anything the careers page answers.
Mechanics: 2–3 questions per session, matched to the persona; keep one universal spare for time-boxed endings; write their answers down — contradictions between interviewers are the highest-value diligence signal you can collect.
37.1 Recruiter screen
Goal: process intelligence and calibration. This person wants you to succeed; use that.
- "How is the loop structured, and what is each round actually evaluating?"
- "What level is this role calibrated at, and what does the committee look for at that level?" (asking about leveling early prevents the down-level surprise in §33)
- "What's the team's timeline, and how many candidates are in process?"
- "What has caused candidates to fall out of this loop?" (recruiters will often just tell you)
37.2 Hiring manager
Goal: understand the job behind the job description, and their theory of the role. This round is bidirectional evaluation at its most concentrated.
- "What's the problem in your org that made you open this req — what breaks or stays broken if it goes unfilled for six months?" (the single best HM question; the answer is the real job)
- "Walk me through the team: tenure, levels, who's strongest at what, and where the gaps are."
- "What did the last person in this seat (or the interim owner) struggle with?"
- "What would the first two deliverables be, and how will you personally judge whether they went well?"
- "How much of my time do you expect in code / in design / in people work at months 3 and 12?" (surfaces TL-vs-EM ambiguity before you accept the wrong job)
- "What's your operating rhythm with your leads — 1:1 cadence, what you want escalated, how you deliver hard feedback?"
- "Where do you and your manager currently disagree about this team's direction?" (bold; asks for candor and almost always gets a revealing answer)
37.3 Peer engineers / future reports
Goal: ground truth. ICs are the least media-trained people in the loop.
- "Walk me through your last production incident — how did it go, and did the postmortem action items actually happen?"
- "How long does a one-line change take to reach production, end to end?" (one number that reveals the whole delivery system)
- "What's the piece of the codebase everyone avoids, and why is it still like that?"
- "When you disagreed with a technical direction recently, what happened?"
- "What would you fix first if you had a month of unscheduled time?"
- For future reports specifically: "What do you want from your next lead that you're not getting today?" (also quietly demonstrates the kind of lead you'd be)
37.4 Director / skip-level
The §12 set applies; add the seasoned tier:
- "What are you accountable for this year that this team materially affects?"
- "When this team missed or slipped in the past year, what was the real cause — and what changed afterward?"
- "How do headcount and priority trade-offs get decided between your teams?"
- "What behavior gets people promoted here in practice — and what behavior gets tolerated that you wish didn't?" (the gap between those two answers is the actual culture)
- "If I'm sitting here in a year and this hire clearly worked, what happened? And what's the most likely way it fails?"
37.5 VP / CTO / executive round
Goal: strategy comprehension. Ask about the business, not the team — showing you think at their altitude is the entire point.
- "How does engineering show up in company strategy — cost center, product differentiator, or the product itself?"
- "What's the bet the company is making that you think is under-appreciated?"
- "How is the org thinking about AI structurally — product capability, productivity layer, or both — and who owns that call?"
- "What would make you say, two years from now, that engineering leadership hiring in this era was a success?"
- Then one that lands your positioning: "The way I've operated is X; where would that help most here, and where would it clash?"
37.6 Product / design / cross-functional partner
Goal: assess the partnership you'll live in — and signal you value it.
- "Walk me through how the last major feature went from idea to shipped. Where was the friction?"
- "When engineering pushes back on scope or dates, how does that usually go?"
- "Do engineers here engage in discovery, or receive requirements?"
- "What does engineering do that makes your job harder — honestly?"
37.7 Bar raiser / neutral third party (Amazon-style)
This person is deliberately outside the team; they evaluate long-term and culture. Ask questions that show you understand their function:
- "What does the bar look like at this level from where you sit — what separates a hire from a strong-but-no?"
- "You've seen many teams here — what distinguishes the ones that work?"
- Keep it shorter; bar raisers are time-boxed and score judgment density, not question count.
37.8 The universal closers (have all three loaded)
- "What's the question I should have asked about this role that I haven't?"
- "Based on this conversation, is there anything about my fit you're still unsure of? I'd rather address it now." (from §12 — it converts silent objections into answerable ones; use it in the final round or with the HM, once per loop)
- "What made you stay?" (works on anyone with tenure; the pause before the answer is data)
37.9 Reverse due diligence — the red-flag checklist
You're evaluating them. Patterns that predict a bad tenure, gathered across the loop:
- Interviewers describe the same team completely differently (no shared reality)
- Nobody can name what success looks like for the role (you'll inherit an unwinnable mandate)
- The role is open because two predecessors left inside 18 months and no one will say why
- Every answer about problems is "we just need to hire great people" (the problem is not headcount)
- The HM can't describe their own manager's expectations (air cover doesn't exist)
- Postmortem action items "usually get done" with no examples (reliability theater)
- All decision authority routes through one person, however impressive (you'd be a senior pair of hands, not a lead)
- Visible contempt between product and engineering in how each describes the other
- They can't explain why the level is what it is (leveling chaos follows you in) Weigh patterns, not single data points — any org has one bad answer. Three of these across a loop is a signal.
38. Signals of Seasonality — the Unasked-For Essentials
The things that make an interviewer write "operates like they've done this for years" — none of which appear in a topic list.
38.1 Executive communication mechanics
- Answer first (BLUF), then reasoning. "Yes — for three reasons" beats two minutes of context arriving at yes. This is the Pyramid Principle in one habit, and it's the fastest single upgrade available to most engineering candidates.
- Calibrate altitude to audience in real time: with a VP, lead with outcome and risk; with an IC, lead with mechanism. Mid-answer, watch for the glazed look and zoom out one level without being asked.
- Signposting: "There are three parts to this — cost, risk, and timeline. Cost first." Interviewers literally take notes in your structure.
- Quantify reflexively, and bound your uncertainty: "roughly 40%, could be 25–60" reads as more credible than a false-precision "43%."
38.2 Handling what you don't know
The moment interviewers most remember. The seasoned sequence: state the boundary plainly → reason from adjacent knowledge → name how you'd find out. "I haven't run Scylla in production. Here's what I'd expect to transfer from Cassandra, here's where I'd expect the shard-per-core model to change the tuning story, and here's what I'd benchmark first." Never bluff — at this level interviewers probe two layers past your claimed knowledge specifically to find the bluff, and one caught bluff outweighs ten strong answers. Conversely, "I don't know" with no reasoning attempt is a wasted at-bat. The middle path is the skill; drill it deliberately on questions at the edge of your knowledge.
38.3 Scar tissue — the stories only operators have
Prepare 4–5 short "I learned this the expensive way" fragments. They're deployable inside technical answers, not just behavioral rounds, and they're unfakeable:
- The migration that was "done" until the long tail of stragglers took longer than the migration
- The cache that hid a correctness bug for months
- The heroic engineer whose heroics were masking a process failure — and what happened when they took vacation
- The dashboard that was green through a sev1 because it measured the wrong thing
- The re-architecture you didn't do, and why restraint was right Delivered in 30 seconds, attached to a relevant technical point, these do more than any framework citation.
38.4 "It depends" — done correctly
Junior "it depends" stops there. Seasoned "it depends" immediately names the two or three variables it depends on, states which case you'd bet on given what you know of their context, and commits: "It depends on read/write ratio and consistency needs — for what you've described, which sounds read-heavy with tolerance for seconds of staleness, I'd pick X." Conditional, then decisive. Interviewers are explicitly listening for whether you land the plane.
38.5 Whiteboard & remote mechanics
- Narrate your zoom level (C4 language from §29.4), label arrows with protocols and data, write the numbers on the board — a diagram with QPS and p99 on it photographs like experience
- Manage the clock out loud: "We have 20 minutes left — deep-dive the ranking service or the ingestion path?" Handing the interviewer that choice is itself a leadership behavior being scored
- Remote logistics as professionalism signals: tested audio, wired connection where possible, a fallback ("if I drop, I'll rejoin from my phone"), Excalidraw/tldraw fluency so shared drawing costs you nothing, camera at eye level, notes okay but never read from
- Interview-day stamina is trainable and mostly ignored: full-loop simulation (§3 week 7) exists precisely because round 5 of 6 is where unrehearsed candidates fade; protein at lunch, water, stand between rounds
38.6 Operator literacies that surface in passing
You're rarely asked these directly; they leak out of good answers and mark seniority when they do:
- Budget fluency: loaded headcount cost (~2× salary), the build-vs-buy math including opportunity cost, cloud unit economics, why a $200k tool replacing half an engineer-year of toil is cheap
- Vendor management: running an eval with weighted criteria, negotiating with a genuine BATNA, exit-cost accounting before signing, managed-service SLAs as apologies not insurance (§36)
- Glue work (Tanya Reilly's framing): the non-promotable coordination work that makes teams function — the lead's job is to see it, value it in calibration, and distribute it deliberately rather than letting it accrete on whoever is most conscientious. Naming glue work unprompted is a strong people-leadership signal.
- Managing former peers: the transition conversation done explicitly and early, renegotiating friendships around new information asymmetry, the trap of keeping the fun technical work for yourself, and over-indexing on fairness in the first quarter because everyone is watching for favoritism
- Legacy modernization narrative: every senior loop eventually asks about old systems. The seasoned arc: understand before judging (the code is the way it is for reasons that were once good) → characterization tests → seams (§35.4) → strangler increments with value delivered at every step → celebrate deletion. Contempt for legacy code is a junior tell; respect for it plus a plan is the senior one.
- Remote/hybrid leadership specifics: async-first documentation as the default, explicit overlap-hours contracts, deliberate onboarding redesign (remote onboarding fails silently), watching for the proximity-bias promotion pattern in hybrid teams
38.7 Follow-up etiquette that compounds
- Same-day, short, specific: one line of thanks + one substantive continuation (the §11 artifact — a cleaned-up diagram, a source relevant to a discussion point). Before the debrief writes itself.
- If you flubbed something and know it: one-line correction in the follow-up ("I said X for the quorum math; on reflection it's Y") — this has rescued candidacies, because it demonstrates the exact self-correction behavior the loop tries to measure
- Post-rejection grace: ask for specifics, thank them, stay warm. Loops recycle interviewers and recruiters across companies for decades; several of your future offers are downstream of how you handled a past no.
38.8 Drill bank additions
- Explain repository pattern, then argue against using it in a given codebase.
- "Is CQRS a good idea for us?" — walk the adoption ladder and place them on it.
- Our vendor offers 99.5% and we sell 99.9%. What are our options?
- An interviewer asks a question you genuinely can't answer. Perform the §38.2 sequence on a real gap of yours.
- You have 90 seconds with the CTO at the end of the loop. What do you ask?
- Rewrite one of your SCOR stories as STAR on the spot.
- Name three red flags from §37.9 you'd probe for, and the exact questions you'd use to surface each without being adversarial.
- Tell a 30-second scar-tissue story that would fit inside a caching design answer.
Part IV added: design patterns and code architecture with the restraint framing, the SLA contractual layer and STAR mapping, the persona-by-persona question playbook with reverse due diligence, and the seasonality signals that don't appear on topic lists.
39. Deployment & Progressive Delivery (Consolidated Deep Dive)
Deployment appeared in fragments (§5.1, §19.3, §30.4); this section is the full treatment, because "how do you ship safely" is a guaranteed question at lead level and the answer is a system, not a technique.
39.1 The strategy matrix — mechanics, cost, and when
| Strategy | Mechanics | Rollback | Cost | Use when | Gotchas |
|---|---|---|---|---|---|
| Recreate | Stop old, start new | Redeploy old | None | Dev/test, singleton batch jobs | Downtime by design |
| Rolling | Replace instances in batches (maxSurge/maxUnavailable in K8s) | Roll back batch by batch — slow | Low | The default for stateless services | Two versions live simultaneously → N-1 compatibility required; a bad version can be 60% rolled out before detection |
| Blue/green | Full parallel environment; cut traffic over at the router | Instant — flip back | 2× capacity during deploy | Low-tolerance-for-bad-minutes services; big-bang framework upgrades | Stateful connections drop at cutover; DB is shared, so schema still needs expand–contract; 2× cost tempts teams to skip it exactly when it matters |
| Canary | Small % of real traffic to new version; widen on healthy metrics | Shift weight to 0 | Low | The default for anything with meaningful traffic | Needs enough traffic for statistical signal; sticky sessions can starve the canary; must compare canary vs contemporaneous baseline, not vs history |
| Shadow / mirror | Copy of live traffic to new version; responses discarded | N/A — no user exposure | ~2× compute for mirrored path | Rewrites, perf validation, ML model validation | Side effects: mirrored writes must hit a sandbox or be idempotent-suppressed; async downstream effects (emails, payments) are the classic disaster |
| Ring-based | Deploy in expanding rings: team → internal users → 1% → region → world (Microsoft-style) | Halt at current ring | Low | Large user bases, client software, OS/platform teams | Slow by design; ring 0 users are unrepresentative — don't tune on them |
| Rolling + surge regions | Region-by-region with bake time between | Halt sequence, evacuate region | Low | Multi-region services | Deploy order should follow traffic (lowest first), respect timezone peaks, and never deploy to all regions inside one bake window |
The composite answer that sounds like practice: rolling as the mechanism, canary as the policy, flags as the exposure control, rings for the blast-radius sequencing. They compose; they're not competitors.
39.2 Traffic-shifting mechanics (how the percentage actually happens)
- Load balancer weights (ALB weighted target groups, nginx upstream weights) — simplest, service-level granularity
- Service mesh (Istio VirtualService, Linkerd TrafficSplit) — per-request routing with header/cookie match: lets you canary internal services and send only employees or a consistent user cohort to the new version
- DNS weighting — coarse and slow (TTL caching); use for region-level only, never for fine-grained canary
- Consistent cohorting: hash on user ID, not random per request — a user flapping between versions mid-session experiences bugs neither version has. This detail is a strong practitioner signal.
- Sticky-session interaction: session affinity can pin your canary's traffic share below its configured weight; measure actual share, not intended share
- Client-side / mobile: you don't control the deploy — staged rollouts via app stores (1% → 100%), feature flags as the real control plane, server-driven config, and the discipline that API servers support N-2 client versions because mobile upgrades take weeks. Forced-upgrade mechanisms as the emergency brake.
39.3 Automated canary analysis (the depth interviewers probe)
- Compare canary against a contemporaneous baseline cohort of the same size — not against the whole fleet (different scale skews percentiles) and not against last week (traffic mix differs)
- Metric set: the SLIs (error rate, p50/p99 latency) + saturation (CPU, memory, GC) + a small set of business guardrails (checkout rate, search CTR). Kayenta-style scoring: per-metric pass/fail (Mann-Whitney U or similar), weighted aggregate, promote/hold/rollback thresholds
- Bake time matters as much as percentage: memory leaks, cache-warmup effects, and cron-triggered paths need hours, not minutes, at each step. A canary schedule is percentage × duration, e.g., 1%/30m → 5%/1h → 25%/2h → 100%
- Statistical honesty: at 1% of low traffic you cannot detect a 0.1% error-rate regression — know your minimum detectable effect, or your canary is a ritual
- Auto-rollback on breach with a human notification, not a human approval — at 3 a.m. the automation is the on-call
- What canaries can't catch: slow-burn data corruption, issues triggered by scale itself, coordination bugs that need both versions interacting. Name these limits; it's the difference between using a tool and understanding it
39.4 Canary vs A/B testing — the distinction interviewers fish for
Same mechanism (traffic splitting), different question and different math:
- Canary asks "is this version safe?" — operational metrics, minutes-to-hours horizon, asymmetric decision (any regression → rollback), run by the deploy system
- A/B asks "is this change better?" — product metrics, days-to-weeks horizon, requires pre-registered hypothesis, power analysis, fixed horizon or sequential correction (§30.5), run by the experimentation platform
- The seasoned line: every A/B variant rides through canary first — safety gates before measurement begins. Conflating them produces the classic failure: "the experiment shows +2% conversion" from a two-day peek with no power analysis, on a variant that was also 30 ms slower and nobody checked.
- Shared infra, separate concerns: one traffic-splitting layer can serve both, but ownership, metrics, and stopping rules must stay distinct
39.5 State, data, and the deploy
- Every strategy above assumes N-1 (ideally N-2) compatibility between code and schema: expand–contract (§30.4) is not optional garnish, it's the precondition. Column adds are safe; renames/drops ride the contract phase only after all code versions in the wild stopped reading them
- Event/message compatibility: consumers deploy before producers when the schema changes; schema registry compatibility modes (§17.1) enforced in CI are the mechanism
- Caches across versions: serialization changes in cached objects break the old version during rollback — version your cache keys with the schema, not the deploy
- Long-lived connections (WebSocket, gRPC streams): drain with connection deadlines and client reconnect logic; a "zero-downtime" deploy that severs 50k WebSockets is not zero-downtime to users
- Stateful services (databases, Kafka, stateful sets): rolling with quorum awareness — never take down more than the fault tolerance (one replica at a time for RF=3), verify catch-up/ISR before proceeding. PodDisruptionBudgets (§19.3) encode exactly this
- Rollback is a forward motion for data: you can roll code back, you cannot un-write data. Migrations that transform data need a reverse migration tested before deploy, or an explicit roll-forward-only declaration with sign-off (§30.4)
39.6 The delivery pipeline as a system (the lead-level answer)
When asked "walk me through how a change ships," narrate the whole system:
- Trunk merge behind a flag (§30.1–30.2) → CI: tests, contract checks, security scans, artifact build, immutable artifact signed once, promoted everywhere
- Auto-deploy to staging → smoke + synthetic checks
- Canary in the lowest-traffic production region, ACA gates each step (§39.3)
- Ring/region rollout with bake times; deploy freezes as data-driven policy (error-budget-based, §28.1), not calendar superstition — though respecting genuine peak events (Black Friday) is judgment, not superstition
- Flag ramp-up separately from deploy (deploy ≠ release, §30.4) with its own metrics
- Post-deploy: dashboards linked from the deploy notification, deploy markers on every graph (the single cheapest observability win — most incidents correlate with a deploy, and the marker turns an hour of diagnosis into a glance)
- Everything above applies to config and flags too — config changes cause as many outages as code and usually ship with less ceremony. Config canarying is a mature-org tell.
Metrics that prove the system works: deploy frequency and lead time (§23.9), rollback rate, mean-time-to-rollback (minutes, not "we'd redeploy"), % of deploys that are automated end to end, and change failure rate split by code vs config vs flag.
39.7 Drill bank additions
- Canary vs A/B — a PM says "the canary shows the feature is winning." Correct the confusion kindly.
- Design the canary schedule for a payments service doing 200 QPS. What can't you detect, and what do you do about that?
- Shadow-test a rewrite of a service that sends emails. Walk the side-effect containment.
- A rollback fails because the old version can't read new-format cache entries. What was the process failure, and what's the fix?
- Your mobile app's new version crashes for 2% of users; the store rollout is at 40%. Walk the response.
- Deploy a schema change that renames a column across a 3-version compatibility window. Sequence it.
§39 added: consolidated deployment and progressive delivery — strategy matrix, traffic-shifting mechanics, automated canary analysis, canary-vs-A/B, state and data coordination, and the pipeline-as-a-system narrative.
PART V — EXPANDED REFERENCE
Parts I–IV are the map. Part V is the territory: every term defined, every claim shown with code or a real system, every topic closed with follow-up questions and primary sources. Read Part V when you want to actually know the thing; read Parts I–IV to remember what to review.
Chapter roadmap (built in order of interview weight):
- §40 Distributed Systems & Performance Foundations ← this turn
- §41 LLM Serving, Inference & the Economics of Tokens ← this turn
- §42 Storage Engines & Databases, Expanded
- §43 Retrieval, Search & Ranking, Expanded
- §44 Agentic Systems & Context Engineering, Expanded
- §45 Kubernetes, Compute & the Kernel, Expanded
- §46 Streaming, Reactive & API Layers, Expanded
- §47 Security & Zero Trust, Expanded
- §48 Deployment, SRE & DR, Expanded
- §49 Leadership Scenarios: Full Worked Answers
- §50 Complete Drill Bank Answers (all 66+)
40. Distributed Systems & Performance Foundations
40.1 The Latency Numbers — and What to Actually Do With Them
The canonical table (Jeff Dean's "Numbers Everyone Should Know," updated for modern hardware). Memorize the orders of magnitude, not the digits.
| Operation | Latency | Mental anchor |
|---|---|---|
| L1 cache reference | 1 ns | 1 second |
| Branch mispredict | 3 ns | 3 seconds |
| L2 cache reference | 4 ns | 4 seconds |
| Mutex lock/unlock (uncontended) | 17 ns | 17 seconds |
| Main memory reference | 100 ns | 1.5 minutes |
| Compress 1 KB with Snappy | 2 µs | 33 minutes |
| Read 1 MB sequentially from memory | 3 µs | 50 minutes |
| SSD random read (NVMe) | 16 µs | 4.5 hours |
| Read 1 MB sequentially from NVMe SSD | 50 µs | 14 hours |
| Round trip within same datacenter | 500 µs | 5.8 days |
| Read 1 MB sequentially from spinning disk | 2 ms | 23 days |
| Disk seek | 3–10 ms | 35–115 days |
| Round trip CA → Netherlands → CA | 150 ms | 4.8 years |
Derived facts worth having on instant recall:
- Memory is ~100× faster than NVMe; NVMe is ~100× faster than a network round trip across regions. Every architecture decision is a placement decision on this ladder.
- Sequential is ~100–1000× faster than random on disk, ~4× on memory (prefetching). This single fact is why LSM trees exist, why Kafka is fast, and why columnar formats win at analytics.
- Speed of light in fiber ≈ 200,000 km/s → ~5 µs per km, ~1 ms per 100 km round trip. You cannot get from Toronto to Frankfurt in under ~60 ms. Ever. When someone asks for a 20 ms global p99, the answer is edge presence, not optimization.
- A 1 Gbps link moves ~125 MB/s → 1 GB takes 8 seconds. A 10 Gbps link, 0.8 s.
Worked back-of-envelope: sizing a search service
"Design search for 50M monthly actives, 20 searches/user/month, p99 < 200 ms, 500M documents."
Traffic
50M MAU × 20 searches = 1B searches/month
1B / (30 × 86400) ≈ 386 QPS average
Peak = 3-5× average → ~1,500 QPS peak
Design headroom 2× → provision for 3,000 QPS
Storage (inverted index)
500M docs × 2 KB avg text = 1 TB raw
Inverted index ≈ 30-50% of raw text → ~400 GB
Stored fields + doc values → ~1.5× → ~600 GB
Replication factor 2 → 1.2 TB total
Shard target 30 GB → 600/30 = 20 primary shards, 40 total
Vector index (if hybrid)
500M × 768 dims × 4 bytes (fp32) = 1.5 TB just for vectors
→ int8 quantization: 384 GB. Still too big for one node.
→ HNSW graph overhead ≈ M × 8 bytes × N; M=16 → 64 GB
Conclusion stated out loud: "vectors don't fit in RAM at fp32;
I'd quantize to int8 and shard across ~8 nodes with 64 GB each,
or use IVF-PQ if recall tolerance allows."
Latency budget (p99 = 200 ms total)
Network in/out 10 ms
Query parsing/rewrite 5 ms
Lexical retrieval (BM25) 30 ms ← fan-out to 20 shards, gather
Vector retrieval (ANN) 40 ms ← parallel with lexical
Fusion (RRF) 2 ms
Reranking (cross-encoder, top-100) 80 ms ← the fat one
Serialization/response 10 ms
─────────────────────────────────
Total ~177 ms, 23 ms slack
Immediately name the risk: "reranking is 45% of the budget and
it's a GPU call — that's my tail-latency exposure. I'd cap it at
top-50, set an 80 ms deadline, and fall back to fusion-only
ranking on timeout rather than blowing the SLO."
That last paragraph — naming where the tail lives and pre-committing a degradation — is what separates a staff answer from a senior one.
Little's Law: the one formula that governs capacity
L = λW — average number of items in a system = arrival rate × average time in system.
Applied to a service: concurrency = throughput × latency.
A service holds p99 latency of 50 ms and must serve 3,000 QPS.
concurrency = 3000 × 0.050 = 150 concurrent requests in flight.
If each request needs a thread and a DB connection:
→ 150 threads minimum (plus headroom → ~200)
→ the DB pool must sustain 150 concurrent queries, or the pool
becomes the bottleneck and latency rises, which raises
concurrency, which exhausts the pool — the classic death spiral.
Inverted: your connection pool is 50. What throughput can you serve
at 50 ms latency?
λ = L/W = 50/0.050 = 1,000 QPS. Hard ceiling. No amount of
application servers changes it.
Interview use: when someone asks "how many instances do you need," Little's Law is the answer, not intuition. When someone asks "why did latency explode at 80% load," the answer is queueing theory below.
Queueing theory: why you run at 70%
For an M/M/1 queue, average wait time scales as W = S / (1 − ρ) where S is service time and ρ is utilization.
| Utilization ρ | Latency multiplier 1/(1−ρ) |
|---|---|
| 50% | 2× |
| 70% | 3.3× |
| 80% | 5× |
| 90% | 10× |
| 95% | 20× |
| 99% | 100× |
This is the entire justification for headroom targets. At 90% CPU your latency is 10× its unloaded value and a 5% traffic bump doubles it again. The knee is around 70–80%; that's why SRE capacity targets live there — not conservatism, arithmetic.
Real variability (M/G/1) makes it worse: W = S·ρ/(1−ρ) · (1+C²)/2, where C is the coefficient of variation of service time. High variance in service time destroys tail latency even at moderate utilization — which is why one slow query type poisons a shared thread pool, and why bulkheads (separate pools per workload class) exist.
The Universal Scalability Law — why adding machines stops helping
Amdahl's Law says the serial fraction caps your speedup. Gunther's USL adds the term that actually bites in distributed systems:
C(N) = N / (1 + α(N−1) + βN(N−1))
α = contention (serialization, locks, the serial fraction)
β = coherence (crosstalk — nodes must agree with each other)
The β term is the killer: because it's quadratic in N, throughput doesn't just plateau, it declines past an optimum. This is why:
- A 5-node etcd cluster is faster than a 7-node one for writes (more nodes = more replication coherence, same quorum semantics)
- Adding app servers to a system bottlenecked on a shared lock makes things worse
- Cassandra scales near-linearly (β≈0, no cross-node coordination on the write path) while a distributed transaction system does not
Interview line: "I'd expect this to scale linearly until the coordination term dominates — for this design, that's the shared sequence generator, and I'd remove it with client-side ID generation (Snowflake-style) before it becomes the ceiling."
The Tail at Scale — Dean & Barroso's playbook
The core insight: if a request fans out to 100 servers and each has a 1% chance of a >1s response, then 63% of requests take >1s (1 − 0.99¹⁰⁰). Tail latency at the leaf becomes median latency at the root. Fan-out amplifies tails.
Google's mitigations, in the order you should name them:
- Hedged requests — send to one replica; if no response by p95, send a duplicate to another replica, take the first answer, cancel the other. Google reported this cutting p99 dramatically at ~2% extra load. Cheap and effective.
- Tied requests — send to two replicas simultaneously, each request carrying the identity of the other. Whichever server starts the work first sends a cancellation to its twin. Removes the p95 wait of hedging at the cost of a small window of duplicate work.
- Micro-partitioning — partition into far more shards than machines (e.g., 20 partitions per machine), so rebalancing is fine-grained and a hot partition can be moved without moving a machine's whole load. This is how Bigtable and Slicer manage skew.
- Selective replication — detect hot items and add replicas for those items only.
- Latency-induced probation — temporarily exclude a slow replica from the pool while continuing to send it shadow traffic to detect recovery.
- Request reissue against a different replica on timeout, with per-request deadlines propagated through the whole call tree (gRPC does this natively; §46).
Implementation sketch (hedging in Go):
// Hedged request: fire the backup at p95 of observed latency.
func hedgedGet(ctx context.Context, replicas []Client, key string,
hedgeAfter time.Duration) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // cancels the loser as soon as we return
results := make(chan Result, len(replicas))
errs := make(chan error, len(replicas))
launch := func(c Client) {
r, err := c.Get(ctx, key)
if err != nil { errs <- err; return }
results <- r
}
go launch(replicas[0])
timer := time.NewTimer(hedgeAfter)
defer timer.Stop()
for i := 1; ; {
select {
case r := <-results:
return r, nil // first winner wins
case <-timer.C:
if i < len(replicas) {
go launch(replicas[i]); i++
timer.Reset(hedgeAfter)
}
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
}
The critical operational caveat to state: hedging under overload is an amplifier — if the system is slow because it's saturated, hedging adds load and accelerates collapse. Gate it: hedge only when the hedge rate is below a threshold (e.g., ≤5% of requests), and disable it when the circuit breaker is open. Say this; it's the difference between having read the paper and having run it.
Performance engineering discipline (the abseil/Google "Fast" hints, generalized)
The habits that make performance work real rather than folkloric:
- Measure, never guess. Intuition about hot spots is wrong most of the time. The first action in any performance investigation is a profile, not a code read.
- Benchmark the right thing. Microbenchmark pitfalls: dead-code elimination (the compiler deletes your unused result — use
benchmark::DoNotOptimize, JMH'sBlackhole, Go's assignment to a package-level sink), constant folding, unrealistic cache warmth (your benchmark's working set fits in L2; production's doesn't), and missing branch-predictor entropy. - Prefer end-to-end benchmarks and production profiles over microbenchmarks when they disagree — and they will. Google-Wide Profiling (always-on, sampled, fleet-wide profiling) exists because lab benchmarks systematically mislead.
- Optimize the memory hierarchy first. Most "CPU-bound" code is actually memory-latency-bound: cache misses, pointer chasing, and false sharing. Data layout beats instruction count. Struct-of-arrays over array-of-structs for scanning workloads; pad hot mutable fields to cache lines (64 bytes) to avoid false sharing between cores.
- Allocation is a first-class cost. In managed languages, allocation rate drives GC pressure, which drives tail latency. Object pooling, arena allocation, and avoiding per-request garbage are the standard fixes; in Go,
sync.Pooland escape analysis; in Java, watch allocation rate in JFR before touching GC flags. - Amdahl before micro-optimization: a 10× speedup of a component that is 5% of the total buys you 4.7%. Profile to find the 60% before touching the 5%.
- Beware the benchmark that measures the framework. If your load generator, serialization, or logging dominates, you're tuning noise.
Practical benchmarking, three languages:
// JMH — the only credible way to microbenchmark on the JVM.
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgs = {"-Xms2G", "-Xmx2G"})
@Warmup(iterations = 5, time = 1) // JIT needs warmup; without
@Measurement(iterations = 10, time = 1) // this you measure the interpreter
public class HashBenchmark {
private byte[] data;
@Setup public void setup() {
data = new byte[1024];
ThreadLocalRandom.current().nextBytes(data);
}
@Benchmark public long xxhash(Blackhole bh) {
long h = XXHash.hash(data);
bh.consume(h); // prevents dead-code elimination
return h;
}
}
// Go — note b.ReportAllocs and the sink to defeat the optimizer.
var sink uint64
func BenchmarkHash(b *testing.B) {
data := make([]byte, 1024)
rand.Read(data)
b.ResetTimer()
b.ReportAllocs() // allocations are the usual culprit
for i := 0; i < b.N; i++ {
sink = xxhash.Sum64(data)
}
}
// go test -bench=. -benchmem -count=10 | benchstat -
// Always -count>=10 and pipe through benchstat: single runs are noise.
# Profiling a live Go service (works on any pprof-enabled binary)
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/profile?seconds=30
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap
# Linux, any language: sample the whole process and make a flame graph
perf record -F 99 -p $(pgrep -f myservice) -g -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > cpu.svg
# JVM: async-profiler avoids safepoint bias that afflicts jstack-based tools
./profiler.sh -d 30 -e cpu -f /tmp/flame.html $(pgrep -f java)
./profiler.sh -d 30 -e alloc -f /tmp/alloc.html $(pgrep -f java)
# Where is the time going at the syscall level?
strace -c -p $(pgrep -f myservice) # summary of syscall counts/time
bpftrace -e 'tracepoint:syscalls:sys_enter_futex { @[comm] = count(); }'
Follow-up questions you should expect here:
- "Your p50 is fine and p99 is terrible. Where do you look?" → GC pauses, lock contention, queueing at a saturated resource, a slow dependency's tail amplified by fan-out, noisy neighbors, or a cold cache path. Order the checks by cost-to-verify: GC logs and thread dumps first, then per-dependency latency histograms, then the fan-out arithmetic.
- "Why can't you average percentiles across instances?" → Percentiles aren't linear. Averaging ten instances' p99 gives a number that corresponds to nothing. You must merge the underlying histograms (HDR histogram, Prometheus native histograms, t-digest).
- "What's coordinated omission?" → When a load generator waits for a response before sending the next request, a stall suppresses the requests that would have measured it, so the tail vanishes from the data. Fix: constant-rate (open model) load generation, e.g.,
wrk2, or k6 with arrival-rate executors.
Further reading: Dean & Barroso, The Tail at Scale (CACM 2013) — read this one twice. Gunther, Guerrilla Capacity Planning (USL). Gregg, Systems Performance (2nd ed.) — the USE method and the tooling. abseil.io/fast/ hints for C++-flavored but universally applicable benchmarking discipline. Google-Wide Profiling (Ren et al., 2010).
40.2 Consensus: Paxos, Raft, and What Runs in Production
The problem, precisely
Consensus: a set of processes must agree on a single value, satisfying agreement (no two decide differently), validity (the decided value was proposed), and termination (every correct process eventually decides).
FLP impossibility (Fischer, Lynch, Paterson 1985): in an asynchronous system with even one crash failure, no deterministic algorithm guarantees consensus. This isn't academic pedantry — it's why every real system uses timeouts. Timeouts are an imperfect failure detector that buys you termination in practice by sacrificing the guarantee in theory. When an interviewer asks "what if the leader is just slow, not dead," FLP is the honest answer: you cannot distinguish them, so you design for the consequence (a spurious election, fenced by terms/epochs).
Paxos
Single-decree Paxos, two phases, three roles (proposer, acceptor, learner):
Phase 1a PREPARE(n) proposer picks proposal number n, sends to acceptors
Phase 1b PROMISE(n, ...) acceptor promises not to accept anything < n;
returns the highest-numbered proposal it already accepted
Phase 2a ACCEPT(n, v) proposer sends value v — which MUST be the value from the
highest-numbered accepted proposal it saw, if any exists;
only if none exists may it propose its own value
Phase 2b ACCEPTED(n, v) acceptor accepts unless it promised to a higher n
That constraint in Phase 2a is the entire safety argument, and it's the part people get wrong: a proposer may not propose its own value if any acceptor has already accepted something. It must adopt the existing value. This is what makes the algorithm safe under arbitrary message reordering.
Why Paxos has a reputation: the paper describes a protocol for deciding one value. Real systems need a log of values. Multi-Paxos adds a stable leader that skips Phase 1 for subsequent slots — and everything hard (leader election, log compaction, membership change, gap filling) lives in the part the paper doesn't specify. Google's Paxos Made Live documents exactly this gap: turning the algorithm into Chubby required solving disk corruption, master leases, membership changes, and testing infrastructure — none of it in the original paper.
Where Paxos actually runs: Google Chubby (the lock service Bigtable and GFS depend on), Google Spanner (a Paxos group per tablet/split), Microsoft Azure Storage, Neo4j causal clustering (Raft actually), Apache Cassandra's lightweight transactions (a Paxos round per LWT — which is why IF NOT EXISTS costs ~4× a normal write).
Raft — understandable by design
Raft decomposes consensus into three sub-problems, which is why it won adoption: leader election, log replication, safety.
State every node holds:
type RaftState struct {
// Persistent — must survive a crash; fsync BEFORE responding to RPCs.
currentTerm int // latest term seen
votedFor int // candidate voted for in currentTerm (or none)
log []Entry // each Entry: {Term int, Index int, Command []byte}
// Volatile — all servers
commitIndex int // highest index known committed
lastApplied int // highest index applied to the state machine
// Volatile — leaders only, reinitialized after election
nextIndex []int // per follower: next log index to send
matchIndex []int // per follower: highest index known replicated
}
type AppendEntriesArgs struct {
Term int // leader's term
LeaderId int
PrevLogIndex int // index of entry immediately preceding new ones
PrevLogTerm int // term of that entry ← the consistency check
Entries []Entry // empty for heartbeat
LeaderCommit int
}
type RequestVoteArgs struct {
Term int
CandidateId int
LastLogIndex int // used for the up-to-date check
LastLogTerm int
}
The five properties that make it safe (be able to name at least three):
- Election Safety — at most one leader per term
- Leader Append-Only — a leader never overwrites or deletes its own entries
- Log Matching — if two logs contain an entry with the same index and term, the logs are identical in all preceding entries. Enforced by the
PrevLogIndex/PrevLogTermcheck on every AppendEntries. - Leader Completeness — a leader for term T contains all entries committed in terms < T. Enforced by the up-to-date vote restriction: a voter refuses a candidate whose last log entry is older (lower term, or same term but shorter).
- State Machine Safety — if a server applies an entry at index i, no other server applies a different entry at i.
The subtle rule people miss: a leader may only mark an entry committed once it has replicated an entry from its own current term to a majority. Counting replicas on an inherited entry from a previous term is unsafe (Figure 8 in the paper). The standard implementation trick is a no-op entry appended immediately on election, which both commits the tail of the previous term and confirms leadership.
Election mechanics:
- Randomized election timeouts (typically 150–300 ms, must be ≫ broadcast time) to avoid split votes
- Terms act as a logical clock; any message with a higher term forces a step-down to follower
- Pre-vote extension: before incrementing its term, a candidate asks whether it would win. Prevents a partitioned node from rejoining with an inflated term and disrupting a healthy leader. Any production Raft has this; mentioning it signals real familiarity.
- CheckQuorum / leader leases: a leader that can't reach a quorum steps down, and leases let it serve linearizable reads locally without a round trip.
Membership changes: joint consensus (both old and new configurations must agree during transition) in the original paper; most implementations use the simpler single-server-at-a-time change, which avoids overlapping-majority problems.
Log compaction: snapshots of the state machine plus InstallSnapshot RPC for followers that have fallen too far behind. Getting this wrong is the most common source of production Raft bugs.
Performance realities:
- Every commit costs one round trip to a majority plus an
fsync. Your write latency floor is fsync latency + intra-cluster RTT — roughly 1–2 ms with NVMe in one AZ, 5–15 ms across AZs. If someone asks why etcd is "slow," this is why. - Batching and pipelining are essential: batch multiple client commands into one AppendEntries; pipeline by not waiting for a response before sending the next batch.
- The leader is a bottleneck (all writes flow through it). The fix at scale is Multi-Raft: partition the keyspace into many ranges, each with its own Raft group and its own leader, spreading leadership across the cluster. This is precisely what CockroachDB and TiKV do — a range per ~512 MB of data, thousands of Raft groups per cluster, with leadership balanced by an allocator.
- Cluster sizing: 3 nodes tolerates 1 failure, 5 tolerates 2. Beyond 5 you pay replication cost for diminishing availability. Never use an even number — 4 nodes tolerates the same 1 failure as 3 while being slower and more likely to lose quorum.
Where Raft runs: etcd (and therefore all of Kubernetes' control plane state), Consul, CockroachDB (per-range), TiKV/TiDB, MongoDB (Raft-like replica set protocol), Kafka KRaft (which replaced ZooKeeper — the metadata log is itself a Raft log), RethinkDB, Neo4j, Redpanda, InfluxDB.
ZAB (ZooKeeper Atomic Broadcast) predates Raft, solves the same problem with a different decomposition, and guarantees primary order — which ZooKeeper needs and generic consensus doesn't provide. Viewstamped Replication (Oki & Liskov, 1988) is arguably first and remarkably close to Raft.
Modern variants worth naming: EPaxos (leaderless, commits in one round trip when commands don't conflict), Flexible Paxos (quorum intersection only needs to hold between phases, enabling smaller Phase-2 quorums), Raft with witness/learner replicas (cheap tie-breakers holding no data), and Delos at Meta (a virtual consensus layer that lets you swap the underlying consensus protocol without downtime — a genuinely clever piece of engineering to reference).
Follow-up questions:
- "Do you need consensus here?" — the best answer is often no. Consensus is expensive; use it for metadata, leadership, and configuration, not for the data path. Cassandra, Dynamo, and S3 achieve massive scale by avoiding consensus for normal writes (quorum replication is not consensus — it doesn't guarantee agreement on order).
- "How do you do linearizable reads without a round trip?" — leader leases (safe only with bounded clock drift), or ReadIndex (the leader confirms leadership with a heartbeat round, then serves from local state), or just accept follower reads with a staleness bound.
- "What happens in a network partition?" — the majority side elects/keeps a leader and continues; the minority side cannot commit and must reject writes. If the minority side keeps serving reads, you've chosen availability over linearizability, and you should say so explicitly.
- "Why did Kafka move off ZooKeeper?" — operational simplicity (one system, not two), metadata scalability (the controller's ZooKeeper read amplification limited partition counts), and faster failover. KRaft stores metadata as an event log replicated by Raft.
Further reading: Ongaro & Ousterhout, In Search of an Understandable Consensus Algorithm (Raft, USENIX ATC 2014) and Ongaro's PhD thesis (the complete treatment of membership changes and compaction). Lamport, Paxos Made Simple. Chandra, Griesemer & Redstone, Paxos Made Live (Google, PODC 2007) — the single most useful paper on the gap between algorithm and system. raft.github.io for the visualization; read the etcd raft package source, which is a clean, production-grade implementation with the state machine isolated from I/O.
40.3 Replication, Consistency & Time
The consistency model ladder
Precise definitions, strongest to weakest — mixing these up is a common and costly interview error:
- Linearizability (single-object, real-time): every operation appears to take effect atomically at some point between its invocation and response, consistent with real time. This is "the system behaves like a single copy."
- Serializability (multi-object, transactional): the outcome of concurrent transactions equals some serial order. Note: no real-time requirement — a serializable system may serve you stale data forever and remain correct.
- Strict serializability = serializability + linearizability. What Spanner and CockroachDB provide.
- Sequential consistency: all processes see the same order of operations, but that order need not respect real time.
- Causal consistency: operations causally related are seen in order by everyone; concurrent operations may be seen in any order. The strongest model achievable while remaining available under partition (per the CALM theorem's neighborhood).
- Read-your-writes / monotonic reads / monotonic writes / writes-follow-reads: the four session guarantees. Most "eventual consistency is fine" systems actually need these, and providing them is cheap (sticky routing, or a client-held version token).
- Eventual consistency: replicas converge if writes stop. Says nothing about when.
The gotcha to have ready: "Is serializability stronger than linearizability?" Neither — they're orthogonal. Linearizability is about recency on single objects; serializability is about transaction isolation across objects. Being able to say this crisply is a strong signal.
Isolation anomalies (SQL isolation levels are defined by which they permit):
| Anomaly | Description | Prevented by |
|---|---|---|
| Dirty read | Read uncommitted data | Read Committed |
| Non-repeatable read | Same row read twice differs | Repeatable Read |
| Phantom read | Same query returns new rows | Serializable (or predicate locks) |
| Lost update | Two read-modify-writes, one lost | RR in most engines / SELECT FOR UPDATE |
| Write skew | Two txns read overlapping data, write disjoint data, together violating an invariant | Only Serializable |
Write skew is the one worth knowing deeply because PostgreSQL's REPEATABLE READ is snapshot isolation, which permits it:
-- Invariant: at least one doctor must remain on call.
-- Two doctors, both on call, both try to go off call simultaneously.
-- Txn A -- Txn B
BEGIN ISOLATION LEVEL REPEATABLE READ; BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors SELECT count(*) FROM doctors
WHERE on_call = true; -- returns 2 WHERE on_call = true; -- returns 2
UPDATE doctors SET on_call = false UPDATE doctors SET on_call = false
WHERE id = 1; WHERE id = 2;
COMMIT; -- succeeds COMMIT; -- succeeds
-- Result: zero doctors on call. Both transactions were "correct."
-- Fixes, in order of preference:
-- 1. SET TRANSACTION ISOLATION LEVEL SERIALIZABLE (Postgres SSI detects
-- the dangerous structure and aborts one — retry logic required)
-- 2. Materialize the conflict: SELECT ... FOR UPDATE on the rows read
-- 3. Move the invariant into a constraint the database can enforce
Postgres's SSI (Serializable Snapshot Isolation) detects "dangerous structures" (a rw-antidependency cycle) and aborts a transaction with 40001. Every serializable system requires retry logic in the application — if a candidate proposes SERIALIZABLE without mentioning retries, they haven't run it.
Time in distributed systems
- Physical clocks lie. NTP drift, leap seconds, VM pauses, and clock skew of tens to hundreds of milliseconds between machines are normal. Never order events by wall clock across machines.
- Lamport clocks: a counter per process, incremented on each event, max'd on receive. Gives you happens-before (
a → b ⟹ L(a) < L(b)) but not the converse. - Vector clocks: a vector of counters, one per node. Gives you the converse too — you can detect concurrency, hence conflicts. Used by Dynamo and Riak. Cost: size grows with participants; requires pruning.
- Google TrueTime (Spanner): GPS + atomic clocks in every datacenter expose an interval
[earliest, latest]with a bounded uncertainty ε (single-digit ms). Spanner's commit wait deliberately sleeps out the uncertainty window before releasing locks, which is how it achieves external consistency (strict serializability) globally. It trades latency for correctness and requires special hardware — that tradeoff is the whole point of the design. - Hybrid Logical Clocks (HLC): combine physical time with a logical counter, giving timestamps that are close to wall clock but never violate causality — without atomic clocks. CockroachDB and YugabyteDB use HLC, which is why they need a configured
max-offsetand will shut down a node that exceeds it rather than risk a consistency violation. This is the practical answer for anyone who doesn't own a datacenter with GPS receivers.
CRDTs — conflict-free replicated data types
Data types whose merge function is commutative, associative, and idempotent, so replicas converge without coordination.
- State-based (CvRDT): ship full state, merge with a join (least upper bound). Simple, bandwidth-heavy; delta-CRDTs fix the bandwidth.
- Operation-based (CmRDT): ship operations; requires exactly-once, causally-ordered delivery.
- The catalog: G-Counter (grow-only), PN-Counter (two G-Counters), G-Set, 2P-Set, LWW-Register, OR-Set (observed-remove — the one that handles add/remove correctly), RGA/Logoot/YATA for sequences (collaborative text).
Real production use: Redis Enterprise active-active geo-replication is CRDT-based. Riak shipped CRDTs as a first-class data type. Figma's multiplayer uses a CRDT-inspired scheme (they've written publicly that they use a simplified LWW approach rather than a full CRDT because their server is authoritative — a great example of choosing the pragmatic 80%). Automerge and Yjs are the reference libraries for collaborative editing; Yjs's YATA algorithm is what most modern collaborative editors build on. Apple Notes and Teletype (Atom) used CRDTs.
The honest limitation to name: CRDTs guarantee convergence, not correctness. Two users concurrently decrementing an inventory counter converge to a value that may be negative. If your invariant is "stock ≥ 0," a CRDT will not save you — you need coordination. Saying this shows you understand the boundary rather than treating CRDTs as magic.
# OR-Set: the CRDT that actually gets add/remove semantics right.
# Naive 2P-Set can never re-add a removed element; OR-Set can,
# because each add carries a unique tag and removes only kill
# the tags they observed.
class ORSet:
def __init__(self):
self.adds = {} # element -> set of unique tags
self.removes = {} # element -> set of tombstoned tags
def add(self, element):
tag = uuid.uuid4()
self.adds.setdefault(element, set()).add(tag)
def remove(self, element):
# Only removes tags this replica has OBSERVED. A concurrent
# add on another replica has a tag we haven't seen, so it survives.
observed = self.adds.get(element, set())
self.removes.setdefault(element, set()).update(observed)
def contains(self, element):
return bool(self.adds.get(element, set()) - self.removes.get(element, set()))
def merge(self, other): # commutative, associative, idempotent
for e, tags in other.adds.items():
self.adds.setdefault(e, set()).update(tags)
for e, tags in other.removes.items():
self.removes.setdefault(e, set()).update(tags)
Replication topologies in practice
- Single-leader (Postgres streaming, MySQL, MongoDB replica sets): simple, no write conflicts, but failover is the hard part. Read-after-write requires sticky-to-primary routing for a window, or an LSN/token the client presents.
- Multi-leader (multi-region writes, CouchDB, DynamoDB global tables): write conflicts are inevitable; resolution is LWW (data loss, but simple — DynamoDB global tables do this and it is not configurable), application-defined merge, or CRDTs.
- Leaderless / quorum (Dynamo, Cassandra, Riak):
R + W > RFfor overlap. Read repair, hinted handoff, and anti-entropy (Merkle-tree repair) restore convergence. Quorum is not consensus — concurrent writes to the same key withW=quorumcan produce conflicting versions with no agreed order. - Chain replication (used in Meta's Delos, Azure Storage, and CORFU-style systems): writes go head→tail, reads served at the tail. Gives linearizability with high throughput and simple recovery; cost is write latency proportional to chain length.
Jepsen — Kyle Kingsbury's testing work is the field's reality check. His findings repeatedly showed that databases' consistency marketing outran their implementations (early MongoDB, Elasticsearch, Redis Sentinel, and many others lost acknowledged writes under partition). Two interview-usable points: (1) "I'd want to see a Jepsen report before trusting a consistency claim" is a credible, senior thing to say; (2) the general lesson — the failure modes that matter are partial: partitions that heal asymmetrically, processes that pause for 30 seconds and resume, clocks that jump. Not clean crashes.
Further reading: Kleppmann, DDIA ch. 5, 7, 9 (the best treatment in print). Bailis et al., Highly Available Transactions. Corbett et al., Spanner (OSDI 2012). DeCandia et al., Dynamo (SOSP 2007). Shapiro et al., A Comprehensive Study of CRDTs. jepsen.io analyses — read the one for whatever database you're about to be interviewed on.
40.4 Partitioning, Hashing & Rebalancing
Consistent hashing — the actual mechanics
Naive hash(key) mod N remaps ~all keys when N changes. Consistent hashing remaps ~K/N.
import bisect, hashlib
class ConsistentHashRing:
"""Virtual nodes are not optional: with 1 token per physical node,
load variance is ~±30%. With 100-200 vnodes, variance drops to a
few percent (standard deviation ~1/sqrt(vnodes))."""
def __init__(self, nodes=None, vnodes=150):
self.vnodes = vnodes
self.ring = {} # hash -> node
self.sorted_keys = []
for n in (nodes or []):
self.add_node(n)
def _hash(self, key):
return int(hashlib.md5(key.encode()).hexdigest()[:8], 16)
def add_node(self, node):
for i in range(self.vnodes):
h = self._hash(f"{node}#{i}")
self.ring[h] = node
bisect.insort(self.sorted_keys, h)
def remove_node(self, node):
for i in range(self.vnodes):
h = self._hash(f"{node}#{i}")
del self.ring[h]
self.sorted_keys.remove(h)
def get_node(self, key):
if not self.ring: return None
h = self._hash(key)
idx = bisect.bisect_right(self.sorted_keys, h) % len(self.sorted_keys)
return self.ring[self.sorted_keys[idx]]
def get_replicas(self, key, n):
"""Walk clockwise, skipping vnodes of already-chosen physical nodes —
otherwise all N replicas can land on one machine."""
if not self.ring: return []
h = self._hash(key)
idx = bisect.bisect_right(self.sorted_keys, h) % len(self.sorted_keys)
seen, out = set(), []
for i in range(len(self.sorted_keys)):
node = self.ring[self.sorted_keys[(idx + i) % len(self.sorted_keys)]]
if node not in seen:
seen.add(node); out.append(node)
if len(out) == n: break
return out
Alternatives worth naming (this is where you separate from the pack):
- Rendezvous / HRW hashing — for each key, compute
hash(key, node)for all nodes and take the max. O(N) per lookup but needs no ring state, gives perfect minimal disruption, and handles weights cleanly. Better than consistent hashing for small N. - Jump consistent hash (Lamping & Veach, Google) — ~5 lines, no memory, perfectly balanced, O(ln N). The catch: it only maps to buckets
0..N-1and cannot handle arbitrary node removal (only shrinking from the end). Perfect for sharding into a fixed number of shards.
// Jump consistent hash — Google. No storage, no ring, perfectly uniform.
int32_t JumpConsistentHash(uint64_t key, int32_t num_buckets) {
int64_t b = -1, j = 0;
while (j < num_buckets) {
b = j;
key = key * 2862933555777941757ULL + 1;
j = (b + 1) * ((double)(1LL << 31) / (double)((key >> 33) + 1));
}
return (int32_t)b;
}
- Maglev hashing (Google's software load balancer) — builds a lookup table giving near-perfect balance and minimal disruption, optimized for L4 load balancing where connection affinity matters and backends churn. Used in Google's frontend and adopted in Cilium and Katran (Meta's L4 LB).
Range partitioning
Ordered key ranges instead of hashes. Enables efficient range scans; risks hot spots on sequential keys (timestamps, auto-increment IDs).
Used by: Bigtable/HBase (tablets, split on size), CockroachDB (ranges, default ~512 MB, auto-split and auto-merge), TiKV (regions), Spanner (splits), FoundationDB, DynamoDB internally (partitions with automatic splitting on both size and throughput).
Hot-spot mitigation, the concrete toolkit:
- Salt the key — prefix with
hash(key) % Nto spread; you now need N parallel scans for a range query. DynamoDB's documented write-sharding pattern. - Reverse the timestamp bits so sequential writes distribute (classic HBase advice).
- Split by throughput, not just size — DynamoDB and CockroachDB both split hot ranges even when small. This is the elegant fix.
- Micro-partitioning + adaptive placement (§40.1) — many more partitions than machines lets the balancer move heat.
- Cache the hot key at the client — for read hotspots specifically, the cheapest fix (DynamoDB DAX, or an in-process cache).
Snowflake IDs — how to get roughly-sortable unique IDs without a coordinator, and the standard answer to "how do you generate IDs at scale":
64 bits: [1 unused][41 bits timestamp ms][10 bits machine id][12 bits sequence]
41 bits of ms ≈ 69 years from a custom epoch
10 bits → 1,024 nodes
12 bits → 4,096 IDs per node per millisecond → ~4M IDs/sec/node
Trade-off to name: time-ordered IDs are great for index locality and
terrible for partition balance — they concentrate all writes on the
newest shard. Twitter accepted this (they wanted sortability);
if you don't need sortability, use ULID/UUIDv7 for readability or
random UUIDv4 for perfect spread and awful B-tree locality.
UUIDv7 is the modern compromise: time-ordered prefix + random suffix.
Rebalancing in production
- Never rebalance automatically without rate limits. The failure mode: a node is slow, the balancer decides it's down, moves its data, which saturates the network, which makes more nodes look slow. Cascading rebalance is a real outage class.
- Vitess (YouTube's MySQL sharding layer, now used by Slack, Shopify, GitHub, Square) implements resharding as: create target shards →
VReplicationcopies and then tails the binlog → verify with diff → switch reads → switch writes → clean up. This is the reference implementation of online resharding; if asked how to reshard without downtime, describe this sequence. - Shopify's "pods" — they shard the entire application stack (DB, cache, workers) into isolated pods, so a shard is a full vertical slice. Failure and load are contained per pod. A great architecture to cite for multi-tenant isolation.
- Slicer (Google's general-purpose sharding service) and Shard Manager (Meta's) exist because every team was rebuilding the same rebalancing logic. The lesson for a lead: sharding policy is a platform capability, not a per-service concern.
Follow-up questions:
- "How many vnodes?" — 100–256 per node is the common range; more reduces variance but increases metadata and rebalance granularity. Cassandra's default moved from 256 to 16 (with the newer allocation algorithm) precisely because high vnode counts hurt repair and availability: with 256 vnodes, any two node failures are likely to share a token range, which is bad for availability. This is a great "I've operated this" detail.
- "Your shard key is wrong and you're in production. Now what?" — you cannot change it in place. The path is: dual-write to a new correctly-keyed store → backfill historically → shadow-read and diff → cut reads over → cut writes → decommission. That is weeks-to-months of work, which is exactly why shard key selection gets so much interview attention.
Further reading: Karger et al., Consistent Hashing and Random Trees (1997). Lamping & Veach, A Fast, Minimal Memory, Consistent Hash Algorithm (Google, 2014). Eisenbud et al., Maglev (NSDI 2016). DeCandia et al., Dynamo (SOSP 2007) — read for vnodes, sloppy quorum, and hinted handoff. Vitess docs on VReplication and resharding.
41. LLM Serving, Inference & the Economics of Tokens
This is the chapter that most directly differentiates you, because most interviewers can go one layer deep here and stop. It is also the area where "design an inference batching system" appears near-verbatim in real loops.
41.1 The single most important distinction: prefill vs decode
An LLM request has two phases with completely different hardware characteristics. Everything about serving architecture follows from this.
| Prefill (prompt processing) | Decode (token generation) | |
|---|---|---|
| What happens | Process all N input tokens at once | Generate 1 token at a time, autoregressively |
| Parallelism | All N tokens in parallel — one big matmul | Inherently sequential — token t+1 needs token t |
| Bottleneck | Compute-bound (FLOPs) | Memory-bandwidth-bound (reading weights + KV cache) |
| Arithmetic intensity | High (matrix-matrix) | Very low (matrix-vector) — GPU sits mostly idle |
| Scales with | Prompt length (quadratic in attention, linear in FFN) | Output length × batch size |
| User-visible metric | TTFT (time to first token) | TPOT/ITL (time per output token) |
The consequence that drives every design decision: during decode, a GPU with 3 TB/s of memory bandwidth serving one request at batch size 1 might use under 5% of its FLOPs. It reads the entire model's weights from HBM to produce a single token. The fix is batching — reading those weights once and amortizing across many concurrent sequences. Batching is nearly free throughput during decode and nearly zero benefit during prefill.
Concretely: for a 70B model in fp16, weights are 140 GB. At 3 TB/s HBM bandwidth, you can read them ~21 times per second. That's your ceiling: ~21 tokens/sec at batch size 1 — but ~21 × batch_size tokens/sec aggregate. At batch 64, that's ~1,300 tokens/sec from the same hardware. This arithmetic is the entire business case for batching, and being able to do it out loud is a strong signal.
Roofline framing: the crossover point where decode stops being memory-bound and becomes compute-bound is roughly at batch sizes of 100–500 depending on the GPU's FLOPs:bandwidth ratio. Below it, add batch. Above it, add GPUs.
41.2 The KV cache — what actually limits your service
During decode, attention needs the keys and values of every prior token. Recomputing them each step would be quadratic; caching them makes decode linear. The cache is the memory hog.
KV cache bytes = 2 (K and V)
× num_layers
× num_kv_heads × head_dim (= hidden_size for MHA)
× sequence_length
× batch_size
× bytes_per_element
Worked example — Llama-3-70B-class model, fp16, MHA:
layers=80, hidden=8192, seq=8192, batch=1, 2 bytes
= 2 × 80 × 8192 × 8192 × 1 × 2
= 21.5 GB ← for ONE request at 8k context
An 80 GB H100 holding 140 GB of fp16 weights already doesn't fit
(needs 2 GPUs). With 2×80=160 GB, ~20 GB is left for KV cache
→ ONE concurrent 8k request. That is a catastrophic serving story.
This is why the following exist, and you should name them in this order:
- GQA (Grouped-Query Attention) — share K/V heads across groups of Q heads. Llama-2-70B uses 8 KV heads instead of 64: an 8× KV cache reduction with negligible quality loss. MQA (Multi-Query) is the extreme: 1 KV head. This is the single biggest architectural lever, and it's why modern models all use GQA.
Same model with GQA (8 KV heads of 128 dim = 1024 instead of 8192): 2 × 80 × 1024 × 8192 × 1 × 2 = 2.7 GB per 8k request → ~7 concurrent requests in the same 20 GB. 8× better. - KV cache quantization — store K/V in fp8 or int8. Another 2× on top of GQA, with measurable but usually acceptable quality cost.
- PagedAttention (vLLM) — the systems fix. Classic implementations allocate a contiguous buffer for
max_seq_lenper request, wasting 60–80% to internal fragmentation and reservation for tokens never generated. PagedAttention borrows OS virtual memory: the cache is split into fixed-size blocks (typically 16 tokens), a per-sequence block table maps logical to physical blocks, and blocks are allocated on demand. Waste drops to under one block per sequence. vLLM's paper reported 2–4× throughput improvements from this alone. - Prefix caching / RadixAttention — sequences sharing a prefix (a system prompt, a few-shot preamble, a long document in a multi-turn chat) share the same physical KV blocks via copy-on-write. In production this is often the largest single win, because system prompts are identical across every request. SGLang's RadixAttention organizes cached prefixes in a radix tree with LRU eviction, generalizing this to arbitrary shared prefixes.
- MLA (Multi-head Latent Attention) — DeepSeek's approach: compress KV into a low-rank latent vector, decompress on use. Dramatically smaller cache; a good "I follow the frontier" mention.
- Offloading — spill KV to CPU RAM or NVMe for long-context or low-QPS workloads. Trades PCIe bandwidth for capacity.
Interview line worth memorizing: "The weights determine whether the model fits; the KV cache determines how many users fit. Capacity planning for LLM serving is KV cache planning."
41.3 Continuous batching — the throughput lever
Static batching (the naive approach): collect N requests, run them together until all finish. Fatal flaw — the batch runs until the longest generation completes, and every finished sequence's slot sits idle. With generation lengths varying 10× (common), utilization is terrible.
Continuous / in-flight batching (from the Orca paper, implemented in vLLM, TGI, TensorRT-LLM): schedule at the iteration level, not the request level. After every single forward pass, evict finished sequences and admit waiting ones.
Static batching (batch of 4, generation lengths 100/20/300/50):
step: |████████████████████████████| 300 steps, 3 slots idle most of it
GPU utilization ≈ 39%
Continuous batching:
Seq B finishes at step 20 → slot immediately filled by Seq E
Seq D finishes at step 50 → slot filled by Seq F
Seq A finishes at step 100 → slot filled by Seq G
GPU utilization ≈ 90%+, 2-4x throughput at the same latency
Chunked prefill — the refinement that fixes the remaining problem. A long prefill (say 8k tokens) monopolizes the GPU for hundreds of milliseconds, stalling every decode in flight and spiking inter-token latency for existing users. Chunked prefill splits the prompt into pieces and interleaves them with decode steps, trading a slightly worse TTFT for a dramatically smoother TPOT. Naming this tradeoff — TTFT vs ITL smoothness — is the depth signal in a batching design round.
Disaggregated prefill/decode — the frontier architecture (used in production at large-scale inference providers): run prefill and decode on separate GPU pools, transferring the KV cache between them. Because the two phases have opposite bottlenecks, co-locating them means neither runs optimally. Separating lets you scale each independently and use different hardware. The cost is a KV cache transfer over the interconnect. This is the answer to "how would you improve on vLLM's architecture."
Sketch: an inference scheduler (the near-verbatim interview question)
"Design a batching system for a single GPU serving up to 100 synchronous requests, maximizing utilization under a latency SLA."
class InferenceScheduler:
"""Iteration-level scheduler with a KV-block budget.
The core constraint is NOT request count — it's KV cache blocks."""
def __init__(self, total_kv_blocks, block_size=16,
max_batch=256, max_prefill_tokens=2048):
self.free_blocks = total_kv_blocks
self.block_size = block_size
self.max_batch = max_batch
self.max_prefill_tokens = max_prefill_tokens # chunked prefill budget
self.running = [] # sequences currently decoding
self.waiting = deque() # admitted, not yet started
self.swapped = [] # preempted, KV offloaded to CPU
def blocks_needed(self, seq):
return math.ceil(seq.total_len / self.block_size)
def schedule_step(self):
"""Called before EVERY forward pass. This is what makes it continuous."""
batch, prefill_budget = [], self.max_prefill_tokens
# 1. Existing decodes get priority — they have users waiting on
# inter-token latency. Each needs at most 1 new block.
for seq in self.running:
if seq.needs_new_block():
if self.free_blocks == 0:
# Out of memory. Preempt — LIFO (newest first) minimizes
# wasted work, and preserves fairness for old requests.
self._preempt_newest()
self.free_blocks -= 1
batch.append(seq)
# 2. Admit waiting requests, chunk-prefilling within budget.
while self.waiting and len(batch) < self.max_batch and prefill_budget > 0:
seq = self.waiting[0]
need = self.blocks_needed(seq)
if need > self.free_blocks:
break # can't fit; wait rather than thrash
chunk = min(seq.remaining_prompt_tokens(), prefill_budget)
seq.prefill_chunk = chunk
prefill_budget -= chunk
self.free_blocks -= need
batch.append(self.waiting.popleft())
self.running.append(seq)
return batch
def _preempt_newest(self):
"""Two options: SWAP (copy KV to CPU, restore later — costs PCIe
bandwidth) or RECOMPUTE (drop KV, re-prefill on resume — costs
FLOPs). Recompute usually wins for short prompts; swap wins for
long ones. vLLM implements both."""
victim = self.running.pop()
self.free_blocks += self.blocks_needed(victim)
self.swapped.append(victim)
def on_step_complete(self, outputs):
for seq, token in outputs:
seq.append(token)
if seq.is_finished(): # EOS or max_tokens or stop string
self.free_blocks += self.blocks_needed(seq)
self.running.remove(seq)
seq.future.set_result(seq.output)
Follow-ups an interviewer will push on, with the answers:
- "How do you keep the latency SLA under load?" — admission control. Reject or queue at the gateway when the estimated queueing delay exceeds the SLA budget; it is better to fail fast with a 429 than to accept work you'll deliver late. Model the queue with Little's Law (§40.1).
- "How do you prioritize?" — multiple queues by tier with weighted fair scheduling; short generations can be favored (SJF-like) for mean latency, but you must add aging to prevent starvation of long requests.
- "What's your eviction policy under memory pressure?" — preempt newest-first (LIFO), because the newest request has the least sunk cost and the fewest user-visible tokens already delivered. Recompute for short prompts, swap for long ones.
- "Streaming?" — yes, SSE (§18.2). Which means a preempted sequence has already sent tokens to the user — you cannot abandon it. This constrains preemption policy, and noticing it unprompted is a real signal.
41.4 Attention and the kernels underneath
- FlashAttention (Dao et al.) — attention is memory-bound, not compute-bound, because the N×N attention matrix round-trips to HBM. FlashAttention tiles the computation, keeps blocks in SRAM, and never materializes the full matrix, using the online-softmax trick to combine tiles. Result: 2–4× faster, and memory linear in sequence length instead of quadratic. FlashAttention-2 and -3 improve work partitioning and exploit newer hardware (FP8, async copies on Hopper). The takeaway to state: the win came from managing the memory hierarchy, not from reducing FLOPs — same lesson as §40.1.
- Speculative decoding — a small "draft" model proposes k tokens; the large model verifies all k in a single forward pass (verification is parallel, like prefill). Accepted tokens are kept, the first rejection resets. Because decode is memory-bound, verifying 5 tokens costs almost the same as generating 1. Typical 2–3× speedups with provably identical output distribution (rejection sampling guarantees this — an important detail, since it means no quality tradeoff). Variants: Medusa (extra decoding heads instead of a draft model), EAGLE (feature-level drafting), n-gram/prompt lookup (draft by copying from the prompt — free and remarkably effective for summarization and code editing where output echoes input).
- Quantization — the practical ladder:
| Format | Memory vs fp16 | Quality | Notes |
|---|---|---|---|
| BF16/FP16 | 1× | baseline | Default |
| FP8 (E4M3) | 0.5× | ~lossless | Native on H100+; the current sweet spot |
| INT8 (SmoothQuant, LLM.int8) | 0.5× | near-lossless with outlier handling | Activation outliers are the whole problem |
| INT4 (GPTQ, AWQ) | 0.25× | small but real degradation | AWQ preserves salient weights by activation magnitude; usually beats GPTQ |
| GGUF k-quants | varies | good at 4–5 bit | The llama.cpp/CPU ecosystem |
Weight-only quantization helps decode most (decode is bandwidth-bound on weights). It helps prefill less (compute-bound). Say this — it shows you understand why rather than that.
- Parallelism — tensor parallelism splits each layer's matrices across GPUs (needs very high bandwidth: NVLink within a node; two all-reduces per layer). Pipeline parallelism splits layers across nodes (tolerates slower interconnect, introduces bubbles). Expert parallelism for MoE. Rule of thumb: TP within a node, PP across nodes, and never use PP if you can avoid it for latency-sensitive serving.
41.5 Serving stacks and what to pick
| Stack | Strengths | Choose when |
|---|---|---|
| vLLM | PagedAttention, continuous batching, huge model coverage, OpenAI-compatible API, active community | The default for self-hosting |
| SGLang | RadixAttention prefix caching, structured-output speed, strong for agents with repeated prefixes | Heavy prefix reuse, constrained decoding |
| TensorRT-LLM | Highest raw throughput on NVIDIA, in-flight batching, FP8 | You've committed to NVIDIA and need max perf |
| TGI (HuggingFace) | Solid production defaults, good observability | HF ecosystem shops |
| llama.cpp / Ollama | CPU/Metal, GGUF quantization | Edge, laptops, prototypes |
| Managed (Bedrock, Vertex, Azure OpenAI, Anthropic/OpenAI APIs) | Zero ops, frontier models, per-token pricing | Almost always the right first answer |
The lead-level position to state: self-hosting only wins when you have (a) sustained high volume — the crossover is typically in the hundreds of millions of tokens per month, (b) a data-residency or latency requirement an API can't meet, or (c) a fine-tuned model you own. Otherwise you're paying an SRE team to lose to an API's price curve. Saying this — rather than defaulting to "we'd host it" — reads as commercial judgment.
41.6 Cost engineering, with the actual math
Unit economics of a RAG endpoint:
System prompt 800 tokens (identical every request!)
Retrieved context 3,000 tokens
User question 50 tokens
Output 400 tokens
At $3/M input, $15/M output:
input = 3,850 × $3/1M = $0.01155
output = 400 × $15/1M = $0.006
total ≈ $0.0176 per request
At 2M requests/month → $35,100/month.
Now apply the levers, in order of impact:
1. PROMPT CACHING on the 800-token system prompt + stable context.
Cached reads typically ~10% of input price.
If 3,000 of the 3,850 input tokens are cacheable at 80% hit rate:
effective input ≈ 3,850 - (3,000 × 0.8 × 0.9) = 1,690 tokens
→ input cost drops ~56% → saves ~$13k/month.
THIS IS ALMOST ALWAYS THE BIGGEST AND CHEAPEST WIN.
2. MODEL ROUTING. Classify intent; send the ~60% of simple queries to a
model 10x cheaper. Escalate on low confidence or explicit complexity.
0.6 × cost/10 + 0.4 × cost = 0.46 × cost → another ~50%.
3. CONTEXT TRIMMING. Rerank to top-5 chunks instead of top-15.
3,000 → 1,200 context tokens. Frequently IMPROVES quality
(less "lost in the middle") while cutting cost. Free lunch.
4. SEMANTIC CACHING. Embed the query, serve cached answers above a
similarity threshold. Works for FAQ-shaped traffic (support, docs);
dangerous for personalized or time-sensitive answers — a wrong cache
hit is a correctness bug, not a performance one. Gate by intent class.
5. OUTPUT LENGTH DISCIPLINE. Output tokens cost 5x input. "Be concise"
in the prompt plus a hard max_tokens is real money.
6. BATCH API for anything not user-facing (evals, backfills, enrichment)
— typically ~50% off.
7. DISTILLATION. Fine-tune a small model on your production traffic's
large-model outputs. Highest effort, highest ceiling; only worth it
at sustained volume with a stable task.
Realistic stacked outcome: $35k → $8-12k/month with no quality loss.
Track cost as a first-class metric: cost per request, per feature, per tenant, per team. Emit tokens and dollars on every trace span. Alert on cost-per-request drift the way you alert on latency drift — a prompt change that adds 500 tokens is a 15% cost regression that no test will catch.
41.7 Observability for LLM systems
Standard APM misses the failure mode that matters: a confidently wrong answer returns HTTP 200 in 800 ms. Your dashboards are green while the product is broken.
What a span must carry:
# OpenTelemetry semantic conventions for GenAI (gen_ai.* namespace)
with tracer.start_as_current_span("llm.chat") as span:
span.set_attribute("gen_ai.system", "anthropic")
span.set_attribute("gen_ai.request.model", model) # PIN THE VERSION
span.set_attribute("gen_ai.request.temperature", temp)
span.set_attribute("gen_ai.request.max_tokens", max_tokens)
span.set_attribute("app.prompt.version", prompt_version) # git sha of the prompt
span.set_attribute("app.retrieval.doc_ids", ",".join(doc_ids))
span.set_attribute("app.retrieval.scores", str(scores[:5]))
resp = client.messages.create(...)
span.set_attribute("gen_ai.usage.input_tokens", resp.usage.input_tokens)
span.set_attribute("gen_ai.usage.output_tokens", resp.usage.output_tokens)
span.set_attribute("gen_ai.usage.cache_read_tokens", resp.usage.cache_read_input_tokens)
span.set_attribute("app.cost_usd", compute_cost(resp.usage, model))
span.set_attribute("gen_ai.response.finish_reason", resp.stop_reason)
span.set_attribute("app.ttft_ms", ttft_ms)
Metrics to alert on that a generic stack won't give you: cache hit rate (a drop means a prompt change broke prefix stability — silent 40% cost increase), finish_reason == "max_tokens" rate (truncation), retrieval score distribution shift (your index went stale), refusal rate, eval score on a continuously-sampled production slice, and cost per request p50/p99.
Further reading: Kwon et al., Efficient Memory Management for LLM Serving with PagedAttention (vLLM, SOSP 2023) — the single most useful systems paper in this space. Yu et al., Orca (OSDI 2022) for continuous batching. Dao et al., FlashAttention and FlashAttention-2. Leviathan et al., Fast Inference from Transformers via Speculative Decoding (2023). Pope et al., Efficiently Scaling Transformer Inference (Google, 2022) — the definitive treatment of parallelism layouts and the roofline analysis. Zheng et al., SGLang / RadixAttention. Ainslie et al., GQA (2023).
42. Architecture Patterns, Fully Worked
Every pattern here follows the same structure: precise definition → what it is commonly confused with → a code walkthrough of the before and after → the operational problems it creates → who runs it in production and why → when it's the wrong choice → follow-up questions with answers.
42.1 CQRS — Command Query Responsibility Segregation
The precise definition
CQRS is the separation of the model used to write data from the model used to read it. That is the entire idea. It descends from Bertrand Meyer's Command-Query Separation (CQS) — a method should either change state (command, returns void) or return data (query, no side effects), never both — and Greg Young extended it from methods to models.
What CQRS is NOT (get these wrong and you'll be corrected mid-interview):
- Not event sourcing. They're frequently deployed together and are completely independent. You can do CQRS with two SQL tables and no events. You can event-source without CQRS.
- Not inherently eventually consistent. Level 1–2 below are fully synchronous and transactional.
- Not microservices. It's a pattern inside a service boundary as often as across one.
- Not "read replicas." Read replicas give you the same model on more hardware. CQRS gives you a different model shaped for the query.
The adoption ladder — the framing that makes you sound experienced
Almost every CQRS conversation goes wrong because people jump to Level 4 and then blame CQRS. Present it as a ladder and place the team on it:
| Level | What's separated | Consistency | Cost | Typical fit |
|---|---|---|---|---|
| 0 | Nothing — one model, one ORM | Strong | None | Most CRUD apps. Stay here. |
| 1 | Commands and queries as separate code paths (handlers, DTOs) | Strong | Trivial | Any app with non-trivial business rules |
| 2 | Separate read and write models over the same database (write to normalized tables, read from denormalized views) | Strong | Low | Complex domains, reporting pressure |
| 3 | Separate read store, updated synchronously or via transactional outbox | Strong-ish (outbox lag) | Medium | Read/write ratios >100:1, different access shapes |
| 4 | Separate read store fed asynchronously by events, often with event sourcing | Eventual | High | Extreme read scale, audit requirements, multiple divergent read shapes |
Say this in an interview: "CQRS is a ladder, not a switch. Most teams need Level 1 or 2 and adopt Level 4, then blame the pattern for the eventual-consistency bugs they signed up for."
Level 1 — the version everyone should do
Before: the classic anemic service where one model serves both purposes.
// BEFORE — one model, one repository, one entity leaking everywhere.
@Service
public class OrderService {
private final OrderRepository repo;
// Returns the entity — which is also the persistence model AND the
// API model. Change the DB schema, break the API.
public Order placeOrder(OrderDto dto) {
Order o = new Order();
o.setCustomerId(dto.customerId());
o.setItems(dto.items());
o.setStatus("PENDING");
return repo.save(o); // command that also returns state
}
public List<Order> findOrders(Long customerId) {
// N+1 waiting to happen; loads full aggregates to render a list
return repo.findByCustomerId(customerId);
}
}
After: commands and queries separated. Commands go through the domain model and enforce invariants; queries bypass it entirely and project straight to a DTO.
// ---------- WRITE SIDE: goes through the domain, enforces invariants ----------
public record PlaceOrderCommand(UUID customerId, List<OrderLine> lines, UUID idempotencyKey) {}
@Component
public class PlaceOrderHandler {
private final OrderRepository repo; // aggregate repository
private final InventoryPort inventory; // port, not a concrete client
private final IdempotencyStore idem;
@Transactional
public UUID handle(PlaceOrderCommand cmd) {
// Idempotency FIRST — commands arrive twice; this is not optional
// in any distributed system. Return the prior result on replay.
var prior = idem.find(cmd.idempotencyKey());
if (prior != null) return prior.orderId();
// The domain model enforces the rules. This is the ONLY place
// an Order can become invalid, which is the point of the write model.
Order order = Order.place(cmd.customerId(), cmd.lines(), inventory::reserve);
repo.save(order);
idem.record(cmd.idempotencyKey(), order.id());
return order.id(); // return the ID only — not the entity
}
}
// ---------- READ SIDE: no domain model, no ORM, straight to a DTO ----------
public record OrderSummary(UUID id, String status, BigDecimal total,
int itemCount, Instant placedAt, String customerName) {}
@Component
public class OrderQueryService {
private final JdbcTemplate jdbc; // deliberately NOT the ORM
public List<OrderSummary> forCustomer(UUID customerId, int limit, int offset) {
// One query, exactly the columns the screen needs, joined and
// aggregated in the database. No lazy loading, no N+1, no
// hydrating aggregates you'll throw away.
return jdbc.query("""
SELECT o.id, o.status, o.total_amount, o.item_count,
o.placed_at, c.display_name
FROM order_summary o
JOIN customer c ON c.id = o.customer_id
WHERE o.customer_id = ?
ORDER BY o.placed_at DESC
LIMIT ? OFFSET ?
""", ORDER_SUMMARY_MAPPER, customerId, limit, offset);
}
}
What you actually gained at Level 1, and should be able to articulate:
- The read path stops paying for the write path's abstractions (aggregate loading, dirty checking, lazy proxies). This is often a 10× latency improvement on list endpoints with zero infrastructure change.
- The write model is free to be rich (invariants, value objects, encapsulated collections) without making every read slow.
- The API contract decouples from the persistence schema.
- You can now optimize the two sides independently — which is what makes Levels 2–4 possible later.
Level 3–4 — separate read store, with the projection
// The write side emits a domain event inside the same transaction
// as the state change (via the outbox — §42.4, this is mandatory).
public record OrderPlaced(UUID orderId, UUID customerId,
List<OrderLine> lines, BigDecimal total,
Instant occurredAt, long version) {}
// The projector builds a read model shaped for exactly one screen.
@Component
public class OrderSummaryProjector {
private final ElasticsearchClient es;
private final CustomerLookup customers;
@KafkaListener(topics = "order-events", groupId = "order-summary-projector")
public void on(OrderPlaced e) {
// DENORMALIZE. The read model duplicates customer name so the
// query needs no join. This is the trade: storage and staleness
// in exchange for read latency.
var doc = Map.of(
"orderId", e.orderId().toString(),
"customerId", e.customerId().toString(),
"customerName", customers.displayName(e.customerId()),
"status", "PENDING",
"total", e.total(),
"itemCount", e.lines().size(),
"placedAt", e.occurredAt().toString(),
"version", e.version()
);
// IDEMPOTENT WRITE. Projectors WILL see duplicates (at-least-once
// delivery, consumer rebalance, replay). Two defenses:
// 1. Use the aggregate ID as the document ID (upsert, not insert)
// 2. Guard with the version — reject out-of-order/stale events
es.update(u -> u
.index("order-summary")
.id(e.orderId().toString())
.doc(doc)
.docAsUpsert(true)
.ifSeqNo(...) // or a scripted version check
);
}
}
Projection rules that separate people who've run this from people who've read about it:
- Projectors must be idempotent. Always. Use upserts keyed by aggregate ID, and carry a monotonic version to discard stale events.
- Projections must be rebuildable from scratch. If you can't drop the read store and replay, you don't have a projection — you have a second source of truth that will silently diverge. Rebuild capability is the whole safety net.
- Track projection lag as a first-class SLI.
max(event_timestamp_in_source) − max(event_timestamp_projected). Alert on it. This number is your consistency window, and product and support need to know it. - One projector per read model, independently versioned. Don't build a god-projector. When a screen changes, you rebuild one projection, not all of them.
- Version the projection, not just the code. Blue/green projections: build
order-summary-v2alongside v1, backfill, verify with a diff, switch reads, drop v1. Same expand-contract discipline as a schema migration (§39.5).
The eventual consistency problem — and the five real fixes
This is the question interviewers use to find out whether you've actually shipped CQRS: "The user places an order, gets redirected to their order list, and the order isn't there. What do you do?"
Weak answer: "add a spinner" or "it's eventually consistent, that's the tradeoff." Strong answer names the options and picks:
- Return the result from the command. The write side already knows the order ID and status; render the confirmation from the command's response rather than re-querying. Solves ~70% of real cases and costs nothing. Try this first.
- Read-your-writes via a version token. The command returns a version/LSN; the client sends it with subsequent reads; the query layer either waits for the projection to reach that version or falls back to the write store. This is exactly how you'd handle replica lag in a database, and it generalizes.
- Synchronous projection for the originating user only. Update the read model in the same transaction (or immediately after) for this one aggregate, asynchronously for everyone else. Hybrid, pragmatic, common in production.
- Client-side optimistic insert. The UI inserts the expected row locally, reconciles when the real projection arrives. Standard in modern frontends (TanStack Query optimistic updates, §22.2) and invisible to users.
- Design the UX around it. "Your order is being processed" is honest and often better product design than a fake-synchronous illusion — payments and shipping are genuinely asynchronous.
The senior framing: "Eventual consistency isn't a bug I hide, it's a property I expose deliberately where it's true and hide where it isn't. The originating user gets read-your-writes; other users get the async path."
Where CQRS-shaped architecture runs in production
- Meta / TAO — the canonical read/write split at planet scale. Writes go to MySQL (the durable, normalized source of truth); reads are served by TAO, a distributed write-through cache with a graph-shaped API. Reads outnumber writes by orders of magnitude and are served by an entirely different system with a different data model. That is CQRS at Level 4 whether or not they call it that.
- LinkedIn / Venice — a derived-data serving platform, purpose-built for exactly this pattern: batch and streaming jobs compute derived datasets (recommendations, people-you-may-know, feature data), which are pushed into a read-optimized store serving online traffic. The write path (Samza/Spark jobs) and the read path (Venice) are entirely separate systems. Espresso is the source-of-truth OLTP store; Venice serves the derived reads.
- Netflix — the homepage is precomputed. Personalized row assembly happens offline/near-line and lands in read-optimized stores (EVCache, Cassandra) so the request path is a lookup, not a computation. This is the "materialize the read model" instinct applied to recommendations.
- Uber — Schemaless as the write-side store with derived indexes and read models built from the changelog; Cadence/Temporal orchestrating the write-side workflows.
- Financial ledgers generally — the write model is an append-only double-entry journal; balances are a projection. Nobody computes an account balance by summing all history on every read, and nobody stores a balance as the source of truth. That split is CQRS, and it's the most defensible use case there is.
- Elasticsearch alongside a relational database — the single most common real-world CQRS deployment, and most teams doing it don't call it that. Postgres is the write model; an OpenSearch index is a projection. If you've done this (and you have), you have shipped CQRS — say so in the interview using this vocabulary. That reframing turns routine experience into pattern fluency.
When CQRS is the wrong answer
- Simple CRUD with symmetric read/write shapes — you've added a second store and a consistency window to solve nothing.
- Teams without the operational maturity to run a projection pipeline (monitoring lag, rebuilding, versioning). The pattern's cost is operational, not conceptual.
- When the real problem is a missing index or an N+1. Fix that first; a startling share of "we need CQRS" is "we need
EXPLAIN ANALYZE." - Strong consistency requirements on the read path with no tolerance for staleness — unless you stay at Level 2.
Follow-up questions and answers
- "How do you handle a projection bug that corrupted the read model?" → Fix the projector, delete the read store, replay from the event log into a new versioned index, diff against the old, switch reads. This is why rebuildability is non-negotiable — it turns a data-corruption incident into a rebuild job.
- "What if replaying the whole log takes 8 hours?" → Snapshot the projection periodically and replay from the snapshot; parallelize by partition key; and keep the projection's throughput at least 10× the live event rate so catch-up is feasible. Measure your rebuild time and treat it like an RTO (§32) — it is one.
- "How do you handle events arriving out of order?" → Order is guaranteed per Kafka partition, so partition by aggregate ID. Across aggregates, don't assume order. Carry a per-aggregate version and drop stale updates.
- "Two read models disagree. Which is right?" → Neither — the event log is. Read models are caches with extra steps. That mental model prevents a whole class of bugs.
- "Does CQRS require a message broker?" → No. Level 2 needs nothing. Level 3 can use a transactional outbox polled by the same service. Brokers become necessary when multiple independent consumers need the stream.
Further reading: Greg Young's CQRS Documents and his talks (the origin, and notably he has publicly warned against the pattern's overuse). Fowler's bliki entries on CQRS and CQS. Vernon, Implementing Domain-Driven Design, ch. 4 and 8. Meta's TAO paper (TAO: Facebook's Distributed Data Store for the Social Graph, USENIX ATC 2013). LinkedIn's engineering blog on Venice.
42.2 Event Sourcing — the full treatment
Definition
Store the sequence of state-changing events as the source of truth, rather than the current state. Current state is derived by replaying events. Account balance = fold(events), not a column.
The trade in one line: you gain a perfect audit log, time travel, and the ability to build any read model retroactively; you pay with schema-evolution pain, GDPR complexity, and a much higher operational bar.
The write model
# ---------- Events: immutable facts, past tense, carrying everything
# needed to understand them WITHOUT external lookups ----------
@dataclass(frozen=True)
class MoneyDeposited:
account_id: str
amount_cents: int # integers for money; never floats
currency: str
occurred_at: datetime
correlation_id: str # ties this to the request that caused it
@dataclass(frozen=True)
class MoneyWithdrawn:
account_id: str
amount_cents: int
currency: str
occurred_at: datetime
correlation_id: str
# ---------- The aggregate: rebuilt from events, enforces invariants ----------
class Account:
def __init__(self, account_id):
self.id = account_id
self.balance_cents = 0
self.version = 0 # for optimistic concurrency
self._pending = [] # events not yet persisted
@classmethod
def rehydrate(cls, account_id, events):
acct = cls(account_id)
for e in events:
acct._apply(e) # apply mutates state, no validation
acct.version += 1
return acct
def _apply(self, event):
"""Pure state transition. MUST NOT validate — these events already
happened. Validation lives in the command methods below."""
match event:
case MoneyDeposited(amount_cents=a): self.balance_cents += a
case MoneyWithdrawn(amount_cents=a): self.balance_cents -= a
# ---- Commands: validate, then emit ----
def withdraw(self, amount_cents, correlation_id):
if amount_cents <= 0:
raise ValueError("amount must be positive")
if amount_cents > self.balance_cents:
# THE invariant. It can only be checked here, on the write model,
# against a consistent view of this aggregate. This is why
# aggregates are consistency boundaries (§29.1).
raise InsufficientFunds(self.balance_cents, amount_cents)
e = MoneyWithdrawn(self.id, amount_cents, "CAD",
datetime.now(timezone.utc), correlation_id)
self._apply(e)
self._pending.append(e)
return e
The event store, with optimistic concurrency
-- The append-only log. Note the unique constraint: it is the entire
-- concurrency control mechanism.
CREATE TABLE events (
global_position BIGSERIAL PRIMARY KEY, -- total order for projections
stream_id TEXT NOT NULL, -- e.g. 'account-1234'
version INT NOT NULL, -- position WITHIN the stream
event_type TEXT NOT NULL,
event_data JSONB NOT NULL,
metadata JSONB NOT NULL, -- correlation/causation ids, actor
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (stream_id, version) -- ← optimistic concurrency
);
CREATE INDEX ON events (stream_id, version);
CREATE INDEX ON events (global_position);
def append(conn, stream_id, expected_version, events):
"""Optimistic concurrency: if another writer appended since we read,
the UNIQUE(stream_id, version) constraint fires and we retry the
whole command against fresh state. No locks, no distributed
transaction — just a constraint."""
try:
with conn.transaction():
for i, e in enumerate(events, start=1):
conn.execute(
"INSERT INTO events (stream_id, version, event_type,"
" event_data, metadata) VALUES (%s,%s,%s,%s,%s)",
(stream_id, expected_version + i, type(e).__name__,
json.dumps(asdict(e)), json.dumps(current_metadata()))
)
except UniqueViolation:
raise ConcurrencyConflict(stream_id, expected_version)
# Caller re-reads the stream, re-runs the command, retries.
Snapshots — when replay gets expensive
def load(store, account_id, snapshot_every=100):
snap = store.latest_snapshot(account_id)
if snap:
acct = Account.from_snapshot(snap) # state at version N
events = store.read(account_id, from_version=snap.version + 1)
else:
acct, events = Account(account_id), store.read(account_id, 0)
for e in events:
acct._apply(e); acct.version += 1
return acct
The rule: snapshots are a cache, never a source of truth. You must be able to delete every snapshot and rebuild. If a snapshot format change requires a migration, you've made them load-bearing — that's a design smell.
The four hard problems (name these unprompted; they're what separates experience from enthusiasm)
1. Schema evolution / upcasting. Events are immutable and live forever. A 2019 event must still be readable by 2026 code.
# Upcasters transform old event versions into the current shape at read time.
# Chain them: v1 -> v2 -> v3. Never rewrite history in the store.
def upcast_money_withdrawn(raw, version):
if version == 1:
raw["currency"] = "CAD" # v1 had no currency field
raw["amount_cents"] = int(raw.pop("amount") * 100) # v1 used floats
version = 2
if version == 2:
raw["correlation_id"] = raw.get("request_id", "unknown")
version = 3
return raw
Rules: only ever add optional fields; never change a field's meaning; never delete a field other code might read; version the event type explicitly (MoneyWithdrawn.v3) if the change is structural.
2. GDPR / right to erasure vs an immutable log. You cannot delete an event from an append-only log without destroying the model. The accepted solution is crypto-shredding: encrypt personal data in events with a per-subject key, store keys separately, and delete the key on an erasure request. The events remain, the PII becomes unrecoverable ciphertext. This is a genuinely impressive answer to give.
3. Kafka is not an event store (a real interview trap). Kafka is an excellent event log for transport, but as an event store it lacks: efficient per-aggregate reads (you can't cheaply load "all events for account-1234" out of a partition with millions of interleaved streams), a way to enforce optimistic concurrency on a stream, and unbounded retention semantics per key without compaction losing history. Teams that try get an external index bolted on and reinvent an event store badly. Use a real event store (EventStoreDB, Marten on Postgres, DynamoDB with stream_id as the partition key and version as the sort key) and publish to Kafka for consumers.
4. It's very hard to undo. Event sourcing is close to irreversible architecturally — the entire system's history is in a format only your code understands. Adopt it for a bounded context with a strong reason, not organization-wide.
Where event sourcing genuinely runs
- Financial ledgers and payments — the natural fit and the strongest case. Double-entry bookkeeping is event sourcing, invented in the 15th century. Balances are projections.
- Nubank — built on Datomic, an immutable database where the fact log is the source of truth; the entire architecture is event-sourcing-shaped and they've spoken publicly about immutability as a core bet.
- Insurance and healthcare claims — regulatory requirements make "why does this record look like this" a first-class query.
- Order/fulfillment systems — an order's lifecycle is inherently a sequence of events (placed, paid, picked, shipped, delivered, returned) and modeling it as state transitions loses information the business wants.
- Git is an event-sourced content-addressed store; your working directory is a projection. Useful analogy in interviews.
- Where it's rejected: most CRUD systems, and most "we'll add event sourcing for the audit log" cases — an audit table is 1% of the cost and solves the actual requirement.
Follow-ups
- "How do you query 'all accounts with balance > $1000'?" → You don't, from the event store. That's a projection. This question is really testing whether you understand that event sourcing without CQRS is unusable.
- "How big can a stream get?" → Bounded aggregates are a design requirement. A stream with 10M events is a modeling failure — the aggregate is too coarse. Split it, or close and open new streams periodically (e.g., per accounting period).
- "Concurrency across aggregates?" → Sagas (§42.3). You cannot have a transaction across aggregates; that's the definition of the boundary.
Further reading: Greg Young's Event Sourcing talks. Vernon, Implementing DDD. Vaughn Vernon and Kleppmann both on the log-as-source-of-truth. Kleppmann's Turning the Database Inside-Out talk — the clearest articulation of why logs and materialized views generalize database internals to the application layer.
42.3 Sagas — transactions across aggregates
The problem
You cannot hold an ACID transaction across services or aggregates. A saga is a sequence of local transactions where each has a compensating action, executed in reverse on failure. The guarantee you get is not atomicity — it's eventual consistency with explicit compensation, sometimes called "semantic atomicity."
Choreography vs orchestration
CHOREOGRAPHY — services react to each other's events. No central brain.
OrderService --OrderPlaced--> [bus]
├──> PaymentService --PaymentTaken-->
├──> InventoryService --StockReserved-->
└──> ShippingService
+ No single point of failure, low coupling to a coordinator
+ Easy to add a new participant (just subscribe)
- The workflow exists NOWHERE as a readable artifact. To understand
the business process you must trace event subscriptions across
six repositories. This is the killer at 5+ steps.
- Cyclic dependencies emerge silently
- Debugging requires distributed tracing as a hard prerequisite
ORCHESTRATION — a coordinator owns the workflow explicitly.
OrderSaga
├─1─> PaymentService.charge() ↩ refund()
├─2─> InventoryService.reserve() ↩ release()
├─3─> ShippingService.schedule() ↩ cancel()
└─4─> NotificationService.confirm() ↩ (none needed)
+ The process is one readable file. New engineers can understand it.
+ Compensation logic is centralized and testable
+ State is queryable ("where is order 123?")
- The orchestrator is a component to run and scale
- Risk of a smart-orchestrator/dumb-services anemic design
The position to take: choreography for 2–3 steps with genuinely independent services; orchestration past that, because a business process that nobody can read is an operational liability. Most production sagas of consequence are orchestrated.
Orchestrated saga with compensation
class OrderSaga:
"""Each step records its compensation BEFORE executing, so a crash
between execution and recording doesn't orphan the compensation."""
STEPS = [
("charge_payment", "refund_payment"),
("reserve_stock", "release_stock"),
("schedule_shipping","cancel_shipping"),
]
async def run(self, saga_id, order):
completed = []
try:
for action, compensation in self.STEPS:
# Persist intent before acting — crash recovery reads this
await self.state.record_step_started(saga_id, action, compensation)
result = await getattr(self, action)(saga_id, order)
await self.state.record_step_completed(saga_id, action, result)
completed.append((compensation, result))
await self.state.mark_complete(saga_id)
except Exception as e:
await self.state.mark_compensating(saga_id, str(e))
# Compensate in REVERSE order. Each compensation must be
# idempotent and must itself be retried until it succeeds —
# a failed compensation is an operational incident, not a
# code path you can swallow.
for compensation, result in reversed(completed):
await self._compensate_with_retry(saga_id, compensation, result)
await self.state.mark_failed(saga_id)
raise
async def charge_payment(self, saga_id, order):
# Idempotency key derived from saga + step: a retry after a
# timeout must not double-charge. This is THE most important
# detail in the whole pattern.
return await self.payments.charge(
amount=order.total,
idempotency_key=f"{saga_id}:charge_payment"
)
The four rules of sagas (recite these):
- Every step is idempotent, keyed on
saga_id:step. Retries are guaranteed, not hypothetical. - Every compensation is idempotent and retried until success. A compensation that fails permanently is a page, not an exception.
- Some things cannot be compensated — an email sent, a physical shipment dispatched. Order the saga so irreversible steps come last, after everything reversible has succeeded. This is called the "pivot transaction," and naming it is a strong signal.
- Compensation is not rollback. A refund is a new transaction that appears in the customer's statement. The business must accept the semantics.
Production systems
-
Uber / Cadence → Temporal. Uber built Cadence for exactly this problem; Temporal is its successor and is now the industry-standard durable execution engine. The model: write the workflow as ordinary sequential code, and the engine persists every step's result so that on crash it replays deterministically and resumes at the exact point of failure. It removes almost all of the boilerplate above.
# Temporal: the saga is just... code. Durability is the runtime's job. @workflow.defn class OrderWorkflow: @workflow.run async def run(self, order: Order) -> str: compensations = [] try: await workflow.execute_activity( charge_payment, order, start_to_close_timeout=timedelta(seconds=30), retry_policy=RetryPolicy(maximum_attempts=5)) compensations.append(refund_payment) await workflow.execute_activity(reserve_stock, order, ...) compensations.append(release_stock) # Durable timer — survives process restarts, no cron needed await workflow.sleep(timedelta(hours=1)) await workflow.execute_activity(schedule_shipping, order, ...) return "completed" except Exception: for c in reversed(compensations): await workflow.execute_activity(c, order, ...) raiseMentioning Temporal and the durable-execution model is a strong modern signal — it reframes "how do you build a saga" as "why would you hand-roll one."
-
AWS Step Functions — the managed state-machine equivalent; standard vs express workflows, built-in retry/catch, and visual execution history. The right answer inside AWS-native shops.
-
Netflix Conductor — Netflix's orchestration engine for their media pipeline, open-sourced.
-
Camunda / Zeebe — BPMN-based, common in enterprise and finance.
-
Airbnb — has written about their orchestration for booking flows; the general industry direction is toward durable execution engines rather than hand-rolled state machines.
Follow-ups
- "How do you test a saga?" → Unit test each step and compensation; integration test the happy path; then explicitly test failure injection at every step boundary, including "failure after the remote side succeeded but before we recorded it." That last case is the one that produces double-charges in production.
- "What about a saga that gets stuck?" → Timeouts per step, a dead-letter state, and a queryable saga store so support can see stuck instances. Every long-running saga needs an operational UI or a query interface; that's a requirement, not a nicety.
- "Isn't two-phase commit simpler?" → 2PC gives real atomicity but requires all participants to support it, holds locks across the network (killing throughput), and blocks indefinitely if the coordinator dies mid-commit. It's viable within a single database or with XA in a controlled environment; it does not survive the public internet or heterogeneous services.
42.4 The Transactional Outbox — the pattern that makes all of the above work
The dual-write problem: you must update the database and publish an event. Two systems, no shared transaction. Whatever order you choose, a crash between them leaves you inconsistent — a database row with no event (silently lost downstream work), or an event with no row (downstream acts on a fiction).
The fix: write the event to an outbox table in the same local transaction as the state change, then relay it asynchronously.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL, -- becomes the Kafka partition key
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
headers JSONB NOT NULL, -- trace context, correlation id
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ -- NULL = unpublished (polling variant)
);
CREATE INDEX ON outbox (published_at) WHERE published_at IS NULL;
@Transactional // ONE transaction. This is the entire point.
public UUID placeOrder(PlaceOrderCommand cmd) {
Order order = Order.place(cmd);
orderRepo.save(order); // business state
outboxRepo.save(new OutboxRecord( // event, same txn
"Order", order.id().toString(), "OrderPlaced",
json(new OrderPlaced(order)), traceHeaders()));
return order.id();
} // Either both commit or neither does. Atomicity restored.
Two relay strategies:
| Polling publisher | CDC / log tailing (Debezium) | |
|---|---|---|
| How | A worker selects unpublished rows, publishes, marks published | Read the database WAL/binlog directly |
| Latency | Poll interval (100 ms–1 s) | ~milliseconds |
| DB load | Extra queries + updates | Near zero on the primary |
| Ops cost | Trivial — it's just code | Kafka Connect cluster, connector config, schema handling |
| Table bloat | Needs a cleanup job | Debezium's outbox router can transform and you delete immediately |
| Choose when | Small/medium scale, no Kafka Connect | High volume, already running Connect |
// Debezium outbox event router — extracts the outbox row into a proper
// event on a topic per aggregate type, keyed by aggregate id.
{
"name": "orders-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"plugin.name": "pgoutput",
"table.include.list": "public.outbox",
"transforms": "outbox",
"transforms.outbox.type":
"io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement": "${routedByValue}.events",
"transforms.outbox.table.field.event.key": "aggregate_id",
"transforms.outbox.table.field.event.payload": "payload",
"tombstones.on.delete": "false"
}
}
Guarantees and the honest caveat: the outbox gives at-least-once delivery, never exactly-once. The relay can publish and crash before marking the row. Therefore every consumer must be idempotent — which is why §42.1's projector rules and §42.3's idempotency keys exist. Saying "the outbox gives at-least-once, so consumers must be idempotent" in one breath is exactly the level of precision interviewers are listening for.
The inbox pattern is the mirror image on the consumer side: record processed message IDs in a table, in the same transaction as the side effect, and skip duplicates. Together, outbox + inbox give you effectively-once processing without distributed transactions.
Production use: the outbox is near-universal in event-driven systems that survived contact with reality — it's the default in Debezium's documentation, in Chris Richardson's microservices patterns, and in essentially every serious CDC pipeline. Shopify, Stripe, and most payments infrastructure use some variant, because losing an event in a payments flow is a business incident.
42.5 Repository & Hexagonal, concretely
// ---------- DOMAIN LAYER: no framework imports, no annotations, no SQL ----------
// The port. Defined BY the domain, FOR the domain, in domain language.
public interface OrderRepository {
Optional<Order> findById(OrderId id);
void save(Order order);
List<Order> findUnfulfilledOlderThan(Duration age); // domain language
}
public class FulfillmentService { // pure policy, trivially testable
private final OrderRepository orders;
private final ShippingPort shipping; // another port
public void fulfillStaleOrders() {
for (Order o : orders.findUnfulfilledOlderThan(Duration.ofHours(24))) {
o.markEscalated(); // invariants live in the aggregate
orders.save(o);
shipping.expedite(o.id());
}
}
}
// ---------- ADAPTER LAYER: all the infrastructure ugliness lives here ----------
@Component
class JpaOrderRepository implements OrderRepository {
private final SpringDataOrderJpa jpa;
private final OrderMapper mapper; // entity <-> aggregate translation
public Optional<Order> findById(OrderId id) {
return jpa.findById(id.value()).map(mapper::toDomain);
}
public void save(Order order) { jpa.save(mapper.toEntity(order)); }
public List<Order> findUnfulfilledOlderThan(Duration age) {
return jpa.findByStatusAndCreatedBefore("UNFULFILLED",
Instant.now().minus(age)).stream().map(mapper::toDomain).toList();
}
}
Why this is worth the mapping cost — and when it isn't. You gain: the domain is testable with an in-memory OrderRepository and zero infrastructure; the persistence model can change (JPA → jOOQ → a document store) without touching business rules; and the domain model is free to differ from the table structure. You pay: a mapper per aggregate, and real boilerplate.
The honest verdict to give: for a CRUD service, this is ceremony — use Spring Data repositories directly and move on. For a domain with genuine invariants, or one you expect to outlive its current database, the boundary pays for itself within a year. A generic Repository<T> interface that just proxies your ORM is the anti-pattern — it adds a layer and buys nothing, because you haven't actually decoupled from the ORM's semantics (lazy loading, identity map, transaction scoping all leak through).
Follow-up: "Where do transactions belong?" → Not in the repository (too fine-grained) and not in the domain (that's infrastructure). They belong at the application service / command handler boundary — one command, one transaction. That's the Unit of Work boundary, and stating it crisply resolves a question a lot of candidates fumble.
43. Worked System Design Answers
Every design question posed anywhere in this document gets a full answer here: the requirements dialogue, the capacity math, the design, the deep dive, the failure modes, the alternatives I rejected and why, and the follow-ups an interviewer will actually push on. Read these as transcripts of a strong answer, not as summaries.
43.1 Design a Distributed Rate Limiter
(Referenced in §5.2 #1. This is the most common warm-up design question in existence, and most candidates give a shallow answer.)
Step 1 — Clarify (2 minutes, out loud)
"Before I design, five questions. What are we limiting on — user, API key, IP, or tenant? What's the scale — requests/sec and number of distinct keys? Is this a hard limit or a soft one — do we need exact enforcement, or is 5% overshoot acceptable? Where does it sit — edge, API gateway, or in-process? And what's the failure policy — if the limiter is down, do we fail open or closed?"
Assume the answers: per-API-key, 1M keys, 100k RPS aggregate, soft limit (small overshoot fine), at the API gateway, fail open (availability over enforcement — a rate limiter that takes down the API is worse than the abuse it prevents).
That last question is the one candidates skip, and it's the one that reveals operational judgment. Say the fail-open decision explicitly and justify it.
Step 2 — The algorithms, with actual tradeoffs
| Algorithm | Memory/key | Burst behavior | Boundary accuracy | Verdict |
|---|---|---|---|---|
| Fixed window counter | 1 int | Allows 2× burst at window edges | Poor | Simple but the edge burst is real: 100/min limit permits 200 requests in the 2 seconds spanning a boundary |
| Sliding window log | O(N) timestamps | Exact | Perfect | Correct but memory-prohibitive at scale — 1M keys × 100 timestamps = unusable |
| Sliding window counter | 2 ints | Smooth | ~Excellent | The pragmatic winner. Weighted average of current and previous window |
| Token bucket | 2 values (tokens, last_refill) | Allows controlled bursts | Perfect | Best when bursts are desirable — the standard for API quotas |
| Leaky bucket (queue) | queue | Smooths, no bursts | Perfect | Use when downstream needs constant rate (e.g., a legacy system) |
| GCRA | 1 timestamp | Precise, burst-tolerant | Perfect | Elegant — one value per key, token-bucket semantics. Redis's redis-cell implements it |
Sliding window counter math (the one to show on the board):
limit = 100/min. Now = 12:01:30 (30% into the current window).
previous window count = 84
current window count = 36
estimate = current + previous × (1 − elapsed_fraction)
= 36 + 84 × 0.5
= 78 → under 100, allow.
Error bound is provably small and it costs two integers per key.
Cloudflare published an analysis of this approach across billions of
requests showing well under 1% error versus an exact sliding log.
Step 3 — The implementation (Redis + Lua, atomic)
-- token_bucket.lua — atomic check-and-consume. Lua in Redis is the
-- correct primitive here: single-threaded execution means no race
-- between read and write, and one round trip instead of three.
-- KEYS[1] = bucket key
-- ARGV[1] = capacity, ARGV[2] = refill_rate/sec, ARGV[3] = now_ms, ARGV[4] = cost
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1])
local last = tonumber(bucket[2])
if tokens == nil then -- first request for this key
tokens = capacity
last = now
end
-- Lazy refill: no background job, no cron. Compute what WOULD have
-- accrued since the last request. This is the key trick — it makes
-- the whole thing O(1) memory with no sweeper process.
local elapsed = math.max(0, now - last) / 1000.0
tokens = math.min(capacity, tokens + elapsed * refill_rate)
local allowed = 0
if tokens >= cost then
tokens = tokens - cost
allowed = 1
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- TTL so idle keys evict themselves: time to refill a full bucket + slack.
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refill_rate) + 60)
-- Return retry-after so the client can back off intelligently
local retry_after = 0
if allowed == 0 then
retry_after = math.ceil((cost - tokens) / refill_rate)
end
return {allowed, math.floor(tokens), retry_after}
class RateLimiter:
def __init__(self, redis, capacity=100, refill_rate=10):
self.script = redis.register_script(TOKEN_BUCKET_LUA)
self.capacity, self.refill_rate = capacity, refill_rate
self.breaker = CircuitBreaker(failure_threshold=5, reset_timeout=30)
def allow(self, key, cost=1):
try:
with self.breaker:
allowed, remaining, retry_after = self.script(
keys=[f"rl:{key}"],
args=[self.capacity, self.refill_rate,
int(time.time() * 1000), cost])
return Decision(bool(allowed), remaining, retry_after)
except (RedisError, CircuitOpen):
# FAIL OPEN. Decided in step 1, implemented here, and I say
# so out loud: "I'd rather serve abuse than serve an outage."
metrics.increment("ratelimiter.fail_open")
return Decision(True, -1, 0)
Always return the standard headers — this is a detail that marks API maturity:
RateLimit-Limit: 100
RateLimit-Remaining: 22
RateLimit-Reset: 17
Retry-After: 17 # on 429 responses
Step 4 — Scaling to 100k RPS
At 100k RPS, one Redis round trip per request is ~100k ops/sec — a single Redis node handles this, but it's now a single point of failure and a latency tax (0.5 ms in-DC RTT on every request).
The production architecture is a two-tier hybrid:
┌──────────────────────────────┐
request ────────►│ Gateway instance (local tier)│
│ • in-memory token bucket │ ← 0 network hops
│ • holds a LEASE of N tokens │ for most requests
└──────────┬───────────────────┘
│ async, batched: "I used 50, give me 50 more"
┌──────────▼───────────────────┐
│ Redis cluster (global tier) │
│ • authoritative counters │
│ • sharded by key hash │
└──────────────────────────────┘
How the lease works: each gateway instance requests a batch of tokens (say 10% of the limit) from Redis, serves requests from that local allowance with zero network calls, and refreshes asynchronously when it's ~70% consumed. Redis load drops by the batch factor — 100k RPS becomes ~1k RPS of Redis traffic.
The tradeoff to name: with M gateway instances each holding a lease, worst-case overshoot is bounded by the outstanding leases. That's why step 1's "is a soft limit acceptable" question mattered — this entire architecture depends on that answer. If the answer had been "hard limit, financial consequences," you must do a synchronous check per request and accept the latency and the availability coupling.
Sharding: shard Redis by hash(api_key) so all operations for one key land on one node — cross-slot Lua doesn't work in Redis Cluster (§18.5). Hot keys (one abusive tenant) get handled by the local tier absorbing them.
Step 5 — Failure modes and operations
- Redis down → fail open with a circuit breaker; local buckets keep enforcing approximate limits from their last lease. Alert loudly.
- Clock skew between gateways → token bucket uses elapsed time from the server's clock in the Lua script, so skew between gateways doesn't corrupt shared state. Pass
nowfrom Redis'sTIMEcommand if you want to eliminate it entirely. - Thundering herd on reset → fixed windows cause synchronized retries at the boundary. Token bucket avoids it naturally; also add jitter to
Retry-After. - Hot key → one key at 50k RPS saturates a shard. Mitigate with the local tier plus, for extreme cases, key splitting (
key:0..9, each with 1/10 the limit). - Multi-region → do not try to share global counters across regions; the cross-region RTT (§40.1) destroys the latency budget. Give each region a proportional share of the limit and accept the imprecision, or accept per-region limits as the product semantics.
Alternatives I'd mention and reject
- API Gateway built-ins (AWS API Gateway usage plans, Kong, Envoy's global rate limit service) — "For most teams this is the right answer and I'd start here rather than build." Envoy's
ratelimitservice is exactly this design, open source and battle-tested. Say this. Reaching for build when buy exists is a junior instinct; the interview reward is for knowing when to build (custom quota semantics, multi-dimensional limits, tight cost control). - Nginx
limit_req— per-instance only, no shared state. Fine for crude protection, not for per-tenant quotas. - Sliding window log in Redis sorted sets — exact, but O(N) memory per key and
ZREMRANGEBYSCOREon every request. Viable at low key counts, not at 1M keys.
Real-world references
Stripe published their rate limiter design — they run multiple limiter types simultaneously (a request-rate limiter, a concurrency limiter, and per-endpoint fleet limits), which is the key insight that a single limiter is rarely enough: you need to protect against both "too many requests" and "too many slow requests holding workers." Cloudflare published the sliding-window-counter analysis showing sub-1% error at planetary scale. Envoy/Lyft's open-source ratelimit service is the reference implementation of the gateway-plus-Redis pattern. GitHub, Twitter, and Shopify all publish X-RateLimit-* headers with token-bucket semantics — Shopify's leaky-bucket API limit is a well-documented public example.
Follow-ups
- "Add a per-endpoint cost — a search costs 10, a GET costs 1." → The
costparameter is already in the Lua script. Weight by measured backend cost, publish the weights. - "How would you rate limit by IP when clients are behind a NAT?" → You'll punish shared egress. Layer signals: IP + user agent + auth token, and prefer authenticated identity where available. For unauthenticated traffic, use a lower limit and a challenge (CAPTCHA/proof-of-work) rather than a hard block.
- "Distinguish rate limiting from load shedding." → Rate limiting is a fairness/contract mechanism, applied per-tenant, independent of system health. Load shedding is a survival mechanism, applied based on your own saturation, dropping the lowest-value work regardless of who sent it. You need both. Netflix's concurrency-limits library (adaptive limits based on observed latency, using TCP-congestion-control-like algorithms) is the reference for the second.
43.2 Design a RAG System with Document-Level Access Control
(Referenced in §15.10 #1. This is the design most relevant to your background — enterprise search over permissioned corpora — and access control is the part that separates a demo from a product.)
Step 1 — Clarify
"Key questions: How many documents and how much churn — are we indexing 100k docs updated weekly, or 50M with real-time updates? What's the permission model — flat ACLs, group-based, hierarchical inheritance from folders, or row-level rules? How fresh must permissions be — if someone's access is revoked, is a 5-minute window acceptable or must it be immediate? What's the latency budget? And what's the consequence of a leak — is this internal docs, or regulated client material?"
Assume: 10M documents, ~100k updates/day, group-based ACLs with folder inheritance, revocation must be effective immediately (this is the hard requirement), p95 < 2 s end to end, and leakage is a serious compliance event. That combination is realistic for legal or financial document search.
Step 2 — The architecture
INGESTION (async, throughput-optimized)
Source systems ──CDC/webhook──► Document queue
│
├─► Extract (Tika/Unstructured: PDF, DOCX, HTML → text + structure)
├─► Chunk (structure-aware, §43.2 step 3)
├─► Enrich (contextual summary per chunk, entity extraction)
├─► Embed (batch, GPU, ~2k chunks/sec)
└─► Index → OpenSearch (BM25 + dense vector in ONE index)
with acl_groups as an indexed keyword field
PERMISSION SYNC (separate pipeline, lower latency)
Identity provider ──SCIM/webhook──► Permission service
└─► group membership cache (Redis, ~50 ms lookup)
└─► document ACL updates → partial index update (ACL field only)
QUERY (synchronous, latency-optimized)
Request + JWT
│
├─1─ AuthZ: resolve user → effective group set (~10 ms, cached)
├─2─ Query understanding: rewrite/decompose (~100 ms, small LLM, optional)
├─3─ HYBRID RETRIEVAL with ACL FILTER APPLIED IN THE QUERY (~80 ms)
│ BM25 top-100 ∥ kNN top-100 → RRF fusion → top-50
├─4─ Rerank: cross-encoder on top-50 → top-8 (~120 ms)
├─5─ Assemble context, generate with citations (~1.2 s, streamed via SSE)
└─6─ Post-check citations resolve to permitted docs (defense in depth)
Step 3 — The access control design (the part that matters)
The cardinal rule: filter at retrieval time, inside the query, never after. Post-filtering is wrong for three independent reasons, and naming all three is the strong answer:
- Recall collapse. If you retrieve top-100 and then filter, a user with access to 1% of the corpus may get zero results despite thousands of relevant permitted documents existing.
- Information leakage. Result counts, latency differences, and pagination behavior leak the existence of documents the user can't see. This is a real compliance finding, not a theoretical one.
- It's unbounded. You cannot know how deep to retrieve to guarantee k permitted results.
// OpenSearch: ACL filter INSIDE the kNN query, not applied after.
// The `filter` clause in knn is evaluated during graph traversal
// (efficient filtering), not as a post-processing step.
{
"size": 100,
"query": {
"bool": {
"filter": [
{ "terms": { "acl_groups": ["grp:legal-team", "grp:all-staff",
"user:s.albatati"] } }
],
"should": [
{ "match": { "content": { "query": "termination clause precedent",
"boost": 1.0 } } }
]
}
},
"knn": {
"field": "embedding",
"query_vector": [...],
"k": 100,
"num_candidates": 500,
"filter": {
"terms": { "acl_groups": ["grp:legal-team", "grp:all-staff",
"user:s.albatati"] }
}
}
}
The filtered-ANN recall problem — the deep technical point here. HNSW traverses a proximity graph; if you filter during traversal and the filter is highly selective (a user can see 0.1% of documents), the graph walk hits mostly-excluded neighborhoods and recall degrades badly. Three mitigations, chosen by selectivity:
| Filter selectivity | Strategy |
|---|---|
| >10% of corpus visible | Filtered HNSW traversal (efSearch raised ~2–3×). Works well. |
| 1–10% visible | Raise num_candidates substantially, or use a partitioned index |
| <1% visible | Pre-filter to a candidate set, then exact/flat search over it. Below a few hundred thousand vectors, brute-force cosine on a filtered subset is faster and exact. Lucene's engine will actually choose this automatically when the filter is selective enough. |
| Tenant isolation | Separate index per tenant — the cleanest answer for hard multi-tenancy. Costs index overhead per tenant; use for big tenants and a shared filtered index for the long tail. |
Denormalizing ACLs into the index is the design decision, and it has a cost: permission changes require reindexing the ACL field. The answer:
# Partial update of ONLY the ACL field — no re-embedding, no re-chunking.
# A folder permission change fans out to every chunk of every doc under it.
def revoke_group_from_folder(folder_id, group):
doc_ids = folder_service.documents_under(folder_id) # may be 100k docs
for batch in chunks(doc_ids, 1000):
opensearch.bulk([
{"update": {"_index": "docs", "_id": chunk_id}}
| {"script": {
"source": "ctx._source.acl_groups.removeAll([params.g])",
"params": {"g": group}}}
for chunk_id in expand_to_chunks(batch)
])
But the requirement was immediate revocation, and a 100k-document bulk update takes minutes. This is the crux of the design, and the honest answer is a two-layer model:
- Denormalized ACLs in the index for efficient retrieval — eventually consistent, seconds to minutes.
- A synchronous authorization check at result assembly against the authoritative permission service, for the ~8 documents that actually reach the context window. Eight point-checks at 5 ms each is 40 ms — affordable — and it makes revocation effective immediately, because the authoritative source is consulted before any content is shown.
"The index filter is an optimization for recall and cost; the post-check on the final result set is the security boundary. I never rely on a denormalized copy of an ACL as my only enforcement point."
That sentence is the whole answer to the security question, and it's the kind of layered thinking that reads as seasoned.
Also required: the same check must gate citations (a citation reveals a title and a snippet) and any cached answers. Semantic caching (§41.6) must include the effective permission set in the cache key, or you will serve one user's answer to another. This is the single most likely way a RAG system leaks data, and mentioning it unprompted is a strong signal.
Step 4 — Chunking and retrieval quality
def chunk_document(doc):
"""Structure-aware chunking. Fixed-size splitting is the default and
it's wrong for legal/technical documents: it splits mid-clause and
destroys the context that makes a chunk interpretable."""
sections = parse_structure(doc) # headings, clauses, tables, lists
chunks = []
for section in sections:
if section.token_count <= 512:
chunks.append(section) # keep intact
else:
chunks.extend(recursive_split(section, 512, overlap=64))
for c in chunks:
# CONTEXTUAL RETRIEVAL: prepend a short LLM-generated summary
# situating the chunk in the document. Anthropic published that
# this materially reduces retrieval failures, because a chunk
# that says "the party may terminate with 30 days notice" is
# meaningless without knowing WHICH contract and WHICH party.
c.context_header = summarize_placement(doc, c) # ~50 tokens, cached
c.embed_text = f"{c.context_header}\n\n{c.text}"
c.bm25_text = f"{doc.title} {c.section_path} {c.text}"
return chunks
Hybrid retrieval with RRF (§15.3) — and the reason it beats score normalization: BM25 scores and cosine similarities live on incompatible scales that vary per query, so any normalization is a heuristic. RRF only uses rank, which is scale-free:
def reciprocal_rank_fusion(rank_lists, k=60):
"""k=60 is the value from the original TREC work; it damps the
influence of top ranks just enough that a doc ranked #1 in one
list and #50 in another beats a doc ranked #3 in both only when
it should. Tune it on your own golden set — but 60 is a good prior."""
scores = defaultdict(float)
for ranks in rank_lists:
for rank, doc_id in enumerate(ranks, start=1):
scores[doc_id] += 1.0 / (k + rank)
return sorted(scores.items(), key=lambda kv: -kv[1])
Step 5 — Evaluation (the part that gets skipped and shouldn't)
Separate retrieval eval from generation eval. Conflating them means you can't tell whether a regression came from the index or the prompt.
EVAL_SUITE = {
# RETRIEVAL — needs labeled query→relevant-doc pairs
"recall@10": lambda: mean(retrieved_relevant / total_relevant),
"mrr": lambda: mean(1 / rank_of_first_relevant),
"ndcg@10": lambda: ...,
# Permission correctness — a HARD gate, not a metric to optimize
"acl_violations": lambda: count(retrieved_docs_user_cannot_see), # MUST be 0
# GENERATION — needs answer labels or a judge
"groundedness": lambda: judge("is every claim supported by context?"),
"answer_relevance": lambda: judge("does it answer the question asked?"),
"citation_accuracy":lambda: fraction_of_citations_that_support_their_claim,
"refusal_rate": lambda: fraction_where_model_correctly_said_unknown,
}
# Run on every prompt change, model version change, chunking change,
# and embedding model change. Block the merge on regression.
# acl_violations > 0 fails the build unconditionally.
Build a golden set of 200–500 queries before building the system, covering the head, the tail, adversarial permission probes ("show me the CEO's compensation memo"), and known-unanswerable questions.
Step 6 — Failure modes and degradation
| Failure | Response |
|---|---|
| Embedding service down | Fall back to BM25-only retrieval; quality drops, service survives |
| LLM provider down | Fail over to secondary provider; if all down, return ranked search results with snippets — a search engine is a valid degraded RAG |
| Reranker times out | Skip reranking, use RRF order, log the degradation |
| Permission service down | Fail closed. This is the one place you do not fail open. Return an error rather than risk serving unpermitted content. |
| Index stale | Surface a freshness indicator; alert on ingestion lag as an SLI |
Alternatives considered and rejected
- Fine-tuning instead of RAG — rejected: permissions can't be baked into weights, updates require retraining, and there's no citation path. Fine-tuning complements RAG (for tone, format, domain vocabulary), it doesn't replace it here.
- Long-context stuffing (put all documents in the prompt) — rejected at 10M docs on cost and latency grounds, and it doesn't solve permissions. Viable only for small, fixed corpora.
- Pure vector search, no BM25 — rejected: exact-match queries (case numbers, statute citations, defined terms) are exactly what legal users search for, and dense retrieval is weak on them. Hybrid is non-negotiable in this domain.
- GraphRAG — considered: valuable if the dominant queries are relational ("which contracts reference this entity across the portfolio"). Expensive to build and maintain. I'd recommend it as a phase 2 for a specific query class, not as the base architecture.
- A separate ACL-filtered index per user — rejected: 1M users × index overhead is absurd. Per-tenant indexes for large tenants is the viable version of this instinct.
Follow-ups
- "Users say results are worse than the old keyword search." → Almost always a recall problem masked as a ranking problem. Instrument: log queries with zero relevant results in the top-10, check whether the document was retrieved at all (retrieval failure) or retrieved and ranked low (ranking failure), and check whether the ACL filter is over-restricting. Two different fixes.
- "How do you handle a 400-page document?" → Parent-document retrieval: embed and search small chunks, but return the enclosing section for context. Also hierarchical summarization for "what does this document say overall" queries, which chunk retrieval answers badly.
- "How do you know when the model doesn't know?" → Retrieval score thresholds (if top result's score is below a calibrated floor, refuse), explicit refusal instructions in the prompt, and measuring refusal rate as a first-class metric. A system that never refuses is not confident, it's broken.
- "Cost at 1M queries/month?" → Run the §41.6 math live: context is the dominant term, so top-8 reranked chunks at ~400 tokens each plus a system prompt is ~4k input; caching the system prompt and using a small model for query rewriting takes it to roughly $12–18k/month at frontier-model prices, and materially less with routing.
44. Worked Design Answers — AI & LLM Systems
44.1 Design an LLM Gateway
(§15.10 #2. This is the design most AI-platform teams actually need, and it's the one that shows you think about a fleet rather than a feature.)
Clarify
"How many internal consumers and what's the aggregate volume? Are we multi-provider or single? Is the requirement cost control, reliability, governance, or all three? Do teams need to bring their own keys, or is this a central cost pool? And is there a data-residency or PII constraint on which provider sees what?"
Assume: 40 internal teams, ~50M requests/month, multi-provider (Anthropic + OpenAI + a self-hosted Llama for cheap/private work), central cost pool with chargeback, and a rule that PII-bearing traffic must stay on the self-hosted model.
Architecture
┌─────────────── LLM Gateway ───────────────┐
Client SDK ──────────►│ │
(OpenAI-compatible) │ 1. AuthN/Z → team identity, quota check │
│ 2. Policy → PII scan, model allowlist │
│ 3. Cache → exact + semantic │
│ 4. Route → model selection │
│ 5. Execute → with retry/failover/hedge │
│ 6. Meter → tokens, cost, attribution │
│ 7. Trace → OTel span with gen_ai.* │
└───────┬──────────┬──────────┬──────────────┘
│ │ │
Anthropic OpenAI vLLM (self-hosted)
Design decision #1: expose an OpenAI-compatible API. Every SDK, framework, and tool speaks it. Making your gateway a drop-in base-URL change is what drives adoption; a bespoke API means teams route around you, and a gateway nobody uses controls nothing. Say this — it's a product-thinking signal on an infrastructure question.
The routing layer
class Router:
def select(self, request, team_policy) -> ModelChoice:
# 1. HARD CONSTRAINTS FIRST — these are not optimizations.
if self.pii_detector.scan(request.messages).found:
return ModelChoice("self-hosted-llama-70b", reason="pii_policy")
if request.model_override and request.model_override in team_policy.allowed:
return ModelChoice(request.model_override, reason="explicit")
# 2. CAPABILITY FLOOR — some requests can't use a small model.
if request.tools or request.response_format == "json_schema":
candidates = MODELS_WITH_TOOL_USE
elif estimate_tokens(request) > 100_000:
candidates = LONG_CONTEXT_MODELS
else:
candidates = ALL_MODELS
# 3. COST-AWARE ROUTING — the actual lever. Classify difficulty
# with a cheap classifier (not another frontier LLM call —
# that defeats the purpose). A fine-tuned small model or even
# a logistic regression on features works.
difficulty = self.classifier.score(request) # ~5ms, local
if difficulty < 0.3:
return ModelChoice(cheapest(candidates), reason="easy",
escalate_on_low_confidence=True)
return ModelChoice(best(candidates), reason="hard")
The escalation pattern that makes cheap-first safe: run the small model, evaluate a confidence signal (logprobs, a self-rated confidence field in a structured response, or a cheap verifier), and retry on the large model when it's low. You pay for two calls on the escalated fraction; if 60% of traffic resolves on a model 10× cheaper, you still net ~45% savings. Measure the escalation rate — if it exceeds ~25%, routing is costing you money and you should raise the difficulty threshold.
Caching — two tiers, different risk profiles
# TIER 1: EXACT MATCH. Hash of (model, messages, temperature, tools, seed).
# Zero risk. Only valid at temperature=0 or with a fixed seed — at
# temperature>0 the user asked for variety and you'd be lying to them.
exact_key = sha256(canonical_json(request))
# TIER 2: SEMANTIC. Embed the query, find near-neighbors above threshold.
# HIGH RISK, must be gated:
# - Cache key MUST include the effective permission set (§43.2) or you
# leak across tenants. This is the #1 cause of data leaks in these systems.
# - Only for intent classes where a paraphrase deserves the same answer
# (FAQ, docs lookup). NEVER for personalized, computational, or
# time-sensitive queries.
# - Threshold must be tuned on a labeled set; 0.95 cosine is a starting
# point, and you should measure the false-hit rate explicitly.
semantic_key = (embedding(request.user_message), tenant_id, permission_hash)
Provider-side prompt caching is separate and strictly better where available — it's exact, provider-enforced, and cuts input cost ~90% on the cached prefix. The gateway's job is to maximize its hit rate: keep system prompts byte-stable, put variable content (retrieved chunks, user message) after static content, and warn teams when a prompt change invalidates a cache prefix. A gateway that reports "your cache hit rate dropped from 82% to 11% after deploy X" is delivering real value.
Reliability
async def execute(self, choice, request):
for attempt, provider in enumerate(self.failover_chain(choice)):
try:
# Deadline propagation: the remaining budget shrinks each attempt.
budget = request.deadline - now()
if budget <= 0: raise DeadlineExceeded()
return await provider.call(request, timeout=budget)
except RateLimited as e:
# Respect Retry-After; if it exceeds our budget, fail over NOW
# rather than sleeping through the deadline.
if e.retry_after > budget: continue
await asyncio.sleep(e.retry_after + jitter())
except (Overloaded, ServerError, Timeout):
self.breaker[provider].record_failure()
continue # fail over to next provider
except InvalidRequest:
raise # 4xx: retrying won't help. Fail fast.
- Circuit breakers per provider, so a degraded provider stops receiving traffic instead of consuming every request's budget before failing over.
- Hedging (§40.1) for latency-critical paths — but gate it hard: LLM calls are expensive, so hedge only when the hedge rate is under a few percent, and never hedge streaming requests that have already emitted tokens.
- Streaming and failover conflict: once you've streamed tokens to the client, you cannot silently retry on another provider. Either buffer the first N tokens before emitting (adds TTFT) or accept that mid-stream failures surface to the user. State the tradeoff.
Metering and chargeback
# Emitted on EVERY request, to both the trace and a metering topic.
UsageRecord(
team_id=ctx.team, service=ctx.service, feature=ctx.feature,
model=choice.model, provider=choice.provider,
input_tokens=r.usage.input_tokens,
cache_read_tokens=r.usage.cache_read_input_tokens,
output_tokens=r.usage.output_tokens,
cost_usd=price(choice.model, r.usage),
cached=cache_hit, cache_tier=tier,
routed_reason=choice.reason, escalated=escalated,
latency_ms=elapsed, ttft_ms=ttft, finish_reason=r.stop_reason,
)
Chargeback needs three things to be credible: attribution granularity finer than the team (feature-level, so a team can act on it), a daily dashboard rather than a monthly surprise, and budget guardrails — soft alert at 80% of a team's monthly budget, hard throttle at 100% with an override path. Without the override path, you'll take down someone's launch and lose the platform's political capital.
Follow-ups
- "Why not let teams call providers directly?" → You lose central cost visibility, per-team rate limiting (one team's runaway loop exhausts the org's provider quota and takes down everyone), PII policy enforcement, provider failover, and the ability to switch models centrally. The gateway is the only place those controls can exist.
- "What's the added latency?" → Target under 10 ms p99 of gateway overhead — auth from a cache, policy scan on a local model, routing from a local classifier. Anything more and teams will route around you. Measure and publish it.
- "How do you roll out a new model version?" → Exactly like any other deploy (§39): shadow the new model on a traffic sample, run the eval suite on the shadow output, canary at 1%/5%/25% with eval-score and cost as the gating metrics, auto-rollback on regression. Model versions are deploys, and pinned versions are mandatory — a provider silently updating a model under you is a production change you didn't make.
44.2 Design a Multilingual Semantic Search System
(§15.10 #6, and directly your domain — this is the design you should be able to give better than the interviewer.)
Clarify
"How many languages, and are queries and documents in the same language or cross-lingual? What's the corpus size and update rate? Is there a hard requirement that a query in Arabic finds a document in English? What's the relevance bar — is this navigational lookup or exploratory research? And do we have relevance judgments, or are we starting cold?"
Assume: 30 languages, cross-lingual required (an Arabic query must find English documents), 500M documents, 100k updates/day, exploratory research use case, p99 < 300 ms, and existing click logs but no human judgments.
The three architectural choices, with the decision
Choice 1: index topology
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| One index, all languages | Simple ops, cross-lingual by default with multilingual embeddings | Analyzer conflicts — you can't apply Arabic stemming and German compound splitting to the same field | Use for the vector field |
| Per-language index, routed by detected language | Correct analyzers, tunable per language | Cross-lingual requires fan-out; language detection errors misroute | Use for the lexical field |
| Hybrid: one vector index + per-language lexical fields in a shared index | Correct analysis and cross-lingual retrieval | More mapping complexity | This is the answer |
// Per-language analyzed subfields; one shared dense vector field.
{
"mappings": {
"properties": {
"content_en": { "type": "text", "analyzer": "english" },
"content_ar": { "type": "text", "analyzer": "arabic" },
"content_de": { "type": "text", "analyzer": "german" },
"content_zh": { "type": "text", "analyzer": "smartcn" },
"content_generic": { "type": "text", "analyzer": "icu_analyzer" },
"lang": { "type": "keyword" },
"embedding": {
"type": "knn_vector", "dimension": 768,
"method": { "name": "hnsw", "engine": "lucene",
"parameters": { "m": 16, "ef_construction": 256 } }
}
}
}
}
Choice 2: the embedding model. Cross-lingual retrieval requires embeddings where "contract termination" in English and its Arabic equivalent land in the same region of vector space. That means a genuinely multilingual model trained with cross-lingual alignment (the LaBSE / multilingual-E5 / BGE-M3 family), not a strong English model with translated inputs. Name the tradeoff: multilingual models are typically a few points weaker than a same-size English-only model on English-only benchmarks. You pay that tax deliberately for cross-lingual capability.
Choice 3: translate or not. Three viable strategies:
- Multilingual embeddings only — simplest, no translation cost, works well for semantic similarity. Default choice.
- Translate queries into every document language, run lexical search per language — expensive at query time, but gives exact-match capability cross-lingually. Use selectively for high-value queries.
- Translate documents into a pivot language at index time — high one-time cost, loses nuance, but makes everything a monolingual problem. Reasonable if your corpus is small and stable; not at 500M docs.
Language-specific problems most candidates miss
Naming these is where you separate yourself, because they're only known from doing the work:
- Tokenization has no whitespace in CJK and Thai — you need dictionary-based segmentation (kuromoji for Japanese, smartcn/jieba for Chinese, ICU for Thai). Whitespace tokenization silently produces garbage.
- German and Dutch compounds — "Lebensversicherungsgesellschaft" must decompound to match "Versicherung". Requires a decompounder with a dictionary.
- Arabic and Hebrew — rich morphology (clitics, prefixes), optional diacritics that must be normalized away, and the Arabic letter forms (alef variants ا/أ/إ/آ, taa marbuta ة vs ه) that need normalization or exact match fails.
- Turkish — agglutinative; a single word carries what English needs six for. Also the famous dotted/dotless i lowercasing bug that breaks naive
toLowerCase(). - Transliteration and code-switching — users type Arabic in Latin script ("kayf halak"), or mix languages in one query. Language detection on a 3-word query is unreliable; prefer running multiple analyzers over betting on detection.
- Token cost asymmetry — non-Latin scripts consume 2–4× the tokens of English in most BPE tokenizers, which directly hits embedding cost and LLM context budget (§41.6). Say this; it connects the linguistic problem to the cost model.
- Normalization pipeline — Unicode NFKC, script detection, diacritic folding (language-aware: folding is right for Arabic, wrong for Vietnamese where diacritics are phonemic).
Query pipeline
Query "شروط إنهاء العقد"
│
├─ Normalize (NFKC, Arabic letter normalization, diacritic strip)
├─ Detect language (with confidence; low confidence → run multi-analyzer)
├─ Optional: LLM query understanding (rewrite, expand, extract filters)
│ — gate on latency budget; only for long/ambiguous queries
├─ Embed with multilingual model ──┐
├─ Lexical: query content_ar + ├─► parallel, ~40ms each
│ content_generic ───────┘
├─ RRF fusion of both rank lists (k=60)
├─ Rerank top-50 with a MULTILINGUAL cross-encoder (~120ms)
│ — critical: a monolingual reranker destroys cross-lingual results
└─ Diversity/business rules → top-10
Scaling the vector index — the math you must do out loud
500M docs × 768 dims × 4 bytes (fp32) = 1.54 TB of raw vectors
HNSW graph overhead ≈ m × 8 bytes × N = 16 × 8 × 500M = 64 GB
Total fp32: ~1.6 TB, must be RAM-resident for low latency.
That's 20+ large nodes. Options, in order of what I'd try:
1. SCALAR QUANTIZATION to int8: 768 × 1 byte = 384 GB. 4x reduction,
typically 1-2% recall loss with rescoring of the top candidates
against full-precision vectors. ← DO THIS FIRST, nearly free.
2. DIMENSION REDUCTION via Matryoshka embeddings: models trained so
truncating to 256 dims retains most quality. 384 GB → 128 GB.
Combined with (1), a 12x total reduction.
3. PRODUCT QUANTIZATION (IVF-PQ): 10-50x reduction, larger recall cost,
worse for filtered search. Use when memory is the binding constraint.
4. DISK-BASED ANN (DiskANN-style): SSD-resident graph, RAM for a
compressed index. Trades ~2-5ms latency for a 10x cost reduction.
Worth it at this scale.
Sharding: by document ID hash, NOT by language. Language sharding
seems natural and is wrong — cross-lingual queries would need to hit
every shard anyway, and language distribution is heavily skewed, so
you'd get badly unbalanced shards.
Evaluation without human judgments (the realistic constraint)
Cold start plan, in order:
1. CLICK MODELS from existing logs. Raw clicks are position-biased;
correct with a position-based model or use counterfactual estimation
(inverse propensity scoring). Gives you a large, cheap, noisy label set.
2. INTERLEAVING for online comparison — far more sensitive than A/B for
ranking changes because each user sees both rankings, removing
between-user variance. Team-draft interleaving is the standard method.
THIS IS THE HIGHEST-VALUE TECHNIQUE HERE and it comes from your domain.
3. A SMALL GOLDEN SET (300-500 queries) with human judgments, weighted
toward the head and toward cross-lingual pairs specifically. Expensive
but necessary as ground truth to validate the click model.
4. LLM-as-judge for relevance grading to scale (3) — validate its
agreement with human judges on the golden set BEFORE trusting it,
and re-validate whenever the judge model version changes.
Metrics: NDCG@10 overall AND per-language. The trap: a global average
hides that Arabic recall is 40% while English is 85%. ALWAYS SLICE BY
LANGUAGE. Report the worst language, not the mean.
Follow-ups
- "Cross-lingual results are worse than monolingual. Why?" → Usually the reranker. Cross-encoders are frequently English-heavy in training even when the bi-encoder is multilingual. Check whether reranking hurts cross-lingual pairs by evaluating with reranking disabled — this is a specific, testable hypothesis and offering it is a strong signal.
- "How do you handle a new language?" → If the embedding model supports it, retrieval works immediately with the generic analyzer; add a language-specific analyzer and evaluate the lift. If the model doesn't support it, you need a new model, which means reindexing 500M documents — a multi-week job. Model choice is a long-term commitment; say so.
- "How do you reindex 500M docs without downtime?" → Alias-based blue/green (§20.4): build
docs-v2alongsidedocs-v1, backfill withrefresh_interval: -1and replicas 0 for speed, dual-write new updates to both, verify with a sampled relevance diff, atomically swap the alias, keep v1 for a rollback window, then drop. At 500M docs with GPU embedding at ~2k/sec, that's ~70 hours of embedding — parallelize across GPUs and plan for days, not hours.
44.3 Design an Agent Platform with Sandboxed Tool Execution
(§15.10 #4. The design that most signals you understand where agentic systems actually break.)
Clarify
"What class of tools — read-only APIs, or things that write to production systems? Who are the agents acting as — the user's identity, or a service identity? What's the tolerance for a wrong action, and does anything need human approval? How long can a task run — seconds, or hours? And do we need to explain, after the fact, exactly why the agent did something?"
Assume: mixed read/write tools including some irreversible ones, agents act on behalf of a user with that user's permissions, wrong actions on write tools are serious, tasks run up to an hour, and full auditability is required.
Architecture
┌──────────────────────────────────────────────────────────┐
│ Control plane │
│ • Agent registry (versioned definitions, prompts, tools)│
│ • Tool registry (schemas, permissions, rate limits) │
│ • Policy engine (who may run what, against what) │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Execution plane │
│ Orchestrator (durable workflow — Temporal-style) │
│ ├── LLM step → gateway (§44.1) │
│ ├── Tool step → sandbox (gVisor/Firecracker/container)│
│ ├── Approval step → human-in-the-loop, durable wait │
│ └── Checkpoint after EVERY step │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ Observability plane │
│ Full trace: every prompt, tool call, result, token, cost│
│ Replay: re-run a trace deterministically for debugging │
└──────────────────────────────────────────────────────────┘
Design decision: the orchestrator is a durable workflow engine, not a while loop. An agent loop that lives in a process is lost when the process restarts — after 40 minutes and $12 of tokens. Durable execution (§42.3) checkpoints every step, so a crash resumes exactly where it stopped. This single choice removes an entire class of production pain, and choosing it unprompted is the architectural signal in this question.
The security model — the heart of the answer
class ToolInvocation:
"""Three independent gates, all required. Defense in depth, because
the LLM's decision to call a tool is NOT a trusted input."""
async def invoke(self, agent_ctx, tool_name, raw_args):
# GATE 1: SCHEMA VALIDATION. The model hallucinates tool calls
# and arguments. Validate against the registered JSON Schema
# BEFORE anything else. Reject and return an error the model can
# correct from — don't crash the run.
tool = self.registry.get(tool_name)
if tool is None:
return ToolError(f"unknown tool: {tool_name}", recoverable=True)
try:
args = tool.schema.validate(raw_args)
except ValidationError as e:
return ToolError(f"invalid arguments: {e}", recoverable=True)
# GATE 2: AUTHORIZATION AGAINST THE USER, NOT THE AGENT.
# The agent runs with a token derived from the user's identity
# (RFC 8693 token exchange, §21.1), scoped DOWN to the minimum
# this agent needs. The agent can never exceed the user's rights,
# and usually holds far fewer.
decision = await self.policy.check(
principal=agent_ctx.on_behalf_of,
agent=agent_ctx.agent_id,
action=tool.action, resource=args.get("resource_id"))
if not decision.allowed:
return ToolError("not permitted", recoverable=False)
# GATE 3: HUMAN APPROVAL for irreversible / high-blast-radius
# actions. This is a DURABLE WAIT — the workflow suspends, could
# be hours, resumes on approval. Classify tools by reversibility
# at registration time, not per-call.
if tool.requires_approval or args_exceed_threshold(tool, args):
approval = await workflow.wait_for_signal(
"approval", timeout=timedelta(hours=4))
if not approval.granted:
return ToolError("approval denied", recoverable=False)
# EXECUTE in a sandbox with a hard timeout and no ambient credentials.
result = await self.sandbox.run(
tool, args,
timeout=tool.timeout,
credentials=decision.scoped_token, # short-lived, narrow
network_policy=tool.egress_allowlist, # explicit allowlist only
idempotency_key=f"{agent_ctx.run_id}:{agent_ctx.step}")
# GATE 4 (output side): tool output is UNTRUSTED INPUT.
return self.sanitize(result)
Prompt injection via tool output is the central threat, and the answer has layers:
- Structural separation — tool results go into the context clearly delimited and labeled as data, never concatenated into the instruction region.
- Least privilege — an agent that can only read cannot be talked into writing. The best defense against injection is that the injected instruction has no capability to abuse. Say this; it reframes the problem correctly.
- The lethal trifecta — an agent with (a) access to private data, (b) exposure to untrusted content, and (c) an egress channel can be made to exfiltrate. Break at least one leg: no untrusted content, or no private data, or no egress. Egress allowlists and stripping URLs/images from model output are the practical controls.
- Output validation — never render agent output as HTML without sanitization; never pass it to a shell, an eval, or an SQL string.
Loop termination is a correctness requirement, not a nicety:
LIMITS = dict(max_steps=40, max_tokens=500_000, max_cost_usd=5.00,
max_wall_clock=timedelta(hours=1), max_consecutive_errors=3)
# Plus cycle detection: hash (tool_name, args) per step; if the same
# call repeats 3 times, the agent is stuck — break and escalate rather
# than burning budget. This is the single most common runaway pattern.
Memory and context
- Short-term: the run's message history, compacted when it approaches the window — summarize completed sub-tasks into a structured state object rather than truncating (§16.3).
- Long-term: a store the agent reads/writes via tools, with the same permission model. Not a magic side channel.
- Sub-agent isolation: give a sub-agent only the slice of context it needs and return only its conclusion. This bounds context growth and limits injection blast radius — one mechanism, two benefits.
Evaluation
Agent eval is task-level success rate, not step accuracy. Build a suite of realistic tasks with programmatic success checks (did the ticket get created with the right fields? did the file end up in the right state?), and track: success rate, steps-to-completion, cost per successful task, human-intervention rate, and unsafe-action rate (attempted actions blocked by policy — this should be near zero and any spike is an incident signal, possibly an injection campaign).
Follow-ups
- "Multi-agent or single agent?" → Single agent with good tools, until proven otherwise. Multi-agent adds coordination failure modes, context duplication, and cost multiplication for benefits that usually come from better tool design. Use sub-agents for context isolation on genuinely separable subtasks — that's the case where it earns its keep.
- "How do you debug a run that did something bizarre?" → Full trace + deterministic replay. Store every prompt, response, tool call, and result with model version pinned. Replay with the same inputs to reproduce. Without replay, agent debugging is archaeology.
- "What if the tool is down?" → Return a structured error to the model — modern models handle "the tool failed, try another approach" well. Distinguish recoverable (return to model) from non-recoverable (abort the run). Don't retry non-idempotent tools blindly.
44.4 Design an Evaluation Pipeline That Gates CI
(§15.10 #5. Rarely asked directly, always impressive when volunteered.)
On every PR touching prompts / retrieval config / model version / tools:
1. FAST GATE (< 2 min, runs on every commit)
• Unit tests on parsing, schema validation, tool contracts
• 30-example smoke set, assertions not judges
• Cost/token regression check: did the prompt grow >10%?
2. FULL EVAL (< 20 min, runs on PR)
• 400-example golden set, stratified: head / tail / adversarial /
unanswerable / permission-probe
• RETRIEVAL metrics: recall@k, NDCG@10, MRR ← separate from below
• GENERATION metrics: groundedness, answer relevance, citation accuracy
• SAFETY: injection suite, PII leakage, refusal calibration
• Judge: rubric-based, pairwise vs the baseline output, RANDOMIZED
ORDER (position bias is real and large)
3. GATING POLICY
• Any acl_violation or PII leak → hard fail, no override
• Primary metric regression > 2% → fail, requires explicit override
with a written justification recorded on the PR
• Cost per request regression > 15% → fail
• Variance check: run the judge 3x on a subsample; if judge variance
exceeds the measured delta, the result is noise — say so rather
than shipping on a phantom improvement
4. POST-MERGE
• Shadow the new config on 1% of production traffic
• Compare eval scores on real queries (not just the golden set)
• Canary per §39.3 with eval score as a gating metric
The three things that make this credible rather than theatrical:
- Judge validation. Measure your LLM judge's agreement with human labels (Cohen's kappa) on a subset before trusting it. Re-measure when the judge model changes. An unvalidated judge is a random number generator with good grammar.
- Statistical honesty. A 400-example set detects large regressions, not 1% ones. Compute and state your minimum detectable effect. Report confidence intervals, not point estimates.
- The golden set is a living asset. Every production failure becomes a new eval case. That feedback loop is what makes the suite converge on reality instead of on what you imagined at design time.
44.5 Design Cost Attribution for LLM Spend Across 40 Teams
The gateway (§44.1) emits the raw records; this is the system on top.
Metering topic (Kafka) ──► Stream aggregator (Flink)
│ tumbling 1-min windows
│ keyed by (team, service, feature, model)
├──► Real-time budget checks → throttle signals
└──► Iceberg table (daily partitions)
│
├──► Dashboard (team self-serve)
├──► Anomaly detection (cost/request drift)
└──► Monthly chargeback report
The hard parts, which are organizational rather than technical:
- Attribution requires context propagation. Team/service/feature must ride the request from the caller through the SDK to the gateway. Make the SDK refuse to send requests without attribution headers — a hard requirement from day one, because retrofitting attribution is nearly impossible.
- Shared costs. A platform team's shared retrieval service consumes tokens on behalf of others. Decide the model early: direct attribution (pass through the originating team) beats proportional allocation, which beats a central pool nobody feels.
- Cached tokens must be priced correctly or teams optimizing for cache hits see no benefit and stop caring.
- Budgets need an override path with an audit trail. A hard throttle with no escape hatch will eventually take down a launch, and the platform will lose the mandate.
The metric that actually drives behavior: cost per unit of business value — cost per resolved support ticket, per document processed, per successful task. Raw spend rises with usage and tells a team nothing. Unit cost tells them whether they're getting better or worse, and it's the number to put on the dashboard.
45. Worked Design Answers — The Classics
Compressed but complete: for each, the clarifying question that matters most, the key insight interviewers are testing for, the design, and the follow-up that separates candidates.
45.1 News Feed (Push vs Pull vs Hybrid)
The question being tested: do you understand fan-out economics?
FAN-OUT ON WRITE (push): when Alice posts, write the post ID into every
follower's precomputed timeline (Redis list per user).
Read = O(1) — just read your list. Sub-millisecond.
Write = O(followers) — Alice has 50M followers → 50M writes per post.
→ Great for read-heavy, bad for celebrities.
FAN-OUT ON READ (pull): store posts by author; at read time, fetch the
authors you follow, merge, sort.
Write = O(1). Read = O(following × posts) with a merge.
→ Great for write-heavy, terrible read latency.
HYBRID (what everyone actually ships):
• Push for normal users (< ~10k followers) — 99.9% of accounts
• Pull for celebrities — their posts are NOT fanned out
• At read time: merge your precomputed timeline with a live pull of
the handful of celebrities you follow
This is the answer. Twitter's original architecture and its evolution
is the canonical public case study.
Details that show depth: timelines are capped (store ~800 entries, not all history — older reads fall back to pull); fan-out is asynchronous via a queue, so posting returns immediately and the timeline fills within seconds; inactive users are skipped in fan-out entirely and materialized lazily on login (a huge saving — most accounts are dormant); and ranking is a separate stage on top of retrieval, so the timeline store holds candidates, not the final order.
Follow-up: "How do you handle a post deleted after fan-out?" → You don't chase 50M list entries. Filter at read time against a tombstone set (a Bloom filter of deleted IDs in front of an exact check). Cheap deletes, slightly more expensive reads — the correct trade given the ratio.
45.2 Notification / Fan-out System
Key insight tested: deduplication and preference management, not delivery.
Event → Notification service
├─ 1. Preference check (per user, per channel, per category) — the
│ most common product bug is notifying someone who opted out
├─ 2. DEDUPLICATION + AGGREGATION — "5 people liked your post" not
│ 5 notifications. Window-based (5 min) with a coalescing key.
├─ 3. Rate limiting per user (§43.1) — a runaway loop must not send
│ 10k pushes to one person
├─ 4. Channel routing: push (APNs/FCM) / email (SES) / SMS / in-app
├─ 5. Template rendering + localization
└─ 6. Delivery with per-channel retry, DLQ, and bounce handling
Idempotency at the event level (event_id in a dedupe store with TTL) because upstream will double-deliver. Track delivery, open, and action rates per channel — and unsubscribe rate as a guardrail metric, because the failure mode of a notification system is not outage, it's users turning it off forever.
45.3 Distributed Job Scheduler with Exactly-Once Semantics
Key insight tested: "exactly-once execution" is impossible; "at-least-once execution + idempotent jobs" or "effectively-once" is achievable. Say this in the first 30 seconds.
-- The lease pattern: the entire design in one table.
CREATE TABLE jobs (
id UUID PRIMARY KEY,
run_at TIMESTAMPTZ NOT NULL,
status TEXT NOT NULL, -- pending|leased|done|failed
lease_owner TEXT,
lease_expires TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
payload JSONB NOT NULL
);
CREATE INDEX ON jobs (run_at) WHERE status = 'pending';
-- Atomic claim. SKIP LOCKED is the key: workers never block each other.
UPDATE jobs SET status='leased', lease_owner=$1,
lease_expires=now()+interval '5 minutes', attempts=attempts+1
WHERE id IN (
SELECT id FROM jobs
WHERE status='pending' AND run_at <= now()
ORDER BY run_at LIMIT 10
FOR UPDATE SKIP LOCKED)
RETURNING *;
Why leases and not locks: a worker that dies holding a lock blocks forever; a lease expires and the job is reclaimed. The consequence you must name: a worker that hangs past its lease means the job runs twice — hence idempotent jobs are mandatory, and long jobs must heartbeat to extend their lease.
Scale beyond one database with partitioned queues, or use a purpose-built engine (Temporal for durable workflows, Quartz clustered for simple cron, Airflow for DAGs, or a Kafka-based scheduler with time-bucketed topics). Recommend the buy over the build; the interesting answer is knowing why the naive SELECT ... WHERE run_at < now() polling loop fails (thundering herd, no fairness, no crash recovery).
45.4 Metrics / Observability Pipeline
Agents (OTel SDK) → OTel Collector (batch, tail-sample, redact, enforce
cardinality limits)
├─ metrics → Prometheus/Mimir (TSDB: delta-of-delta timestamp
│ encoding + XOR float compression, Gorilla-style)
├─ traces → tail sampler → object storage + index (Tempo/Jaeger)
└─ logs → hot (7d, searchable) / warm (30d) / cold (1y, archived)
The central problem is cardinality, and it's what the question is really about. A metric with an unbounded label (user ID, request ID, full URL path) creates a new time series per value; a million series will take down Prometheus. Enforce at the Collector: allowlist labels, drop or bucket high-cardinality dimensions, and route the high-cardinality questions to traces and logs where they belong. Meta's Scuba and the "wide events" model exist precisely because pre-aggregated metrics can't answer arbitrary high-cardinality questions — cite this when arguing for event-based observability.
Downsampling and retention: raw at 15 s for 7 days, 5-minute rollups for 90 days, hourly for 2 years. Storage cost drops ~100× and nobody queries 15-second resolution from six months ago.
45.5 Ad Click Aggregation (Stream Processing)
Key insight tested: event time vs processing time, and late data.
Click events → Kafka (partitioned by ad_id) → Flink
• Event-time windows (tumbling 1 min) keyed by (ad_id, campaign)
• WATERMARK = max_event_time − allowed_lateness(5 min)
"I will not see events older than this" — the promise that lets
a window close
• Allowed lateness 1 hour → late events update already-emitted results
• Side output for events later than that → a repair job, not silent loss
• Deduplication on click_id in a keyed state store with TTL
• Two-phase-commit sink → effectively-once into the serving store
The reconciliation answer that impresses: streaming gives you fast, approximate counts; a nightly batch job over the raw event log recomputes authoritative numbers, and you reconcile. Money is billed on the batch number, dashboards use the stream. That's the Lambda architecture's surviving lesson, and it's how ad systems actually work — nobody bills advertisers off a streaming aggregate that might have dropped a window.
45.6 Payments Ledger with Idempotency
Key insight tested: double-entry, immutability, and idempotency as an API contract.
-- Entries are IMMUTABLE. No UPDATE, no DELETE, ever. Corrections are
-- new reversing entries. This is 500-year-old accounting practice and
-- it's non-negotiable.
CREATE TABLE ledger_entries (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL,
account_id UUID NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('debit','credit')),
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), -- integers only
currency CHAR(3) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The invariant: for any transaction_id, SUM(debits) = SUM(credits).
-- Enforce in the write path AND verify continuously with a reconciliation
-- job that alerts on any imbalance. A ledger that can drift silently
-- is not a ledger.
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL, -- detect key reuse with DIFFERENT body
response JSONB,
status TEXT NOT NULL, -- in_progress | complete
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Idempotency semantics that show you've done this (Stripe's model is the reference): the key is provided by the client; same key + same body returns the cached response; same key + different body is an error (422), not a silent overwrite — that catches client bugs; a key seen while in_progress returns 409 so concurrent retries don't double-execute; keys expire after 24 hours.
Balances are a projection (§42.2), maintained incrementally with periodic full recomputation for verification. Never store balance as the source of truth.
45.7 Ride-Hailing Dispatch
Key insight tested: geospatial indexing and the matching loop.
- Geo index: geohash, S2 cells (Google), or H3 hexagons (Uber's, and the better choice — hexagons have uniform neighbor distance, unlike squares where diagonal neighbors are farther). Driver locations stream into a per-cell index in Redis with short TTLs.
- Matching: don't greedily match each rider to the nearest driver — that's locally optimal and globally poor. Batch requests over a short window (a few seconds) and solve a bipartite assignment (Hungarian algorithm or a min-cost flow approximation) over the batch. Uber has published on batched matching outperforming greedy; naming this is a strong differentiator.
- Supply/demand signals feed surge pricing, which is a separate service consuming the same location stream.
- Scale: shard by geography — a city is an independent unit, which is also the natural blast-radius and deployment boundary (§39.1 rings).
Follow-up: "driver location updates at 1M writes/sec?" → Don't durably persist every ping. Keep current location in memory/Redis, sample to durable storage at a lower rate for the trip record, and use the stream for real-time matching. Not all writes deserve durability — deciding which do is the design.
45.8 Object Storage / File Sync (Dropbox-style)
Key insight tested: content-addressed chunking and delta sync.
- Chunk files with content-defined chunking (rolling hash / Rabin fingerprint) so an insertion at the start of a file doesn't shift every subsequent boundary — fixed-size chunking gets this wrong and re-uploads the whole file.
- Content-address each chunk by hash → automatic deduplication across all users, and sync becomes "which chunk hashes do you not have."
- Metadata service (file tree, versions, sharing) separate from the block service (chunk storage on S3). Different scaling properties, different consistency needs.
- Sync protocol: client computes local chunk list, server diffs, transfers only missing chunks. Notification of remote changes over a long-lived connection.
- Conflict resolution: last-writer-wins with conflict copies ("file (conflicted copy from Sam's laptop)") is what real products ship — honest about the fact that automatic merge of arbitrary binary files is impossible.
45.9 Ticketing / Inventory Under Contention
Key insight tested: how you handle 100k people wanting 1k seats.
The naive design (SELECT available; UPDATE) fails three ways:
1. Lock contention on the same rows melts the database
2. Oversell under race conditions
3. Queue collapse — everyone retries simultaneously
The real design:
1. VIRTUAL WAITING ROOM at the edge. Admit users at a rate the
backend can serve. This is the single most important decision:
you shape demand instead of absorbing it.
2. INVENTORY IN REDIS as an atomic counter (DECR), not in the RDBMS.
Redis is single-threaded — DECR is atomic and fast. The database
is the durable record, updated asynchronously.
3. RESERVATION with a TTL (10 min) — a hold, not a sale. Expired
holds return inventory automatically.
4. IDEMPOTENT checkout keyed on reservation ID.
5. Overselling protection: reserve a small buffer; reconcile
continuously against the durable record.
Follow-up: "bots?" → Rate limiting by identity not IP, proof-of-work or CAPTCHA at the waiting room, device fingerprinting, and purchase limits per verified identity. Accept that this is an arms race and instrument it as one.
45.10 Multi-Region Active-Active KV Store
Key insight tested: you must state the conflict resolution strategy unprompted.
- Partition by key hash; replicate each partition across regions.
- Write path options: (a) single-writer-per-key (route writes for key K to its home region — no conflicts, cross-region write latency for non-local keys); (b) local writes with async replication and conflict resolution (low latency, conflicts guaranteed); (c) global consensus per write (Spanner-style — correct, expensive, 100 ms+ cross-region).
- Conflict resolution: LWT with HLC timestamps (simple, silently loses data), vector clocks with application-level merge (Dynamo's model, pushes complexity to the client), or CRDTs (converges automatically, constrains your data types — §40.3).
- State the choice and its cost. "DynamoDB global tables use last-writer-wins and it is not configurable — if the business can't tolerate silent loss on concurrent writes to the same key, global tables are the wrong primitive."
- Failover: health-checked GeoDNS or anycast; and the hard part is not failover but failback with divergent data (§32).
46. Storage Engines & Databases, Expanded
46.1 LSM Trees vs B-Trees — the fundamental fork
Every storage decision traces back to this. Be able to draw both.
B+TREE (Postgres, MySQL/InnoDB, most RDBMS)
• Fixed-size pages (8 KB Postgres, 16 KB InnoDB) in a balanced tree
• WRITE: find the leaf, modify IN PLACE, write the whole page
→ random I/O, write amplification = page_size / row_size
(a 100-byte update rewrites 8 KB = 80x amplification)
→ must write to the WAL first (and Postgres writes the ENTIRE page
to WAL on first touch after a checkpoint — "full page writes" —
to survive torn pages)
• READ: O(log n) page reads, typically 3-4 levels, top levels cached
→ excellent, predictable read latency
• Space amplification ≈ 1.3x (fill factor, fragmentation)
LSM TREE (RocksDB, Cassandra, ScyllaDB, LevelDB, HBase, and the engine
under DynamoDB, CockroachDB, TiKV, Kafka Streams state stores)
• WRITE: append to WAL + insert into an in-memory memtable (skiplist)
→ SEQUENTIAL I/O only. Write amplification at ingest = ~1x
→ memtable fills → flushed as an immutable sorted SSTable to L0
• COMPACTION merges SSTables in the background
→ THIS is where LSM's write amplification actually lives:
leveled compaction ≈ 10-30x total; tiered ≈ 4-10x
• READ: check memtable → check each level's SSTables
→ potentially many disk reads. Mitigated by:
- BLOOM FILTERS per SSTable (~10 bits/key = 1% false positive;
turns "is this key here?" into a memory lookup)
- Block cache for hot data
- Index and summary blocks
→ read amplification is the LSM's cost
• Space amplification: leveled ~1.1x, tiered up to 2x+ (obsolete data
lingering until compaction)
THE TRADE (RUM conjecture — you optimize two of Read, Update, Memory):
B-tree → read-optimized, low space amp, high write amp
LSM → write-optimized, low space amp (leveled), high read amp
Compaction strategies — and picking one is a real interview question:
| Strategy | Write amp | Read amp | Space amp | Use when |
|---|---|---|---|---|
| Leveled (LCS) | High (~10-30×) | Low (≤1 SSTable per level) | Low (~1.1×) | Read-heavy, update-heavy, space matters |
| Size-tiered (STCS) | Low (~4×) | High (many overlapping tables) | High (up to 2×+) | Write-heavy, mostly-immutable data |
| Time-window (TWCS) | Lowest | Low for time-range queries | Low | Time series with TTL — whole SSTables expire and are dropped without merging. Correct answer for metrics/events. |
| Universal/hybrid | Middle | Middle | Middle | RocksDB default-ish compromise |
The tombstone problem (Cassandra's signature failure mode). A delete in an LSM writes a marker, not a removal — the data still exists in older SSTables. The tombstone can only be purged after gc_grace_seconds (default 10 days, sized to allow repair to propagate the delete everywhere). Consequences:
- A range scan over a partition with a million tombstones reads all of them →
TombstoneOverwhelmingException, query timeouts, and node instability. - Queue-like workloads on Cassandra are an anti-pattern — insert, read, delete, repeat over the same partition accumulates tombstones catastrophically. If you're asked "why is our Cassandra queue slow," this is the answer.
- Skipping repair past
gc_grace_secondscauses data resurrection: a node that missed the delete re-propagates the old value after the tombstone is purged elsewhere.
RocksDB tuning parameters worth knowing by name: write_buffer_size (memtable size), max_write_buffer_number, level0_slowdown_writes_trigger / level0_stop_writes_trigger (backpressure when compaction falls behind — "write stall" is the symptom you'll be asked to diagnose), target_file_size_base, bloom_filter_bits_per_key, and separating the WAL onto a different device. RocksDB is embedded in an enormous amount of infrastructure (Meta built it; it's under Kafka Streams, Flink state backends, CockroachDB historically, TiKV, MyRocks, and dozens more) — knowing it is broadly transferable.
Follow-up: "Write throughput dropped 10× suddenly and disk isn't full." → Compaction is falling behind and the LSM is applying backpressure (write stall). Check pending compaction bytes, L0 file count against the slowdown/stop triggers, and whether compaction threads are starved or I/O-bound. Fixes: more compaction threads, a different compaction strategy, faster storage, or reduced ingest rate. This is a very common real incident.
46.2 PostgreSQL internals that come up
MVCC and the vacuum problem. Postgres implements MVCC by writing a new row version for every UPDATE and marking the old one dead (xmin/xmax transaction IDs). Consequences:
- An UPDATE is effectively a DELETE + INSERT → indexes must be updated too (mitigated by HOT updates when the changed column isn't indexed and the new version fits on the same page — a real reason to avoid indexing frequently-updated columns).
- Dead tuples accumulate → table bloat.
autovacuumreclaims them. If autovacuum can't keep up (long-running transactions hold the horizon open, or it's tuned too conservatively for the workload), tables and indexes bloat, sequential scans get slower, and the fix isVACUUM FULL(takes anACCESS EXCLUSIVElock — an outage) orpg_repack(online). - Transaction ID wraparound: XIDs are 32-bit. If a table isn't vacuumed within ~2 billion transactions, Postgres shuts down writes to prevent data loss. This has taken down major services (Sentry wrote a well-known postmortem). Monitor
age(datfrozenxid). Naming wraparound unprompted is a strong operator signal.
Index types and when each wins:
-- B-tree: default; equality and range on scalar types
CREATE INDEX ON orders (customer_id, created_at DESC); -- composite, order matters
-- Covering index: include non-key columns so the query never touches the heap
CREATE INDEX ON orders (customer_id) INCLUDE (status, total); -- index-only scan
-- Partial: index only the rows you query. Dramatically smaller.
CREATE INDEX ON orders (created_at) WHERE status = 'pending';
-- GIN: multi-value columns — JSONB, arrays, full-text
CREATE INDEX ON docs USING gin (metadata jsonb_path_ops);
CREATE INDEX ON docs USING gin (to_tsvector('english', body));
-- BRIN: huge append-only tables with natural correlation to physical order.
-- A BRIN index on a 1 TB time-series table is MEGABYTES, not gigabytes.
CREATE INDEX ON events USING brin (created_at) WITH (pages_per_range = 128);
-- GiST: geometric, range types, nearest-neighbour
CREATE INDEX ON shapes USING gist (geom);
Reading a query plan — the skill, not the syntax:
EXPLAIN (ANALYZE, BUFFERS, VERBOSE) SELECT ...;
-- What to look for, in order:
-- 1. Rows Removed by Filter → an index is missing or unusable
-- 2. estimated rows vs actual rows off by >10x → stale stats (ANALYZE)
-- or a correlated-columns problem (CREATE STATISTICS)
-- 3. Seq Scan on a large table in a selective query → missing index,
-- or the planner chose it because the index is unselective
-- 4. Nested Loop with a large outer → often the cause of a 100x slowdown
-- when row estimates are wrong
-- 5. shared read vs shared hit → cache miss ratio
-- 6. Sort Method: external merge Disk → work_mem too small
Connection pooling: Postgres uses a process per connection (~10 MB each); a few hundred connections is the practical ceiling. PgBouncer in transaction pooling mode multiplexes thousands of clients onto tens of server connections — but it breaks session-scoped features: prepared statements (pre-PG14 protocol-level), SET/session variables, advisory locks, LISTEN/NOTIFY, and temp tables. Know what breaks; that's the interview content.
46.3 DynamoDB — modeling by access pattern
Single-table design, concretely. The idea: overload one table's PK/SK so that related entities share a partition and one query retrieves them together.
PK SK Attributes
------------------ -------------------- -------------------------
USER#42 PROFILE name, email, created_at
USER#42 ORDER#2026-08-01#a1 total, status
USER#42 ORDER#2026-07-15#b2 total, status
ORDER#a1 METADATA shipping_address
ORDER#a1 ITEM#1 sku, qty, price
ORDER#a1 ITEM#2 sku, qty, price
Access patterns satisfied:
"user profile + recent orders" → Query PK=USER#42, SK begins_with ""
ONE request, ONE round trip
"all items in order a1" → Query PK=ORDER#a1, SK begins_with ITEM#
"orders in a date range" → SK is sortable by design (ISO dates)
"all pending orders" (GSI) → GSI1PK=STATUS#pending, GSI1SK=created_at
— a SPARSE index: only write GSI1PK
when status is pending, and the index
contains only pending orders. Elegant
and cheap.
The limits that shape design: 400 KB item, 1 MB per query page, 10 GB per partition key for LSIs (a hard cap that has caused real migrations), 3000 RCU / 1000 WCU per partition. Transactions are limited to 100 items and cost 2× — they exist for correctness, not for bulk work.
Hot partition mitigation, in order of preference: (1) pick a better partition key; (2) write sharding — append #{random(0..N)} to the PK and scatter-gather on read; (3) DAX or an application cache for read hotspots; (4) on-demand capacity to absorb spikes. Modern DynamoDB splits hot partitions automatically by both size and throughput, which handles much of this — but a single hot key cannot be split, and that's the case you must design around.
The honest verdict: DynamoDB is superb when access patterns are known and stable, and painful when they change. The migration cost of a wrong partition key is the reason "access patterns first" is the mantra. If the product is still discovering its queries, Postgres is the lower-regret choice.
46.4 Choosing — the decision walkthrough
When asked "which database," don't name one. Walk the questions:
- What are the access patterns? Point lookups, range scans, ad hoc analytics, full-text, similarity, graph traversal? This alone eliminates most options.
- What's the consistency requirement per operation? Not per system — per operation. Most systems have a few operations needing strong consistency and many that don't.
- What's the read:write ratio and absolute scale? 1000:1 at 100 QPS is a laptop. 1:1 at 1M QPS is an architecture.
- What's the data size and growth? 100 GB fits in RAM on one machine and changes everything.
- What does the team already run? A boring database the team can operate at 3 a.m. beats an optimal one they can't. This is a legitimate, senior answer and interviewers reward it.
The default recommendation to state and then justify departures from: "Postgres until it hurts." It does OLTP, JSON, full-text, geospatial, time-series (with Timescale), and now vectors (pgvector). One system, one operational model, mature tooling. Add specialized stores when a specific access pattern genuinely outgrows it — and be able to name the specific metric that triggered the move.
47. Retrieval, Search & Ranking, Expanded
47.1 The inverted index, mechanically
Documents:
d1: "distributed systems design"
d2: "systems design interview"
Inverted index (term → posting list):
distributed → [(d1, tf=1, pos=[0])]
systems → [(d1, tf=1, pos=[1]), (d2, tf=1, pos=[0])]
design → [(d1, tf=1, pos=[2]), (d2, tf=1, pos=[1])]
interview → [(d2, tf=1, pos=[2])]
Phrase query "systems design" uses POSITIONS: find docs where
pos(design) = pos(systems) + 1. This is why positions are stored
and why they cost index space.
Lucene segment mechanics — the operational model behind Elasticsearch/OpenSearch:
- A segment is an immutable mini-index. Writes go to an in-memory buffer.
- Refresh (default 1 s) makes the buffer searchable as a new segment — this is why ES is "near-real-time," not real-time. Set
refresh_interval: -1during bulk loads for a large throughput win. - Flush persists to disk and truncates the translog (durability, not visibility — a distinction candidates confuse).
- Merge combines small segments into larger ones in the background. Deletes are tombstones until a merge purges them (same pattern as LSM — because Lucene is an LSM-ish structure).
- Consequences: too many segments → slow queries (every segment is searched); merge storms → I/O spikes; a
force_mergeon a live index is a foot-gun except on read-only indices.
BM25, and why it beat TF-IDF:
score(q,d) = Σ IDF(t) × [ tf(t,d) × (k1+1) ] /
[ tf(t,d) + k1 × (1 − b + b × |d|/avgdl) ]
k1 (≈1.2) controls TERM FREQUENCY SATURATION — the 10th occurrence of
a word adds far less than the 2nd. Raw TF-IDF grows unboundedly and
rewards keyword stuffing; BM25 saturates.
b (≈0.75) controls LENGTH NORMALIZATION — long documents shouldn't win
just by containing more words.
Being able to say "BM25 is TF-IDF plus saturation and length normalization" in one sentence is the level of fluency expected.
47.2 Vector search internals
HNSW builds a multi-layer proximity graph: sparse long-range links at the top for fast traversal, dense short-range links at the bottom for accuracy. Search descends greedily.
Parameters and their real effects:
m (16-64) max links per node. ↑ = better recall,
more memory (m × 8 bytes × N), slower build
ef_construction (128-512) candidate list size at BUILD time.
↑ = better graph quality, much slower indexing
ef_search (m..1000) candidate list at QUERY time. ↑ = better recall,
linearly slower. THE runtime recall/latency dial.
Memory: vectors (d × 4 bytes × N) + graph (m × 8 × N)
Deletes are soft — HNSW cannot cheaply remove a node without damaging
graph connectivity. Deleted nodes are marked and skipped, and the index
degrades until rebuilt. HIGH-CHURN CORPORA ARE HNSW'S WEAK SPOT — say so.
| Index | Memory | Recall | Build | Filter support | Use when |
|---|---|---|---|---|---|
| Flat (exact) | 1× | 100% | none | perfect | <1M vectors, or post-filter subsets |
| HNSW | High | 95-99% | slow | good (filtered traversal) | The default |
| IVF-Flat | 1× + centroids | tunable via nprobe | fast | good | Large, memory-available |
| IVF-PQ | 10-50× smaller | 80-95% | fast | poorer | Memory-constrained, billions |
| ScaNN | Middle | High | Middle | good | Google's; strong recall/speed frontier |
| DiskANN | Low RAM, SSD-resident | High | slow | good | Billion-scale on a budget |
47.3 Learning to rank and the multi-stage funnel
500M docs
│ RETRIEVAL (recall-oriented, cheap: BM25 + ANN) → ~1000
│ LIGHT RANKING (fast model, few features) → ~200
│ HEAVY RANKING (LTR / cross-encoder, many features) → ~20
│ RE-RANKING (diversity, freshness, business rules) → 10
▼
The principle: each stage is 10–100× more expensive per document and operates on 10× fewer documents. Total cost stays flat while quality rises. Getting the funnel wrong — running an expensive model too early — is the most common architectural mistake in search and recsys.
LTR approaches: pointwise (predict a relevance score — simple, ignores that ranking is relative), pairwise (RankNet — learn which of two docs is better), listwise (LambdaMART/LambdaRank — directly optimizes NDCG; LambdaMART with gradient-boosted trees remains the strong baseline and often beats neural rankers on tabular feature sets). Neural: cross-encoders (BERT over query+doc concatenated — best quality, must run on a short candidate list), ColBERT (late interaction — token-level matching with precomputed doc embeddings, a middle ground).
Features that matter: query-document (BM25 per field, semantic similarity, exact-match indicators), document (PageRank-ish authority, freshness, quality signals, length), query (length, intent class, language), and user/context (personalization, location, device, session history). Feature freshness and training/serving skew is where LTR systems actually break — the feature store exists for this.
47.4 Evaluation done right
OFFLINE
NDCG@k — graded relevance with position discounting. The default.
MRR — for known-item search (one right answer)
Recall@k — for the RETRIEVAL stage specifically; a ranking metric on
a candidate set you never retrieved is meaningless
MAP — multiple relevant docs, binary relevance
ONLINE
Interleaving — team-draft: merge two rankings, attribute clicks to the
system that contributed the clicked item. 10-100x more sensitive than
A/B because it removes between-user variance. THE tool for ranking
experiments, and underused outside search teams.
A/B — needed for effects interleaving can't see (engagement over time,
revenue, retention)
Guardrails — latency, zero-result rate, abandonment, query reformulation
rate (a rising reformulation rate means users aren't finding things,
even if CTR looks fine)
THE TRAP: offline/online correlation is often weak. Validate that your
offline metric predicts online outcomes before trusting it to gate
launches. Many teams optimize NDCG for a year and move no business metric.
Position bias and counterfactual evaluation: click logs are biased — position 1 gets clicked regardless of relevance. Correct with a position-based propensity model and inverse propensity scoring, or collect unbiased data via randomized interleaving. Mentioning IPS marks you as someone who's done real relevance work.
48. Leadership Scenarios — Full Worked Answers
Each scenario below gives the structure of a strong answer, the actual words where wording matters, the trap, and the follow-up. These are the role-plays from §8.4, §23, and §25 with answers attached.
48.1 "Your strongest engineer is toxic in code review."
Structure: first move → information → the line.
"First move is a private conversation, quickly — within a day or two of noticing, not at the next 1:1 three weeks out, because every day it continues the team learns that it's acceptable.
Before that conversation I'd gather specifics. 'You're being harsh' is unactionable. I'd have two or three actual review comments and the effect they had — a junior who stopped opening PRs, or a change that got reverted because nobody wanted to push back. SBI: situation, behavior, impact.
In the conversation I'd assume good intent and say so, because usually there is some: strong engineers who are harsh in review are often protecting quality and don't see the cost. I'd say something like: 'Your review standards are one of the reasons this codebase is good, and I don't want to lose that. But the way three of these landed made people less likely to ship, and that costs us more than the bugs you're catching. Here's what I'd like to change.'
Then I'd make it concrete rather than a vibe request: adopt the comment taxonomy —
blocking:/suggestion:/nit:— so severity is explicit instead of inferred from tone; require that a blocking comment proposes an alternative; and move style entirely to automation so those comments stop existing.The line I wouldn't cross: if it continued after clear feedback, it stops being a coaching problem and becomes a performance problem, and I'd say that explicitly at the second conversation rather than letting it drift. Being technically excellent doesn't exempt someone from how they affect the team, and if I let it, I've told everyone else that the rules are negotiable for strong performers."
The trap: framing it as "be nicer." That's unactionable and reads as a personality critique. Concrete process changes give the person a way to succeed.
Follow-up — "What if they push back and say the juniors are just too sensitive?" → Move from opinion to data: review latency on their PRs, how often juniors request them as a reviewer, the pattern of who stopped contributing. And separate the two claims: the standard can be right and the delivery can be costly. You're not asking them to lower the bar.
48.2 "An engineer has missed three commitments in a row."
"I'd start from the assumption that I don't know why, because there are four very different causes and they have opposite fixes: they're blocked and not escalating; they're overloaded with invisible work; the estimates were unrealistic and I signed off on them; or it's a genuine capability or engagement problem. Diagnosing before acting is the whole game here.
The conversation opens with observation, not accusation: 'The last three commitments slipped. I want to understand what's happening — walk me through the last one.' Then I mostly listen.
If it's blockers — my failure. I own that I wasn't creating enough safety to escalate, and we set a rule that anything blocked more than a day comes to me.
If it's estimation — partly my failure too. We break work smaller until items are under a week and use historical cycle time rather than optimism.
If it's genuine underperformance, then I get very specific: what the expectation is, what the gap is, what support I'm providing, and by when I need to see change. In writing, so there's no ambiguity. And I'd say plainly that this is a performance conversation, because the cruellest thing a manager does is deliver a soft signal and then act on a hard one.
The principle underneath all of it: no surprises at review time. If this ends up in a performance plan in three months, it should be the fourth conversation about it, not the first."
Follow-up — "They say the work is boring." → That's useful information, not an excuse. Boredom in a strong engineer is a retention risk with a fix; boredom as a reason for missed commitments is still a commitment problem. Address both, separately: renegotiate what they work on, and keep the reliability expectation.
48.3 "A PM promises a date to a customer without asking you."
"Two problems, and I'd deliberately separate them: the immediate date, and the process that produced it. Solving only the first guarantees a repeat.
On the date — I don't lead with 'that's impossible.' I lead with options and costs: 'Here's what we can deliver by that date, here's what it would take to deliver all of it, and here's what we'd be trading.' Usually a reduced scope that meets the customer's actual need exists, and finding it is more useful than being right about process.
On the process — that's a separate, calm conversation, not in front of the customer or the team: 'I want to make sure you can commit to dates confidently. To do that you need me in the room before the number goes out. What would make that easy?' Framing it as helping them be more credible works far better than 'don't do that again.'
If it repeats after that, I'd escalate — but with data, not grievance: the specific commitments, what they cost, and a proposed working agreement. And I'd bring my director in as a peer conversation with the PM's manager, not as a complaint."
The trap: protecting the team by being obstructive. You'll win the battle and lose the partnership, and the PM will start routing around you.
48.4 "Your director wants a date you can't commit to."
"I never answer with a flat no, and I never give a date I don't believe. Both destroy credibility, just on different timelines.
What I give is a distribution and its drivers: 'Based on our cycle time over the last quarter, I'm 50% confident in October 15th and 85% confident in November 5th. The variance is dominated by two things: the vendor integration, which I don't control, and whether we get the second backend engineer. If we descope the reporting module, the 85% date moves to October 20th.'
That does three things at once: it's honest, it's actionable — the director now has levers — and it demonstrates I'm forecasting rather than guessing.
If they need the earlier date regardless, then it's an explicit trade conversation: scope, quality, or people. I'd put the options in writing with what each costs, so the decision is made with open eyes and there's a record of what we agreed. And then I'd commit to whatever we choose without relitigating it.
The one thing I hold firm on is not committing to something I believe is impossible, because the cost lands later and larger — on the team's trust and on the director's own credibility upward."
Follow-up — "They say 'I need you to make it work.'" → "I will make the best version of this work, and I'll tell you now what I think we'll be trading, so you're not surprised in six weeks." Commit to effort and honesty, not to arithmetic you don't believe.
48.5 "Two teams are building the same service. You have no authority over either."
"Influence problem, so I start with understanding rather than a proposal, because arriving with a solution to a problem I haven't diagnosed is how you get politely ignored.
I'd talk to both leads separately and find out what each is actually solving. Often they're not building the same thing — they're building two things that look similar from outside and differ in a requirement that matters. If that's true, the answer isn't consolidation, it's a clear boundary and a shared interface, and I've saved everyone a fight.
If it is genuine duplication, I'd write it up: what each system does, where they overlap, what the duplication costs in maintenance and cognitive load, and two or three options — one team owns it and the other consumes; extract a shared library; or keep both with an explicit boundary and a sunset date for one. Options with costs, not a verdict. Nobody adopts a conclusion they weren't part of reaching.
Then I'd get the two leads in a room with the document and let them react. My goal is for one of them to propose the answer, because a decision they own survives and a decision I impose gets quietly ignored.
If they can't agree, I escalate — and escalation is a tool, not a failure. I'd bring both leads and the written options to the shared manager, framed as 'we've narrowed to two paths and need a decision-maker,' not as a complaint about the other team. DACI is worth naming here: the problem is usually that no one is the named approver."
48.6 "Review queue depth doubled after the AI tooling rollout."
"Expected, and it's the specific failure mode I'd have been watching for — AI moves the bottleneck from writing code to reviewing it, and the industry data shows throughput up and change failure rate up together.
I'd measure before acting: time-to-first-review, merge time, PR size distribution, and change failure rate split by AI-assisted and not. My hypothesis is that PR size grew, because it's suddenly cheap to generate 800 lines.
Then the interventions, roughly in order of leverage:
- PR size limits. Under ~400 lines. This is the single biggest lever, because review quality collapses past that and large PRs get rubber-stamped — which is the actual risk, not the queue depth.
- Author accountability. The rule I'd set explicitly: you must be able to explain every line you submit as if you wrote it. AI assistance doesn't transfer authorship of the understanding.
- Raise the automated bar so humans review design and correctness, not mechanics: coverage requirements on new code, stricter static analysis, contract tests.
- Review SLA and reviewer rotation so the load doesn't concentrate on two seniors, which is where it always lands.
- Label AI-assisted PRs and track their change failure rate separately — that's the number that tells you whether the speed is real.
What I'd tell my director: 'We're getting the velocity gain, and it's showing up as review debt. Here's the instrumentation and here's what I'm changing. I'd expect merge time to normalize in six weeks and I'll report the change failure rate split either way.' Naming the risk before it becomes an incident is the whole job."
48.7 "Make the case for 25% reliability investment to a product-focused VP."
"I'd never argue this on engineering aesthetics — 'we need to pay down tech debt' loses every time to a feature with a revenue number attached. I'd translate it into their units.
Three moves:
- Cost of the status quo, quantified. 'We spent 340 engineer-hours on incidents last quarter — that's 1.5 engineers of capacity we didn't spend on roadmap. Change failure rate is 18%, so roughly one in five releases costs us a rollback and a re-do.'
- Tie it to a business metric they already own. 'Our p99 on checkout is 2.4 seconds. Every 100 ms historically correlates with X% conversion on our funnel. This isn't reliability work, it's revenue work.'
- Make it bounded and measurable. Not 'we need 25% forever' — 'I want 25% for two quarters, targeting change failure rate under 8% and incident hours under 100. If we hit that, it drops to 15% steady-state. If we don't, you should question my plan.' An open-ended ask is easy to refuse; a bounded one with a success criterion is a bet.
And I'd offer the error budget framing as the durable mechanism: when we're within budget we ship fast, when we've burned it we fix reliability. That converts a recurring argument into a policy we agreed on in advance, and it's much easier to get agreement in calm times than during an incident."
48.8 "An engineer wants promotion; they're one level of scope short."
"The worst thing I can do is be vague, because vagueness reads as 'no' without giving them anything to act on, and that's how you lose good people.
I'd be direct and specific: 'I think you'll get there, and I don't think the packet would succeed this cycle. Here's the gap.' Then name it concretely against the rubric — usually at this boundary it's scope and influence, not technical skill: they're executing excellently on work someone else defined, and the next level requires defining it and pulling others along.
Then the part that matters: I own half of the gap. If they haven't had cross-team scope, that's partly because I haven't assigned it. So I'd leave the conversation with one or two specific opportunities — own the migration that touches three teams, run the design review process, mentor two engineers through their own growth — chosen because they generate the exact evidence the packet needs.
And a timeline with a checkpoint: 'Let's look again at the end of Q1. Here's what I'd expect to be able to write about you by then.'
The thing I'd avoid is the false promise — 'do these things and you'll be promoted' — because calibration involves other people and I don't control the outcome. What I can promise is that I'll advocate with specific evidence, and that I'll tell them early if I think it's not going to land."
48.9 "You inherit a team with low morale after a reorg."
"First 30 days is listening, and I'd say that to the team explicitly so my lack of decisions doesn't read as absence.
1:1 with everyone in week one, with three questions: what's working, what's broken, and what do you want that you're not getting. I'd take notes and look for the pattern — after eight conversations, the two or three real problems are obvious, and they're rarely what leadership thinks they are.
Then I'd pick one visible, achievable fix and ship it inside the first month. Not the biggest problem — the one I can definitely deliver. Morale after a reorg is fundamentally a belief problem: people don't believe things will improve. One delivered fix buys more credibility than the best strategy document.
In parallel I'd be honest about what I can't change. Reorgs usually come with decisions that are final, and pretending otherwise burns trust when reality arrives. 'That decision isn't reversible. Here's what I can affect, and here's what I'll push on.'
Around day 60 I'd share back what I heard, what I'm doing about it, and what I'm not — closing the loop is what turns a listening tour into something other than theater. The failure mode of listening tours is that people tell you things and nothing visibly happens, which is worse than not asking."
48.10 "You've been down-leveled in an offer."
"First, separate the emotion from the decision — the down-level might be right, or it might be an evidence problem.
I'd ask the recruiter a specific question, not a general one: 'What signal was missing for the higher level?' Usually the answer falls into one of three buckets: I demonstrated execution but not scope; a specific round went badly; or the team's level need is genuinely lower and it's not about me.
If it's an evidence problem, I'd ask whether additional signal can be considered — a conversation with a hiring manager focused on cross-org work, or a written artifact. That request is normal and is sometimes granted; the worst outcome is a polite no.
If the level is real, then it's a straightforward decision about the actual job: is the scope interesting, is the trajectory credible, and is there a written commitment on a level review timeline? A verbal 'you'll get promoted quickly' is worth nothing; a documented review at six months with named criteria is worth a lot.
And I'd negotiate level before compensation, because the band follows the level and arguing dollars inside the wrong band is fighting the wrong fight."
49. Complete Drill Bank — Answer Key
Compressed answers to all 66 drills. If you can expand any of these to two minutes with an example from your own work, you're ready.
AI/LLM (1–6)
- Prefill vs decode bottleneck — prefill processes all tokens in parallel (matrix-matrix, high arithmetic intensity → compute-bound); decode generates one token at a time (matrix-vector, must read all weights per token → memory-bandwidth-bound). §41.1.
- Confidently wrong RAG — bisect: did retrieval return the right chunk? If no → chunking, embedding, or query understanding. If yes → generation ignored it: check context position (lost in the middle), prompt grounding instructions, and context length. Separate retrieval metrics from generation metrics or you can't tell. §43.2.
- Fine-tune vs retrieval — fine-tune for form (tone, format, domain vocabulary, output structure) and for latency/cost at high volume; retrieve for facts (anything that changes, anything permissioned, anything needing citation). Never fine-tune to teach facts you'll need to update.
- 20-step agent eval — task-level success rate with programmatic checks, not per-step accuracy. Plus steps-to-completion, cost per successful task, human-intervention rate, unsafe-action rate. §44.3.
- Indirect prompt injection defense — least privilege first (the injected instruction has no capability to abuse), structural separation of data from instructions, egress allowlists, output validation, and breaking the lethal trifecta (private data + untrusted content + egress). §44.3.
- Cut LLM spend 60% — prompt caching → model routing with escalation → context trimming (often improves quality) → semantic caching (gated) → output length limits → batch API → distillation. §41.6.
Context/Graph (7–9) 7. 3,000-token prompt of business rules — that's knowledge in the wrong place. Move it to retrievable context with governance, keep the prompt for interaction design. §16.1. 8. Graph over vector — multi-hop relational questions, explanation/auditability requirements, entity-centric domains. Not for lookup-shaped questions over churning corpora. §16.5. 9. 128k context budget — explicit allocation: system + tools + retrieved + history + output reserve. Critical instructions at start and end. Compact history into structured state, isolate sub-agents. §16.3.
Streaming/Reactive/APIs (10–15)
10. Kafka exactly-once — idempotent producer (PID + sequence) + transactions + read_committed. Covers Kafka-to-Kafka only; it does not make your database write or external API call exactly-once. §17.1.
11. flatMap vs concatMap — flatMap interleaves, unbounded concurrency by default, order not preserved; concatMap serializes, preserves order. Use concatMap when order matters, flatMapSequential for concurrency with ordered emission. §17.2.
12. Virtual threads vs WebFlux — for a new service on JDK 21+, virtual threads give the scalability with blocking-style code and far lower complexity. Reactive still wins for genuine streaming with backpressure across a network boundary. Watch pinning on synchronized. §17.2.
13. GraphQL N+1 — DataLoader batches within a request tick and caches per request. Caching doesn't solve it because the problem is per-request resolver fan-out, not repeated identical queries across requests. §17.3.
14. L4 LB breaks gRPC — gRPC multiplexes many requests over one long-lived HTTP/2 connection; an L4 balancer balances connections, so all requests pin to one backend. Fix: L7 balancing, client-side LB, or a service mesh. §17.4.
15. Watermarks — they answer "have I seen everything for this window yet?" in event time. max_event_time − allowed_lateness. Enables windows to close and results to emit despite out-of-order arrival. §17.1.
Caching/Edge (16–18)
16. Cache stampede — request coalescing (single-flight), probabilistic early expiration, jittered TTLs, lock-and-refresh with stale-while-revalidate. §18.4.
17. Complex-dependency invalidation — surrogate keys / cache tags: tag responses with the entities they depend on, purge by tag. §18.4.
18. SSE vs WebSocket for tokens — SSE: unidirectional is all you need, works with existing HTTP infra and auth, built-in reconnect with Last-Event-ID. WebSocket adds bidirectional capability you don't use and infrastructure friction you don't want. §18.2.
Compute/K8s/OS (19–23)
19. Healthy but slow pod — CPU throttling (CFS quota) → check container_cpu_cfs_throttled_seconds; then GC pauses; then DNS (ndots:5); then noisy neighbor; then a slow dependency. Order by cost to verify. §19.3.
20. Removing CPU limits helps latency — limits enforce a CFS quota; a bursty service hits the quota and gets throttled hard even when the node is idle. Requests guarantee the floor; limits cap the ceiling you often want to exceed briefly. §19.3.
21. Serverless vs containers math — serverless wins below ~30–40% steady utilization; always-on wins above. Compute GB-seconds vs instance-hours and include ops cost. §19.1.
22. fsync and durability — a write returning means it's in the page cache, not on disk. fsync forces it to stable storage. Databases fsync the WAL before acknowledging a commit; skipping it trades durability for throughput. §19.4.
23. eBPF for intermittent latency — attach to syscall tracepoints and kernel functions to histogram latency without instrumenting the app: bpftrace for one-off histograms, bcc tools (biolatency, runqlat, tcpconnlat) for standard questions. Zero-instrumentation, production-safe. §19.4.
Storage (24–28)
24. DynamoDB table for 5 access patterns — enumerate patterns first, then design PK/SK to satisfy the primary ones with begins_with queries, GSIs (sparse where possible) for the rest. §46.3.
25. Cassandra range query started timing out — tombstones. Deletes accumulate as markers; a range scan reads them all. Check tombstone warnings, the workload pattern (queue-shaped?), and compaction strategy. §46.1.
26. Mongo shard key — high cardinality, even write distribution, and present in your common queries. Avoid monotonic keys (hot shard). Hashed for distribution, ranged for range queries — you rarely get both. §20.3.
27. Zero-downtime reindex — build the new index alongside, backfill with refresh_interval: -1, dual-write, verify with a diff, atomically swap the alias, keep the old for rollback. §20.4.
28. Postgres write skew — REPEATABLE READ is snapshot isolation and permits it. Two transactions read overlapping data, write disjoint rows, jointly violate an invariant. Fix with SERIALIZABLE (plus retry logic) or SELECT ... FOR UPDATE to materialize the conflict. §40.3.
Security (29–33)
29. PKCE for confidential clients — defends against authorization-code interception even where a secret exists; OAuth 2.1 makes it mandatory. Defense in depth costs nothing. §21.1.
30. Revocation with stateless JWTs — short TTLs (5–15 min) plus refresh rotation; a revocation list checked at refresh; or a jti/version claim checked against a fast store for high-value operations. Accept a bounded window or you've reinvented sessions. §21.1.
31. Critical CVE, first 4 hours — inventory (do we run it, where, what version), exposure (internet-facing? reachable code path?), mitigate (WAF rule, feature flag, network policy), patch, verify, communicate. Prioritize by KEV/EPSS × exposure, not CVSS alone. §21.4.
32. Zero trust in 60 seconds — verify every request explicitly regardless of network location; least privilege; assume breach. Sequencing: identity → device → workload → network → data. §21.2.
33. CVSS vs EPSS — CVSS scores theoretical severity; EPSS predicts real exploitation probability; CISA KEV lists what's actively exploited. Patch KEV first, then EPSS × exposure. §21.4.
Frontend (34–36) 34. Redux holding server data — migrate server state to TanStack Query (caching, staleness, invalidation, optimistic updates are its job), keep Redux for genuine UI state. Usually deletes half the store. §22.2. 35. Bad INP — long tasks blocking the main thread on interaction. Profile with the Performance panel; break up long tasks, defer non-critical work, virtualize lists, reduce re-render cascades from context. §22.3. 36. Micro-frontends worth it — independent teams needing independent deploy cadence at real scale. Otherwise the coordination and bundle costs exceed the benefit. §22.3.
Leadership (37–42) — see §48 for full answers.
Architecture & delivery (43–52) 43. Burn-rate alerts — alert on budget consumption rate, multi-window multi-burn-rate (e.g. 14.4× over 1h AND 5m to page). Long window confirms significance, short window confirms it's current. §28.1. 44. Prometheus cardinality explosion — an unbounded label (user ID, request ID). Fix at the Collector: label allowlists, drop/bucket high-cardinality dimensions, route those questions to traces. §28.2. 45. Testing 40 microservices — consumer-driven contract testing (Pact) in CI, plus schema breaking-change detection (buf/OpenAPI diff). Full-environment integration suites don't scale. §30.3. 46. Deploy vs release — deploy moves bits, release exposes users; decoupled by feature flags. Lets you deploy continuously and release deliberately. §30.4. 47. Split a 15-person team — by cognitive load and stream alignment (Team Topologies), not by technical layer. Layer-based teams force every feature through three backlogs. §29.3. 48. Modular monolith vs extraction — default to the monolith with enforced module boundaries; extract when a specific force demands it (independent scaling, independent deploy cadence for separate teams, fault isolation, regulatory separation). §29.2. 49. Experiment won target, tripped latency guardrail — don't ship. Guardrails exist to prevent exactly this trade. Investigate whether the latency cost is inherent or fixable, then re-run. §30.5. 50. "Can't lose data, can't be down" — extract RTO and RPO as numbers, then show the cost curve: backup/restore → pilot light → warm standby → active-active. The business picks a point on it. §32. 51. Chaos-test an untested assumption — state the steady-state hypothesis, inject the specific fault (AZ loss, dependency latency), measure blast radius against the hypothesis, with an abort switch. §28.3. 52. Down-leveled — §48.10.
Patterns & contracts (53–60) 53. Repository, then argue against it — it decouples the domain from persistence and enables in-memory testing; but a generic repository over a capable ORM adds a layer without decoupling (ORM semantics leak through). §42.5. 54. CQRS adoption ladder — Level 0–4; place the team, and note that most need Level 1–2 and adopt Level 4. §42.1. 55. Vendor 99.5%, we sell 99.9% — you've promised what you can't structurally deliver. Options: redundancy (second vendor, failover), graceful degradation that keeps you up without them, caching to survive their outages, or renegotiate the SLA. §36. 56. Genuine unknown — state the boundary, reason from adjacent knowledge, name how you'd find out. Never bluff; never stop at "I don't know." §38.2. 57. 90 seconds with the CTO — ask about strategy, not the team: how engineering shows up in company strategy, or the under-appreciated bet. §37.5. 58. SCOR → STAR — Situation+Complication → S; chosen option and execution → T/A (compress alternatives to one sentence); Result + reflection → R. §36. 59. Red flags to probe — pick three from §37.9 and use §37.3's peer questions to surface them without adversarial framing. 60. Scar-tissue story in a caching answer — 30 seconds, attached to a technical point: the cache that hid a correctness bug for months because the stale path was never exercised.
Deployment (61–66) 61. Canary vs A/B confusion — canary asks "is it safe" (operational metrics, minutes, asymmetric rollback); A/B asks "is it better" (product metrics, weeks, power analysis). Every A/B variant rides through canary first. §39.4. 62. Canary schedule at 200 QPS — at 1% you're getting 2 QPS, which detects only gross failures. Start at 10%, extend bake time, and accept that subtle regressions need the full rollout plus strong monitoring. Compute the minimum detectable effect and say what you can't catch. §39.3. 63. Shadow-testing a service that sends email — mirror reads freely; for side effects, either route to a sandbox provider, suppress at an outbound gate keyed on the shadow flag, or make the shadow path structurally incapable of egress. Never rely on the code "knowing" it's a shadow. §39.1. 64. Rollback broke on cache format — the process failure was not versioning the cache key with the serialization schema, so the old version read new-format entries. Fix: schema-versioned cache keys and rollback testing as part of the release checklist. §39.5. 65. Mobile crash at 40% rollout — halt the staged rollout immediately (store-level), flip the server-side feature flag off to disable the code path for already-updated clients, then diagnose. Server-side kill switches are why mobile releases need flags. §39.2. 66. Rename a column across a 3-version window — expand: add the new column, dual-write both. Migrate: backfill, switch reads to the new column, deploy through all versions. Contract: stop writing the old, verify no readers, drop. Each phase is independently deployable and rollback-safe. §39.5.
50. Closing Note on Using This Document
Reading it once is not the point. Three passes, three purposes:
- Pass one — inventory. Skim every section heading and mark anything you couldn't explain to a peer for two minutes. That's your study list. Everything else is maintenance.
- Pass two — production. Work the marked sections with the drills (§25, §49). Say the answers out loud. Write the code snippets from memory. An answer you've only read is not an answer you have.
- Pass three — retrieval. In the last two weeks, use only §31 (frameworks), §37 (questions to ask), §49 (drill answers), and your own 16 stories. That's the night-before layer.
The document's failure mode is comprehensiveness creating the illusion of preparation. The only real signal is whether you can produce the answer under time pressure, out loud, to a skeptical stranger. Everything here is in service of that, and none of it substitutes for the mocks.
Document complete: Parts I–V. Part I–IV are the map and the mechanics; Part V is the expanded technical and leadership reference with worked answers, code, production case studies, and primary sources.
51. Gap Register & Expansion Queue
An honest per-section audit against the target quality bar (definition → mechanics → code/example → production case study → tradeoff debate → follow-up Q&A → references). This doubles as the work queue for the Claude Code build described in the companion prompt file.
Depth legend: ✅ at target · 🟡 partial (insight present, treatment thin) · 🔴 outline only (terms named, not taught)
| § | Topic | Depth | Highest-priority missing pieces |
|---|---|---|---|
| 15 | AI/GenAI/LLM foundations | 🟡 | Transformer walkthrough with shapes; RoPE/ALiBi explained not named; fine-tuning ladder with a worked LoRA example; sampling strategies with output demonstrations |
| 16 | Context/prompt/graph eng. | 🔴 | A full context-budget worked example; a GraphRAG build walkthrough with real extraction prompts; entity-resolution treatment; LangGraph state-machine code |
| 17 | Streaming/reactive/APIs | 🔴 | Kafka exactly-once end-to-end walkthrough with producer/consumer code; a Reactor pipeline debugged step-by-step; DGS resolver + DataLoader full example; gRPC service with deadline propagation shown; saga-vs-2PC debate expanded |
| 18 | CDN/caching/real-time | 🔴 | WebSocket backplane implementation sketch; cache-stampede code (singleflight); a real Cache-Control decision walkthrough per asset class; CDN purge strategy worked example |
| 19 | Compute/K8s/kernel | 🔴 | CFS throttling demonstrated with numbers; a full pod-debugging transcript; namespace/cgroup hands-on commands; io_uring vs epoll explained with a benchmark; JVM-in-container sizing walkthrough |
| 20 | Storage systems | 🟡 | §46 covers engines; still missing: Cassandra data-modeling worked example (query-first), Mongo aggregation walkthrough, Iceberg table lifecycle demo, Spanner/CockroachDB read-write transaction trace |
| 21 | Security | 🔴 | Full OAuth authz-code+PKCE sequence diagram with every parameter explained; a JWT validation implementation with each check justified; STRIDE threat model worked on a real system; SSRF exploitation-and-defense walkthrough; Zanzibar/ReBAC model explained with tuples |
| 22 | Frontend | 🔴 | Reconciliation walkthrough; RSC boundary worked example; TanStack Query migration before/after; INP debugging transcript |
| 23 | Leadership playbook | 🟡 | §48 covers 10 scenarios; remaining: skill-matrix template filled in; a real capacity-planning spreadsheet walkthrough; calibration-meeting simulated transcript; 1:1 agenda examples |
| 24 | AI cross-cutting | 🟡 | Per-layer claims need one concrete example each; the six leadership questions need full §48-style answers |
| 28 | SRE/observability | 🟡 | OTel Collector config walkthrough; a burn-rate alert in PromQL; a chaos game-day runbook; k6 script with open-model load |
| 29 | DDD/Team Topologies | 🟡 | Event-storming walkthrough on a real domain; context-map worked example; an inverse-Conway reorg case study |
| 30 | Delivery engineering | 🟡 | A Pact contract test end-to-end; flag lifecycle code; a power-analysis calculation shown |
| 31 | Frameworks card | ✅ | Complete for its purpose |
| 32 | DR | 🟡 | A full DR runbook example; failback reconciliation walkthrough |
| 33–38 | Offer/questions/seasonality | ✅/🟡 | Largely complete; add 2–3 more worked negotiation dialogues |
| 39 | Deployment | 🟡 | ACA scoring worked example; an Argo Rollouts manifest annotated |
| 45 | Classic designs | 🟡 | Each needs expansion from key-insight to §43-length transcript (10 designs × ~1,500 words) |
| 46–47 | Storage/retrieval expanded | 🟡 | LTR feature-engineering worked example; a training-data pipeline for judgments; ScaNN/DiskANN mechanics |
| 49 | Drill answer key | 🟡 | Each 3-sentence answer needs a 2-minute spoken-form version |
Estimated remaining work at the target bar: ~450–700k words. This is a book build, and it belongs in the Claude Code loop described in claude-code-reference-build-prompt.md, not in further chat appends.
PART VI — THE ENGINEERING MANAGEMENT WORKSHOP
Built to the universal quality standard: every topic defined, motivated, shown mechanically, grounded in a worked example, proven with named companies, argued with alternatives, and closed with follow-ups answered several ways. This part is the one most candidates are weakest on, because it's the part you can't learn from LeetCode.
52. Team Architecture & Organizational Design
52.1 The Spotify Model — and why knowing it failed is the real signal
What it claims to be. A 2012 whitepaper by agile coaches Henrik Kniberg and Anders Ivarsson, Scaling Agile @ Spotify, describing four structures:
| Unit | Definition | Analogous to |
|---|---|---|
| Squad | Small cross-functional autonomous team owning a feature area end to end; has a Product Owner and a long-term mission | A Scrum team / stream-aligned team |
| Tribe | A collection of squads (<100 people, per Dunbar) working in a related area, led by a Tribe Lead | A department |
| Chapter | People with the same skill within a tribe (all backend engineers), led by a Chapter Lead who is their line manager | A functional discipline |
| Guild | A cross-organization community of interest (security, testing) — voluntary, no reporting lines | A community of practice |
The two intended axes: alignment (leaders set the problem) and autonomy (squads choose the solution). "Aligned autonomy."
Why this is on the exam. Because it's the most-copied org model in software, and the seasoned answer is that it never actually worked — including at Spotify.
The evidence, which you should be able to cite precisely:
- Kniberg published a post titled "No, I didn't invent the Spotify model" (2015), explaining the practices emerged from many people and were never designed as a transferable framework. By 2016 he was telling audiences directly not to copy it, on the grounds that Spotify's way of working changed constantly, so any copy would be a copy of something that no longer existed.
- Former Spotify employee Jeremiah Lee published Failed #SquadGoals (2020) after joining post-growth: the famed squad model was only ever aspirational and never fully implemented, and he watched leadership incrementally transition to more traditional management structures as the company tripled to ~3,000 people in 18 months.
- The whitepaper was a snapshot of one company's practices at one moment, not a model. Other companies — Zalando, Typeform, BT among those who've spoken publicly — tried it and pulled back.
The four specific failure modes (this is the content, not the trivia):
- Matrix without decision rights. The Product Owner owned the "what," the Chapter Lead owned the "how." When those conflict — and they always do at the point where scope meets technical approach — the model specifies no arbiter. In practice this produced informal power struggles the model never addressed.
- Autonomy without alignment produced fragmentation. Squads chose their own frameworks, deployment practices, and monitoring. The result: an inconsistent codebase, high maintenance burden, and engineers unable to move between squads — which destroys the flexibility autonomy was supposed to buy.
- It assumed no dependencies, and dependencies exist. The model worked passably for feature development, where squads had clean boundaries, and badly for platform work, where a change affects everyone. Declaring teams autonomous does not make the dependency graph go away.
- It scaled coaching, not capability. Spotify didn't have enough agile coaches for every team, and many teams lacked the underlying practice knowledge to make autonomy productive. Autonomy handed to a team without the capability to use it is abandonment.
What to actually keep. The ideas are sound; the packaging was the problem. Strip the vocabulary and you get: cross-functional teams owning a durable mission, functional communities for craft development, and explicit tension management between alignment and autonomy. Those are Team Topologies' stream-aligned teams and communities of practice, with better-defined interaction modes and an explicit cognitive-load constraint (§29.3). Team Topologies is the more rigorous descendant, and preferring it is the defensible position.
How to answer when asked "what do you think of the Spotify model?" — three framings, pick by interviewer:
To a hiring manager (concise, positioned): "I'd be cautious. The model was a 2012 snapshot that Spotify itself never fully implemented and moved away from — Kniberg, who documented it, spent years telling people not to copy it. The underlying ideas are good: cross-functional teams with durable missions and functional communities for craft. I'd rather reach for Team Topologies, which gives you the same benefits with explicit interaction modes and a cognitive-load constraint that the squad model lacked."
To a director probing org-design judgment (analytical): "The interesting thing about the Spotify model isn't the structure, it's the failure mode — it's a case study in what happens when you grant autonomy without alignment mechanisms. Squads picked their own tooling, the codebase fragmented, and engineers couldn't move between teams, which was the exact flexibility the structure was meant to create. And the matrix never named a decision-maker when the PO's 'what' collided with the chapter lead's 'how.' So when I look at any org design, the two questions I ask are: where does a disagreement get resolved, and what's holding the technical choices coherent across teams."
To a peer engineer (candid, shorter): "It's a cargo cult. Spotify doesn't use it and arguably never did. The good parts survive in Team Topologies with better definitions."
Follow-up: "So how would you structure a 40-person org?" → Start from the value streams, not the org chart: what are the 4–6 durable slices of customer value? Make those stream-aligned teams of 6–9, each owning its slice end to end including on-call. Add a platform team only when the stream teams' cognitive load is demonstrably dominated by infrastructure — and treat it as a product with adoption metrics, not a mandate. Use enabling teams temporarily for capability gaps. Then apply the inverse Conway maneuver: if you want a modular architecture, you must have modular teams first, because the architecture will mirror the org whether you plan it or not.
Follow-up: "What if leadership has already mandated squads and tribes?" → Use the vocabulary, fix the mechanics. You don't win by fighting the nouns. Quietly install what the model omits: a named decision-maker per squad (DACI), a technical standards baseline that squads must meet (paved road, not golden cage), and an explicit dependency register with owners. The seasoned move is making a flawed structure work rather than demanding a re-org.
Further reading: Kniberg & Ivarsson, Scaling Agile @ Spotify (2012); Kniberg, No, I didn't invent the Spotify model (2015); Jeremiah Lee, Failed #SquadGoals (2020); Skelton & Pais, Team Topologies (2019).
52.2 The alternatives, and what each is actually optimizing
| Model | Core unit | Optimizes for | Fails when | Named practitioner |
|---|---|---|---|---|
| Two-pizza teams + single-threaded leader | Team small enough to feed with two pizzas; one leader whose only job is that initiative | Ownership clarity and speed; removing the "everyone's job is nobody's job" failure | Coordination costs across many small teams; duplicated effort | Amazon |
| Team Topologies | Stream-aligned / platform / enabling / complicated-subsystem, with three interaction modes | Bounded cognitive load and explicit inter-team contracts | Requires genuine platform investment to work as designed | Widely adopted post-2019 |
| Handbook-first, DRI-based | Documented ownership per decision; async by default | Remote scale, transparency, low meeting load | Requires real writing discipline; slow if writing is weak | GitLab |
| Context not control | Highly senior individuals given context, not process | Speed with exceptional people; minimal process overhead | Breaks with less-experienced staff; opaque to outsiders | Netflix |
| Shape Up | 6-week cycles, fixed time / variable scope, appetite instead of estimates | Avoiding estimation theater and endless backlogs | Needs real autonomy over scope; poor fit for committed roadmaps | 37signals/Basecamp |
| Feature crews | Temporary cross-functional team formed per feature, disbanded after | Focus on one outcome | Loses long-term ownership; on-call orphans | Microsoft (historically, DevDiv) |
The staff-level insight to state: none of these are interchangeable, because each is solving that company's specific bottleneck. Amazon's constraint was coordination overhead at scale — hence single-threaded ownership. GitLab's was being all-remote across every timezone — hence handbook-first. Netflix's was that they hire only very senior people — hence minimal process. Copying a structure without inheriting the constraint it solved is the single most common org-design mistake, and the Spotify model is its most famous instance.
The one universal: Conway's law. Your architecture will mirror your communication structure. If you need three teams to ship one feature, that's an architecture problem wearing an org costume, and vice versa.
52.3 Microservices, cloud native, and the org consequences
The claim interviewers are testing: do you understand that microservices is an organizational decision with technical consequences, not the reverse?
Definition, precisely. Microservices: independently deployable services, each owned by one team, communicating over a network, each with its own data store. The load-bearing words are independently deployable and its own data store — remove either and you have a distributed monolith, which has all the costs and none of the benefits.
Why it exists. Not for scale — a monolith scales horizontally fine. It exists to let many teams deploy independently without coordinating releases. That's the actual benefit. If you have three teams, you probably don't have that problem.
The organizational preconditions, which is the real answer:
- One service per team, not one team per many services. A team owning 15 microservices has a distributed monolith with extra YAML.
- Each team owns the service in production, including on-call. Without this, you've split the code and centralized the pain.
- A platform capable of supporting independent deploys: CI/CD per service, service discovery, observability, and a paved road. Without a platform team, microservices means every team reinvents deployment — a real, common, expensive failure.
- Team size ≥ 4 per service for a sustainable on-call rotation. This alone caps how many services a 40-person org can responsibly own.
The 2026 position to hold. The pendulum has swung. The defensible default is a modular monolith with enforced internal boundaries — module APIs, no cross-module database access, independently testable — extracting a service only when a specific force demands it: independent deploy cadence for separate teams, independent scaling of a genuinely different load profile, fault isolation for a critical path, polyglot requirements, or regulatory separation. Amazon's own Prime Video team published a widely-discussed case of consolidating a serverless microservice architecture back into a monolith for a 90% cost reduction; the lesson isn't "microservices bad," it's that the right granularity is workload-specific.
Cloud native, defined without buzzwords. Applications designed for elastic, unreliable, API-driven infrastructure: containerized, dynamically orchestrated, stateless where possible, configuration externalized, observable by default, and designed to survive individual instance loss. The CNCF definition adds the practices — declarative APIs, immutable infrastructure, service meshes. The 12-factor app is the concise checklist; know that it predates secret managers and treat its config guidance accordingly.
Follow-up: "When would you NOT go cloud native?" → Predictable steady-state load where reserved instances beat elasticity economically; workloads with hard hardware dependencies (GPU topology, specialized NICs, licensed appliances); regulated environments where the operational surface of Kubernetes is itself a compliance burden; and small teams for whom the operational complexity exceeds the benefit. "We run three services on ECS Fargate and don't need Kubernetes" is a mature answer, not a naive one.
53. Sizing, Estimation & Assigning Work
53.1 The techniques, mechanically
Planning Poker. Descended from Wideband Delphi; named by James Grenning (2002) and popularized by Mike Cohn.
Mechanics:
1. PM/DRI reads the item and answers clarifying questions
2. Everyone selects a card SIMULTANEOUSLY and reveals together
← the simultaneity is the entire point: it prevents anchoring
on the loudest or most senior voice
3. High and low estimators explain their reasoning
← this is where the VALUE is. The number is a byproduct;
the discovered disagreement is the product
4. Re-estimate. Converge or timebox to two rounds and take the higher.
Scale: modified Fibonacci — 1, 2, 3, 5, 8, 13, 20, 40, 100, ?, ∞
Why non-linear: precision is impossible at scale, so the scale
forces coarseness. You cannot argue 7 vs 8.
8+ means "break it down." 20+ means "we don't understand it yet;
spike it."
When it earns its time: new teams, unfamiliar domains, high-uncertainty work, and any item where you suspect the team has different mental models. When it's waste: a mature team on familiar work, where the meeting costs more than the information produced.
T-shirt sizing (XS/S/M/L/XL). Coarse, fast, for epics and roadmap-level items where numbers imply false precision. Its real virtue: nobody tries to sum t-shirts into a commitment.
Affinity estimation. For estimating a large backlog fast (50–200 items in an hour):
1. Print/card every item. Place one reference item on a wall.
2. SILENT sorting: everyone places items left (smaller) to right
(larger) relative to what's already there. No talking.
3. Talking phase: anyone may move an item once and must say why.
4. Draw size boundaries across the resulting spectrum, assign values.
The silent phase is what makes it fast and anchor-resistant. Best tool for an initial backlog or a re-baseline.
GitLab's issue weights — a real, publicly documented alternative worth citing. GitLab doesn't use story points; it uses weights on issues, and the process is explicit: engineering managers assign an engineer to break the work down and apply weights, in collaboration with the DRI, before the milestone starts. Weighing at the front of the build track is what lets product managers make prioritization tradeoffs and lets the team confirm they've scoped the right amount for a milestone. The scheduling decision and the estimation decision are deliberately coupled.
Shape Up's appetite — the inversion worth knowing. Instead of "how long will this take," ask "how much time is this worth?" Set a fixed appetite (2 weeks or 6 weeks), then shape the solution to fit. Scope is the variable, time is fixed. This eliminates estimation entirely, and it works only if the team has genuine authority to cut scope — which is why it fits product companies with autonomous teams and fits poorly where scope is contractually committed.
Forecasting from throughput (the modern alternative). Don't estimate; measure.
Take the last 8-12 weeks of completed items. Two numbers:
- THROUGHPUT: items completed per week
- CYCLE TIME distribution: percentiles, not the mean
Forecast by Monte Carlo: sample historical throughput 10,000 times
to simulate finishing N remaining items.
Output: "85% confident we finish 30 items in 7 weeks or less."
Why this beats story points: it uses observed reality rather than
predicted effort, it produces a probability rather than a date, and
it cannot be gamed by inflating estimates.
Precondition: items must be roughly similar in size — which you get
free from the "break down anything over a week" rule.
The #NoEstimates argument, stated fairly: if you slice work to a consistent small size, counting items forecasts as well as estimating them, and you save all the estimation effort. The counter: many organizations genuinely need a date for external commitments, and "we'll tell you when it's done" isn't an answer to a customer contract. The synthesis, and the position I'd take: estimate coarsely for prioritization, forecast from throughput for dates.
53.2 The Goodhart problem — the seasoned critique you must be able to deliver
Story points fail predictably, and knowing the failure mechanism separates a lead from someone repeating a certification:
- Points get converted to hours by management. The moment someone computes "our velocity is 40 points = 40 points of capacity," points have become time with extra steps, and their whole purpose (decoupling estimate from duration) is dead.
- Velocity becomes a target. Goodhart's law: when a measure becomes a target, it ceases to be a good measure. Teams inflate estimates, and velocity rises with zero change in delivered value. The number goes up and nothing improves — this is the tell.
- Cross-team comparison. Points are calibrated per team and meaningless across teams. Comparing them is the most common misuse and the fastest way to destroy their honesty.
- They measure effort, not value. A 13-point item that nobody uses is worse than a 1-point item that fixes a top complaint.
The line worth having ready: "I use estimates to have the conversation, not to have the number. If our estimation practice isn't surfacing disagreements about scope and approach, it's ceremony and I'd stop doing it."
53.3 Breaking down an epic — a full worked example
This is the skill interviewers probe with "how would you break this down," and most candidates answer at a level of generality that proves nothing. Do it concretely.
The epic: "Add saved searches to the platform."
Step 1 — Slice vertically, never horizontally.
WRONG (horizontal — layer by layer):
□ Database schema for saved searches
□ Backend API for saved searches
□ Frontend UI for saved searches
□ Tests
Why it's wrong: nothing is shippable until all four are done. No
feedback, no partial value, and the integration risk lands at the end
when there's no time left.
RIGHT (vertical — thin slices through every layer):
□ 1. User can save the current search with a name and see it in a list
(schema + endpoint + minimal UI, no editing, no sharing) [MVC]
□ 2. User can run a saved search from the list
□ 3. User can rename and delete a saved search
□ 4. Saved searches sync across devices
□ 5. User can share a saved search with their team
□ 6. Email alert when a saved search has new results
Each slice is independently shippable and independently valuable.
Step 2 — Apply INVEST to each slice. Independent, Negotiable, Valuable, Estimable, Small, Testable. Slice 1 passes. Slice 6 is not Small (email infrastructure, scheduling, unsubscribe handling, digest logic) and gets broken further.
Step 3 — Find the MVC. GitLab's framing — the Minimal Valuable Change — is the useful discipline: what is the smallest change that still delivers value? Slice 1 is it. GitLab's own guidance is that engineers should push back toward smaller scope at any point in the lifecycle when they can see an opportunity to cut, because engineers understand what edge cases cost. That's the sentence to internalize: cutting scope is an engineering responsibility, not just a product one.
Step 4 — Identify risk and sequence for learning. Which slice teaches us the most about whether this is right? Ship slice 1 to 5% of users and instrument it. If nobody saves a search, slices 2–6 shouldn't be built.
Step 5 — Now estimate. Weight or point the slices. Anything over a week goes back to step 1.
Step 6 — Assign deliberately (§53.4).
Follow-up: "The PM insists all six slices ship together as one release." → Separate deploy from release (§30.4). Build and merge the slices incrementally behind a feature flag, release them together if the product needs a single launch moment. You get incremental integration and incremental review; they get the launch. Nobody has to lose.
53.4 Assigning work — the part nobody teaches
Assignment is your highest-leverage recurring decision and most leads make it by availability. The four inputs:
- Growth. Does this stretch someone toward their next level? Target ~70% familiar / 30% new. More than that and you're setting up failure; less and you're wasting a development opportunity.
- Risk. Critical-path work with a hard date goes to someone who has done something similar. You do not learn on the migration that must land in three weeks.
- Bus factor. If one person has done all the payments work for two years, the next payments task goes to someone else with them pairing. Deliberate knowledge distribution is a lead responsibility that never appears in a sprint plan.
- Glue work distribution. The coordination, documentation, onboarding, and incident follow-up that makes teams function accretes on whoever is most conscientious — and it's non-promotable. Tanya Reilly's framing. See it, name it, rotate it, and value it in calibration. Volunteering this concept unprompted is one of the strongest people-leadership signals available.
The mechanism: maintain a skill matrix — each engineer × each competency (domain areas, systems design, code quality, communication, ownership, mentoring), scored 1–4. Review it quarterly. Assign against the gaps, not the calendar.
Search Ingest Infra SysDesign Mentoring On-call
Alex (Sr) 4 2 3 3 2 4
Priya (Mid) 2 4 2 2 1 3
Sam (Jr) 1 2 1 1 1 2
Jo (Staff) 3 3 4 4 3 4
Reads immediately:
• Search is a bus-factor-1 risk on Alex → pair Priya on the next
search item even though Alex is faster
• Sam needs a scoped ownership opportunity, not more tasks
• Jo should be mentoring, not writing the ingest code they'd write
fastest — that's the classic staff-engineer misallocation
• Nobody is growing toward Alex's on-call depth except Jo
Follow-up, asked several ways — "How do you decide who works on what?"
The concise version (screen): "Deliberately, against a skill matrix — not by who's free. Every assignment is either a growth investment or a risk decision, and I try to know which one I'm making."
The version showing depth (hiring manager): "Four inputs: growth — is this 70/30 familiar-to-new for them; risk — does the deadline allow learning; bus factor — am I concentrating knowledge further; and glue-work fairness — the coordination and onboarding work that's invisible and non-promotable tends to land on the same conscientious person unless I rotate it. I keep a skill matrix per person per competency so those decisions are visible rather than vibes, and I review it quarterly."
The version with a story (director): "I had a team where one engineer owned all of the search relevance work for two years. He was the fastest at it every single time, so every search ticket went to him — which was locally optimal and a bus-factor-one disaster. When he took a month of parental leave we couldn't ship a relevance change. After that I started pairing someone on every search item even when it was slower, and I made the skill matrix explicit so I couldn't kid myself. The lesson I took is that assignment by who's fastest is a decision to concentrate risk, and it compounds silently."
54. Jira & Azure DevOps — Making the Tool Tell the Truth
The framing that earns respect: the tool is not the process. A tracker's only jobs are (1) making work visible, (2) making flow measurable, and (3) preserving decisions. Everything else teams do in Jira is usually ceremony that costs more than it returns. Lead with that and you sound like someone who has cleaned up a Jira instance rather than someone who has filled one.
54.1 Hierarchy and hygiene (both tools)
Jira Azure DevOps
───────────────────── ──────────────────────────
Initiative (Premium) Epic
Epic Feature
Story / Bug / Task User Story / Bug
Sub-task Task
Rules that keep it honest:
• An EPIC is an outcome, not a bucket. "Saved searches" is an epic;
"Backend work" is a bucket and it will never close.
• A STORY is a vertical slice with user-visible value, ≤ 1 week.
• A TASK is an implementation step, only when the story genuinely
needs decomposition. Most don't — sub-tasks are where teams
manufacture busywork.
• A BUG carries reproduction steps, expected vs actual, and severity.
If it doesn't, it's a rumor, not a bug.
• If an item has been open 90 days, it's not backlog, it's a decision
you haven't made. Close it or schedule it.
Definition of Ready (before pulling into a sprint): acceptance criteria written; dependencies identified; sized; no known blockers; the team can explain it. Definition of Done (before closing): merged; tests written and passing; deployed to production or behind a flag; observability in place; docs updated; PM has seen it. Put DoD in the board configuration — Azure DevOps supports it per-column on the Kanban board, and Jira teams should put it in the column description. An unwritten DoD means every engineer applies a different one.
54.2 Configuration practices that actually matter
Jira:
- Resist workflow customization. The most common Jira disease is a 14-state workflow with mandatory transition screens. Every state must answer "who acts differently because of this state?" If nobody does, delete it. Five to seven states is plenty: Backlog → Ready → In Progress → In Review → Done (+ Blocked as a flag, not a status).
- Components vs labels: components are curated and ownable (they can auto-assign); labels are free-text and rot within a quarter. Use components for anything you'll report on.
- WIP limits on board columns. The single highest-value board setting and the one almost nobody enables. It makes the queue visible and forces finishing over starting.
- Automation rules worth having: transition to In Progress when a branch is created; transition to In Review when a PR opens; comment on the issue when the PR merges; flag any issue with no update in 5 days; auto-assign by component.
- The three reports that matter: Control Chart (cycle time distribution over time — this is your forecasting input), Cumulative Flow Diagram (where work is piling up — a widening band is a bottleneck), and the Sprint Burndown only if you're doing sprints and only as a within-sprint signal, never as a performance metric.
- Don't use Jira as a document store. Decisions belong in a design doc or ADR with a link from the issue. Jira comments are unsearchable archaeology within six months.
Azure DevOps:
- Area Paths = ownership; Iteration Paths = time. Getting this wrong is the classic ADO mistake. Area paths map to teams and components and should mirror your org; iteration paths are sprints and are shared. A team's board is defined by its area path.
- Link commits and PRs to work items with
AB#123in the commit message or PR description. This gives you automatic traceability and is what makes ADO's release-notes generation and audit story work. Enforce it with a branch policy requiring linked work items. - Branch policies are ADO's strongest feature: required reviewers by path (so the security team must approve
/auth/**), minimum approver count, build validation, comment resolution required, and merge strategy enforcement. Configure these once and code review discipline becomes structural rather than cultural. - Queries (WIQL) and Delivery Plans: saved queries for "my team's items with no activity in 5 days" and "bugs with severity 1 unassigned" are worth more than any dashboard. Delivery Plans give cross-team timeline visibility, which is the one thing ADO does better than Jira out of the box.
- Dashboards: cycle time widget, lead time widget, burndown, and a query tile for stale items. Four tiles. Not twenty.
54.3 The metrics to read, and the ones to refuse
| Read this | Because | Never use this as a target |
|---|---|---|
| Cycle time distribution (85th percentile) | It's your forecasting input and it reflects reality | Average cycle time — hides the tail that actually hurts |
| Cumulative flow diagram | Shows exactly where work queues | — |
| Work item age (in-progress items) | Catches stuck work while it's stuck, not after | — |
| Throughput (items/week) | Forecasting input, un-gameable | Velocity as a target — Goodhart guarantees inflation |
| Blocked time | The lead's actual job (§23.1) | — |
| Review queue depth / time-to-first-review | Usually the largest hidden cost in cycle time | — |
| DORA four keys | Delivery system health | Any of these compared across teams — DORA's own guidance warns against it |
| — | — | Individual commit counts, lines of code, points per person — actively destructive |
The sentence that demonstrates seasoning: "I read the cumulative flow diagram before the burndown, because the burndown tells me whether we'll hit the sprint and the CFD tells me why."
Follow-up asked three ways — "How do you know your team is productive?"
Concise: "I look at flow, not output. Cycle time at the 85th percentile, throughput, and where work is queueing. Output metrics like velocity or commits tell you how busy people are, which isn't the question."
With the tension named: "Three layers. Delivery health from DORA — deployment frequency, lead time, change failure rate, recovery time. Flow health from cycle time and CFD. And human health from DevEx signals — interruptions, cognitive load, whether people feel they can do good work. I need all three, because you can hit great DORA numbers by burning a team out, and that shows up two quarters later as attrition. I'd also flag that DORA alone has become less reliable in AI-heavy teams — throughput rises while stability degrades, so I pair it with a quality guardrail."
With a story: "At one point our velocity was up 30% quarter over quarter and I was pleased with myself until I looked at cycle time, which hadn't moved. What had actually happened was estimate inflation — we'd started sizing everything one Fibonacci step higher after a rough quarter. The number went up and nothing improved. That's when I stopped reporting velocity upward entirely and switched to throughput and cycle time percentiles, which can't be gamed by estimating differently."
55. The GitLab Workshop — Learning from a Company That Published Everything
GitLab's handbook is the largest public corpus on how to actually run an engineering organization — over 2,000 pages, open to the world, and changed by merge request like code. For a team lead interview, it's the highest-density study material available, because you can read the actual policies rather than a conference talk's summary.
55.1 Handbook-first, and why it's a management technique
The practice: if it isn't in the handbook, it isn't policy. Changes go through a merge request — proposed, reviewed, merged — so process changes have an author, a reviewer, a diff, and a history.
Why this works, mechanically:
- It removes the meeting. A question answered in the handbook is answered once, for everyone, forever. A question answered in a meeting is answered for eight people and lost.
- It makes process changes reviewable. You can disagree with a diff. You cannot disagree with a hallway convention.
- It scales across timezones. In an all-remote company, synchronous is the expensive resource. Written is the default.
- It exposes inconsistency. Two contradictory policies are visible when both are written.
How to use this in an interview — this is the transferable version, and it works at any company:
"I run a written-first team. Decisions land in an ADR or a design doc, process lives in the team's README, and changes to either go through review the same way code does. The test I use is: if someone joins next month, can they find out how we work without asking anyone? GitLab's handbook is the extreme version of this and it's worth reading — over two thousand pages, public, and changed by merge request. Most teams don't need that scale, but the principle scales down well."
55.2 The DRI — Directly Responsible Individual
Definition: one named person accountable for a decision or initiative. Not a committee, not a role, a person.
Why it exists: the failure mode it prevents is the decision that everyone discusses and nobody makes. GitLab applies DRIs at every level — a DRI for an epic, a DRI for a handbook page, a DRI for a large-scale initiative — and their product development flow explicitly describes the DRI collaborating with engineering managers and collaborators to break down and weight work.
The nuance that shows understanding: a DRI is not a dictator. They gather input, and they're accountable for making the call and documenting it. This is the same structure as DACI's single Approver (§29.4). The value isn't authority, it's the elimination of ambiguity about who decides.
Transferable practice: on any project, name the DRI in writing at kickoff. When someone asks "who's deciding this?", the answer should already exist.
55.3 Iteration and the MVC
GitLab treats iteration as a company value, not a process, and the operational expression is the Minimal Valuable Change — the smallest change that still delivers value to a user.
The specific mechanics worth stealing:
- Smaller merge requests, always. GitLab's handbook is explicit that to deliver iteratively you must create smaller MRs.
- Engineers are expected to push scope down. The handbook states that engineers are a vital part of the feedback loop with product, because engineers understand what additional behaviors and edge cases cost — and should give that feedback as early as possible, at any point in the lifecycle, when they see an opportunity to cut scope and ship something smaller.
- Weighting happens up front in the build track, with the EM assigning an engineer to break down and weight the work in collaboration with the DRI, so that PMs can make prioritization tradeoffs with real information and the team can confirm the milestone is correctly scoped.
- GitLab runs iteration training for new team members and explicitly acknowledges that iteration doesn't come naturally to everyone and varies across cultures — which is a notably mature piece of organizational self-awareness.
The interview-usable version:
"The practice I'd bring is treating scope-cutting as an engineering responsibility, not just a product one. Engineers see what an edge case costs before the PM does, and the cheapest moment to cut scope is the moment you notice. GitLab formalizes this — they call the unit a Minimal Valuable Change and they expect engineers to push back toward smaller scope at any point in the lifecycle. On my team that looks like a standing question in refinement: what's the smallest version of this that a user would still thank us for?"
55.4 The rest of the transferable set
- Weights over story points. Sizing tied directly to the scheduling decision, applied by the engineer who'll do the work, in collaboration with the person accountable for the outcome.
- Milestones over sprints. Time-boxed release cadence rather than a ceremony-heavy sprint ritual.
- Everything is an issue or an MR. Discussion attaches to an artifact, so context is recoverable a year later.
- Async by default, meetings with agendas and documents. A meeting without a linked agenda document doesn't happen.
- Transparency as a default, with a narrow internal exception. Most companies invert this — internal by default, public by exception — and the inversion is what makes GitLab's handbook possible.
The honest critique to have ready, because an interviewer will probe: handbook-first requires strong writing across the whole organization, and it's slow when writing is weak. It can also produce documentation sprawl where the handbook contradicts itself at scale. And "if it's not in the handbook it's not policy" can become a weapon in disagreements. The transferable version is written-first for decisions and process, not necessarily 2,000 pages.
56. Google, Microsoft, Amazon & Netflix — What Each Proved
56.1 Google — Project Oxygen and Project Aristotle
Project Oxygen (2008 onward) asked what makes a great manager, and it began by testing the hypothesis that managers don't matter much. The data said they do, and the research distilled a set of behaviors into a Manager Feedback Survey where every item begins with "My manager…" — an upward-feedback instrument, rated by the team.
The behaviors (the list has been refined over time; the durable core):
- Is a good coach
- Empowers the team and does not micromanage
- Creates an inclusive team environment, showing concern for success and wellbeing
- Is productive and results-oriented
- Is a good communicator — listens and shares information
- Supports career development and discusses performance
- Has a clear vision and strategy for the team
- Has key technical skills to advise the team
- Collaborates across the company
- Is a strong decision-maker
The two findings that matter for an interview: technical skill ranks eighth, not first — it's necessary to advise credibly but it is not what distinguishes great managers. And coaching ranks first. If you're moving from senior IC to lead, the single most useful reframing available is that your technical depth is table stakes, and the differentiating skill is developing other people.
Project Aristotle (from 2012, led by Julia Rozovsky in People Analytics) asked what makes a team effective. The hypothesis was that the best teams are the best combination of people. The data said otherwise: who is on the team mattered less than how the team interacts. Five dynamics emerged, with psychological safety — the shared belief that you can take interpersonal risks, admit mistakes, ask questions, and disagree without humiliation — as the foundation the other four sit on:
- Psychological safety (foundational)
- Dependability — members reliably deliver
- Structure and clarity — clear roles, plans, goals
- Meaning — the work matters personally
- Impact — the work matters beyond the team
The mechanism a lead can act on, because "build psychological safety" is otherwise unactionable:
- Model fallibility first. Say what you got wrong, publicly, before asking anyone else to. The most-cited Aristotle anecdote is a Google manager who found his team disengaged, called an offsite, and disclosed his stage-4 cancer diagnosis — after which team members began sharing too, and the team's dynamics changed. The general principle isn't disclosure of that magnitude; it's that safety propagates downward from whoever has the most to lose by being vulnerable.
- Make speaking up structurally easy: rotate meeting facilitation, ask the most junior person first, use round-robins rather than open floors, run blameless postmortems and mean it.
- Respond well to bad news, every single time. One punished escalation teaches the whole team more than a year of stated values.
How to deploy this in an interview:
Direct: "Google's Project Aristotle found psychological safety was the strongest predictor of team effectiveness — stronger than who's on the team. And Project Oxygen found technical skill was eighth on the list of what makes a great manager. Both findings pointed me the same direction when I moved into leading: my technical depth gets me credibility, but it isn't the job."
Applied, when asked how you'd fix a struggling team: "I'd look at the Aristotle dynamics in order, because they build on each other. Is it safe to disagree here — do people raise problems, or do problems surface only after they've become incidents? Then dependability, then structure and clarity, which is usually where the tractable problems are: unclear ownership, no definition of done, ambiguous priorities. Meaning and impact are real but they're rarely the binding constraint. And I'd start by modeling it — the fastest way to make it safe to be wrong is to be visibly wrong myself first."
56.2 Amazon — the mechanisms
- Two-pizza teams: small enough to be fed by two pizzas. The point isn't the headcount, it's that communication overhead grows quadratically and small teams don't need coordination mechanisms.
- Single-threaded leader (STL) / single-threaded owner: one leader whose only responsibility is that initiative. Amazon's own diagnosis was that the biggest predictor of an initiative's success was whether someone owned it full-time. This is the most transferable Amazon mechanism — "who is single-threaded on this?" is a question worth asking about any struggling project.
- Working backwards / PR-FAQ: write the press release and FAQ before building. Forces clarity on the customer benefit before a line of code.
- Six-page narratives, no slides. Meetings start with 20 minutes of silent reading. Slides let a speaker hide fuzzy thinking behind bullets; prose does not. Whether or not you adopt it, having a position on written-narrative culture is a strong signal.
- Bar raiser: an interviewer from outside the hiring team with veto power, whose incentive is the long-term bar rather than filling this req.
56.3 Microsoft — engineering system and culture reset
- Abandoning stack ranking (2013) is the more instructive story than anything they added. Forced distribution made teammates competitors, and killing it was the precondition for the collaboration Nadella's growth-mindset culture required. If asked about performance management, "I don't believe in forced distribution, and Microsoft's reversal is the well-documented case" is a defensible, evidenced position.
- One Engineering System (1ES): the drive to a single shared toolchain across a company that had dozens. The lesson is platform-as-product: consolidation succeeds when the shared system is genuinely better, not when it's mandated.
- Feature crews and short sprint cadence in the developer division — cross-functional teams formed around a deliverable, shipping on a fixed rhythm.
- Growth mindset as an operating concept, not a poster: "learn-it-all rather than know-it-all." The interview-usable version is how you treat being wrong in design reviews.
56.4 Netflix — context, not control
- Context not control: give people the information and the strategy, not the process. Requires unusually senior staff, and Netflix is explicit that it's a consequence of their talent density, not a universal recipe.
- Informed captain: a named decision-maker per decision, expected to gather dissent and then decide. Same pattern as DRI and DACI's Approver — three companies independently converging on "name the decider" is itself the lesson.
- The keeper test: would you fight to keep this person? Honest, and genuinely harsh; know it, and have a view. Mine: the test is a useful private prompt for a manager and a corrosive thing to say out loud to a team, because it converts every ambiguity into a threat.
The synthesis worth stating when asked which model you'd adopt: "Every one of these solved the specific company's binding constraint. Amazon's was coordination overhead, so they optimized ownership. GitLab's was all-remote across every timezone, so they optimized written asynchrony. Netflix's was that they only hire very senior people, so they removed process. The mistake is copying the artifact without inheriting the constraint. What I'd actually take is the pattern all four share: name one accountable person, write decisions down, and keep teams small enough that they don't need a coordination layer."
57. Management Workshop — Interview Q&A, Multiple Framings
Each question with three answers: screen (concise, 30–45 seconds), depth (hiring manager, 2 minutes, shows mechanism), and story (director, shows scar tissue). Match the framing to the room.
"How do you structure a team?"
Screen: "From the value streams, not the org chart. Four to six durable slices of customer value, one team of six to nine on each, owning it end to end including on-call. Platform teams only when stream teams' cognitive load is genuinely dominated by infrastructure."
Depth: "I start with Team Topologies' framing: stream-aligned teams as the default, bounded by cognitive load rather than headcount. The constraint that actually matters is how many domains a team can hold in its collective head — past that you get slow onboarding, more defects, and burnout. Then interaction modes get explicit: which relationships are collaboration, which are X-as-a-service. And I'd use the inverse Conway maneuver deliberately — if I want a modular architecture, I need modular teams first, because the architecture will mirror the org whether or not I plan it. What I'd avoid is the Spotify model. It was a 2012 snapshot Spotify never fully implemented and moved away from, and its failure mode is instructive: autonomy without alignment, and a matrix with no named decision-maker."
Story: "I inherited a group split by layer — a frontend team, a backend team, and a data team. Every feature needed all three backlogs to align, so lead time was dominated by waiting, not working. We reorganized into two vertical teams each owning a product area top to bottom. Cycle time roughly halved and, more importantly, the arguments changed from 'whose priority is this' to 'what should we build.' The thing I got wrong: I underestimated how much the specialists would grieve losing their craft community, and I should have set up chapters or guilds from day one rather than bolting them on after people complained."
"How do you estimate work?"
Screen: "Coarsely for prioritization, and I forecast dates from throughput rather than from estimates. Estimation is for having the conversation; the historical cycle time is what I'd actually bet a date on."
Depth: "Three levels. Roadmap items get t-shirt sizes — numbers there imply a precision that doesn't exist. Sprint-level work gets planning poker or weights, and the value there is the disagreement it surfaces, not the number: when two engineers say 2 and 13, we've found a scope misunderstanding and that's worth the meeting. For dates, I use throughput and cycle time percentiles with a Monte Carlo forecast, so I can say '85% confident by November 5th' rather than a single date that's wrong by construction. And I break anything over a week down further, which conveniently makes the items uniform enough that counting works as well as estimating."
Story: "I ran a team where velocity climbed 30% over two quarters and I was quietly pleased until I checked cycle time, which was flat. We'd inflated our estimates after a bad quarter — the number went up and nothing had improved. Goodhart's law, exactly on schedule. I stopped reporting velocity upward and moved to throughput and 85th-percentile cycle time, which you can't game by estimating differently. It also changed the conversation with my director from 'are we going faster' to 'where is work actually waiting,' which turned out to be code review, not development."
"How do you break down a large epic?"
Screen: "Vertical slices, never horizontal layers. Each slice ships independently and delivers something a user would notice. If a slice is over a week, it isn't a slice yet."
Depth: (walk the saved-search example from §53.3 out loud — the six slices, the INVEST check, the MVC, sequencing for learning.) "And the sequencing matters as much as the slicing: I order by what teaches us the most, not by what's easiest. Ship slice one to five percent of users and instrument it — if nobody saves a search, slices two through six shouldn't exist."
Story: "We had a six-month replatforming epic broken down by layer — schema, then services, then UI. Four months in we had nothing shippable and no idea whether the design worked, because the integration risk was all parked at the end. We stopped, re-sliced vertically, and shipped a working thin path in three weeks. It was uglier than the plan and it told us two of our assumptions were wrong, which we'd have discovered in month six otherwise. Since then my rule is that if the plan has no shippable increment in the first three weeks, the plan is wrong."
"How do you make sure work gets done as expected?"
Screen: "Definition of Ready and Definition of Done written down, WIP limits on the board, and a daily scan for anything blocked or aging. Most missed expectations are blocked work nobody escalated, not slow work."
Depth: "Three mechanisms. First, clarity before start — acceptance criteria and a written DoD, because unwritten DoD means every engineer applies a different one. Second, flow visibility — WIP limits and work-item age on the board, so I catch stuck work while it's stuck rather than in retro. Third, blocker triage classified by type: blocked on information, on a decision, on a dependency, or on skill. Each has a different fix and treating them the same is why teams stay stuck. I also track decision latency on myself — if a decision has been sitting with me for two days, that's my failure, not theirs."
Story: "I used to think our delivery problem was estimation. Then I put work-item age on the board and found the median in-progress item sat untouched for four days waiting on review, on a two-day development task. The bottleneck wasn't building, it was queueing — and no amount of better estimating would have found it. We set a four-hour first-review SLA and rotated reviewers, and cycle time dropped by a third without anyone working differently."
"How do you grow the people on your team?"
Screen: "Deliberate assignment against a skill matrix, stretch work at roughly 70% familiar and 30% new, and growth plans mapped to the next level's rubric with named artifacts rather than adjectives."
Depth: "I keep a matrix of each person against each competency, and I assign work to close gaps rather than by who's free. I use a delegation ladder explicitly — do it, do it and tell me, propose then do, decide and inform, own it — and I tell people which rung they're on, because the ambiguity is what makes delegation feel like either abandonment or micromanagement. Feedback is SBI and timely. And for promotion I start two quarters early by assigning work that generates evidence at the target level, because waiting for packet season is how good engineers get stuck. Google's Project Oxygen found coaching was the top behavior of effective managers and technical skill was eighth — that reordering matched my experience moving into leadership."
Story: "I had a strong senior who wanted staff and was one level of scope short — excellent execution, no cross-team influence. My first instinct was to tell him to 'be more strategic,' which is useless advice. Instead I gave him ownership of a migration that touched three teams and deliberately stayed out of the coordination. He hated the first month. Six months later he'd run the design review, negotiated the sequencing with two other leads, and had the artifacts the packet needed. What I learned is that the gap is usually my assignment failure, not their capability failure — they'd never been given work that could generate the evidence."
"How do you handle a team that's resistant to a process change?"
Screen: "Find out why. Resistance is usually accurate information about a cost I haven't accounted for. I'd rather run a bounded experiment with a review date than mandate."
Depth: "I treat it as a design problem, not a compliance problem. First, is the change solving a problem the team actually has? If they don't feel the pain, the change is solving my problem and I should say so honestly rather than pretending. Second, I'd propose it as a time-boxed trial — two sprints, with an explicit success measure and a genuine agreement to revert. That converts a debate about opinions into an experiment with data. Third, I'd give the loudest skeptic a role in designing it, because opposition converted to ownership is worth more than consensus. What I wouldn't do is mandate and hope, because a process people follow reluctantly degrades into ceremony within a quarter."
Story: "I tried to introduce a PR-size limit and got real pushback — people said it would slow them down and add overhead. Rather than argue I asked them to help me measure it: we tracked review latency against PR size for a month. The data was stark — anything over about 400 lines sat in review three times longer and got a fraction of the comments. The team adopted the limit themselves after seeing it, and one of the original skeptics wrote the tooling to warn on oversized PRs. The lesson I took is that I'd been trying to win an argument when I should have been designing a measurement."
"What's the hardest part of being a team lead?"
Screen: "Letting work be done worse than I'd do it, because that's how anyone else gets better. The instinct to take the keyboard is the thing to unlearn."
Depth: "Two things. The first is that my output became invisible — I stopped shipping and started being measured by what other people ship, and the feedback loop went from hours to months. That's genuinely disorienting and it's where new leads either learn to trust process signals or start micromanaging to feel productive. The second is that the hard calls are almost never technical. Telling someone they're not ready for promotion, or that their behavior is costing the team, is harder than any system design, and doing it late is worse than doing it badly."
Story: "The hardest thing I've done was tell a friend on my team that his performance had slipped, in the same conversation where I had to say the word 'formal.' I'd delayed it about two months telling myself I was being kind, and in that time he'd had no idea anything was wrong. When I finally said it, his first reaction wasn't anger, it was 'why didn't you tell me earlier?' — which was completely fair and is the thing I still think about. Now my rule is that if I'm rehearsing a conversation in my head for more than a day, that's the signal to have it, and no one on my team should ever be surprised at review time."
58. Workshop Drills (67–86)
- Structure a 40-person org from scratch. Walk your reasoning and name the model you're not using.
- Your leadership mandated squads and tribes. What do you fix quietly?
- Argue for and against microservices for a 25-person org shipping one product.
- When is a modular monolith the right answer? What force triggers extraction?
- Define cloud native without buzzwords. When would you not go cloud native?
- Run planning poker on an item where two engineers say 2 and 13. What do you do next?
- Estimate a 200-item backlog in an hour. Method and mechanics.
- Forecast a date for 30 remaining items. Show your method and phrase the output.
- Argue against story points to a team that loves them.
- Break down "add saved searches" into vertical slices. Name the MVC.
- Your PM wants all six slices released together. Reconcile it.
- Who gets the critical-path migration and why? Who gets the greenfield service?
- What is glue work, why does it matter, and how do you distribute it?
- Design a Jira workflow with the fewest states that still tells the truth.
- Area Paths vs Iteration Paths in ADO — what breaks if you conflate them?
- Which three tracker reports do you read weekly, and what does each tell you?
- What did Project Aristotle find, and what would you do differently on Monday because of it?
- Technical skill ranked eighth in Project Oxygen. What do you make of that?
- What's transferable from GitLab's handbook to a company that isn't remote?
- Amazon's single-threaded leader, GitLab's DRI, Netflix's informed captain — what's the common finding, and what does it tell you?
PART VII — ANSWERS TO EVERYTHING LEFT OPEN
Parts I–VI named things. This part answers them. Every question posed to an interviewer gets what you're listening for; every requirement stated as a bullet gets the mechanism, the reasoning, and the words to say.
59. Questions to Ask — What You're Listening For
A question is only half the tool. The other half is knowing what a good answer sounds like, what a concerning answer reveals, and what you do next. Asking a sharp question and then nodding at a vague answer wastes the question. For each below: why it works, the green flag, the red flag, your follow-up, and what you do with the information.
59.1 To the hiring manager
"What's the problem in your org that made you open this req — what breaks or stays broken if it goes unfilled for six months?"
Why: The job description is marketing. This asks for the pain, and the pain is the actual job.
Green flag: A specific, bounded answer with consequences. "Our search relevance work has been owned by one staff engineer who's moving to another team. If we don't fill this, three quarters of roadmap work stalls and we lose the institutional knowledge." They've thought about it, they can name the gap, and the scope is real.
Red flag: "We're just growing and always need good people." Either they haven't thought about it, or the role has no defined mandate — which means you'll spend your first six months inventing one while being measured against an expectation nobody wrote down. Also concerning: an answer that describes a person rather than a problem ("we need someone senior") — that's a headcount req, not a role.
Your follow-up: "Who's holding that today, and what happens to them when I arrive?" You're checking whether you're being hired into a vacuum or on top of someone who wanted the job.
What you do with it: This answer is the raw material for your 30/60/90 plan (§11) and for the "what does success look like" alignment in your first week. Write it down verbatim.
"Walk me through the team: tenure, levels, who's strongest at what, and where the gaps are."
Why: Tests whether they know their people, and gives you the real inheritance.
Green flag: They can do it without notes, name specific strengths, and are candid about a gap. "Priya's my strongest systems person, been here three years; Sam joined six months ago and is still ramping; we have nobody strong on the data pipeline side and that's my biggest risk." A manager who knows their team this well is a manager you'll learn from.
Red flag: Vagueness, or only positives. "They're all great" means either they don't know their team or they won't tell you the truth — and both predict a rough first quarter. A second red flag: heavy tenure skew, either everyone under a year (churn) or everyone over five (stagnation and possible resistance to a new lead).
Your follow-up: "Who on the team wanted this role?" Ask it directly. If someone did, you need to know before day one, and how the manager handles the question tells you a lot.
What you do with it: Build a mental skill matrix before you start (§53.4). Also identifies who you should ask to speak with during the loop.
"What did the last person in this seat struggle with?"
Why: The failure mode is usually structural and will be waiting for you too.
Green flag: Specific and self-implicating. "He was very strong technically but never built a relationship with product, so he found out about roadmap changes late and burned out fighting them. I should have coached that earlier." A manager who names their own contribution is one who'll tell you the truth later.
Red flag: Blame with no reflection ("he just wasn't a fit"), or "there was no last person, this is new." The second isn't disqualifying but it means no path has been walked — expect to define the role yourself.
Your follow-up: "What would you do differently in supporting the next person?" This is the question that gets the useful answer.
What you do with it: This is your top risk. Whatever they name, plan explicitly for it in your first 90 days.
"How much of my time do you expect in code, in design, and in people work — at month three and at month twelve?"
Why: This is where TL-vs-EM ambiguity kills careers. A role sold as "technical leadership" that's actually 80% people management, or vice versa, is the single most common mis-hire at this level.
Green flag: Different numbers for the two timeframes, with reasoning. "Month three I'd want you 50% hands-on because you need credibility and context. By month twelve, closer to 20% code, 40% design and technical direction, 40% people — and if you're still at 50% code at twelve months, something's wrong with how we've staffed the team."
Red flag: "Whatever you think is right" — sounds empowering, is actually an absence of a mandate, and you'll be judged against an unstated expectation. Or numbers that don't add to a real job ("you'll be hands-on 80% and also manage five people").
Your follow-up: "And how does the performance conversation work for this role — am I evaluated on what I ship or what the team ships?" The answer to that is the real job description.
What you do with it: If the answer conflicts with what you want, this is the moment to say so. Both of you are better off.
"What's your operating rhythm with your leads — 1:1 cadence, what you want escalated, how you deliver hard feedback?"
Why: You'll spend more time managing this relationship than any other. Compatibility here determines whether the job is sustainable.
Green flag: They have an answer because they've thought about how they manage. "Weekly 1:1s, yours not mine — bring the agenda. Escalate anything that'll slip a committed date or involves another director, and escalate early rather than when you've exhausted options. On feedback, I'll tell you directly and privately within a day or two; I don't save things for reviews."
Red flag: No cadence, or "my door is always open" as the whole answer — that's an absence of structure dressed as accessibility. Also: "I'm pretty hands-off" from a director who's never managed a lead before can mean abandonment.
Your follow-up: "When was the last time one of your leads pushed back on you, and what happened?" This is the highest-signal follow-up in the whole set.
What you do with it: Compare against how you actually work. A weekly-1:1 director and a "ping me when you need me" director are different jobs.
"Where do you and your manager currently disagree about this team's direction?"
Why: It's a bold question and it's fair. You're asking whether they have air cover and whether strategy is settled.
Green flag: A real, specific answer delivered without anxiety. "She wants us to prioritize the enterprise features; I think we have a reliability problem we need to fix first. We haven't resolved it — that's part of why I want someone strong in this role." That's honesty and it tells you exactly what you're walking into.
Red flag: "We're fully aligned." Either untrue, or the manager has no independent view. Both are bad — a lead reporting to someone with no position of their own gets whipsawed by every change from above. Also watch for visible discomfort at the question itself: that tells you disagreement isn't safe in this org.
Your follow-up: "How do disagreements like that usually get resolved here?"
What you do with it: You now know the political weather and can position your first-90-day plan on the side of whichever priority actually gets funded.
59.2 To the director / skip-level
"How do you like to be disagreed with?"
Why: You will disagree with this person. Asking the mechanics up front makes the first real disagreement survivable, and asking it at all signals that you expect to disagree — which is itself a staff-level posture.
Green flag: A specific mechanism, not a platitude. "Bring me the data and an alternative, not just an objection. In a group setting, push back in the room — I'd rather have it out in front of people than get a Slack message afterward. And if I've decided and you still disagree, tell me once more privately, then commit." That's someone who has thought about it and can be worked with.
Red flag: "I love being challenged!" with no mechanism behind it. Everyone says this; the people who mean it can describe how. Follow up hard on this one. Another red flag: an answer that's entirely about channel and never about substance ("just book time with me").
Your follow-up: "Can you give me an example of a time someone changed your mind?" This is the question that separates the real answer from the performance. If they can name a specific instance — what they thought, who pushed, what changed — the earlier answer was true. If they can't produce one, treat "I love being challenged" as decoration.
What you do with it: Calibrate your first disagreement deliberately. Use the channel they described, with the evidence they asked for, on something that matters but isn't existential. You're testing the answer at low stakes.
"What's your escalation threshold — when do you want me to bring you in?"
Why: Both failure modes are expensive. Escalating too early reads as not owning the job; escalating too late means they hear about a problem from someone else, which damages trust permanently. Nobody tells you where the line is unless you ask.
Green flag: Named categories with a bias stated. "Bring me anything that will slip a date we've committed externally, anything involving another director's team where you've already tried once, anything with legal or security implications, and anything about a person's employment. On everything else, decide and tell me in our 1:1. And I'd rather you escalate too early than too late — I'd rather hear it from you than from my peer." That last clause is the tell of a good manager.
Red flag: "Use your judgment" as a complete answer — that's the setup for being criticized later for a judgment call they'd have made differently. Also concerning: a threshold so low it's micromanagement ("check with me before any architectural decision"), or a director who says "never, I trust you completely" — which sounds great and means you'll be alone when something goes wrong.
Your follow-up: "And what's the thing you'd be annoyed to find out about late?" People answer this more honestly than the general version, because it's concrete and slightly negative.
What you do with it: Write the categories down and use them literally for the first quarter. After that you'll have calibrated instinct.
"What did the last person who succeeded in a role like this do differently?"
Why: Success at this level is context-dependent and the pattern is rarely written anywhere. The director has watched several people try; they know the differentiator even if they've never articulated it.
Green flag: A behavioral, specific answer. "The one who really worked spent her first two months just talking to people — engineers, support, sales — before proposing anything. Everyone else came in with a plan in week two and it never fit. And she wrote things down; when there was a disagreement six months later, there was a document." That's a playbook handed to you.
Red flag: Only technical attributes ("she was a really strong engineer") — that suggests the org doesn't distinguish leadership from IC excellence, and the role may not be a real leadership role. Or a generic answer ("hard work, good communication") meaning they haven't observed closely.
Your follow-up: "And the person who struggled — what did that look like?" The negative version often gets the more specific answer, because failures are memorable.
What you do with it: This is close to being handed the rubric. Structure your first 90 days around whatever they describe, and reference it explicitly in your follow-up note.
"What are you accountable for this year that this team materially affects?"
Why: It maps your work to their incentives. Once you know what their number is, you know which of your proposals will get funded.
Green flag: A specific metric or commitment, and a clear line from your team to it. "I've committed to cutting enterprise churn by three points, and the top-cited reason in exit interviews is search quality — which is this team."
Red flag: Vagueness, or an answer that reveals the team isn't connected to anything the director owns. That means low priority, low headcount, and low protection during a reorg. It's not disqualifying, but you should know it.
Your follow-up: "What would have to be true at the end of the year for you to feel this team over-delivered?"
What you do with it: Frame every subsequent proposal in these terms. Reliability investment framed as churn reduction gets funded; framed as tech debt it doesn't (§48.7).
"When this team missed or slipped in the past year, what was the real cause — and what changed afterward?"
Why: Tests whether the org learns. The "what changed" half is the important half.
Green flag: A causal story with a structural fix. "We committed to a date based on an estimate from before we understood the vendor integration. What changed is we now do a spike before committing to anything with an external dependency."
Red flag: Blame directed at individuals or another team, or a cause with no subsequent change — that's an org where postmortems are theater and the same slip is coming again on your watch.
Your follow-up: "Did that change stick?"
"What behavior gets people promoted here in practice — and what behavior gets tolerated that you wish didn't?"
Why: Two-part question doing real work. The gap between the answers is the culture.
Green flag: Honest on both halves, and the gap is small. "Promotions go to people who take on ambiguous cross-team problems and finish them. What I tolerate that I shouldn't is a couple of senior people who are dismissive in design reviews — I've addressed it but not hard enough." That's a director with self-awareness who'll be honest with you.
Red flag: Refusal to answer the second half, or a large gap between stated values and tolerated behavior. If heroics get promoted and burnout gets tolerated, you now know the job.
Your follow-up: None needed. Let the silence sit; people often add to this one unprompted.
"If I'm sitting here in a year and this hire clearly worked, what happened? And what's the most likely way it fails?"
Why: Forces a concrete success definition and, more valuably, gets them to name the risk they're already worried about.
Green flag: Both halves answered specifically. The failure half is the gift: "The most likely failure is you spend a year building the perfect platform and product feels you didn't help them ship." Now you know exactly what to guard against.
Red flag: Only the success half answered, or a failure mode that's entirely about you ("if you're not technical enough") with no acknowledgment of organizational risk.
What you do with it: Address the named failure mode directly in your close and in your follow-up note. It's the objection you now get to pre-empt.
59.3 To peer engineers and future reports
"Walk me through your last production incident — how did it go, and did the postmortem action items actually happen?" Green: A specific incident, a blameless account, and at least one action item they can point to as shipped. Red: Can't remember one (either nothing breaks — unlikely — or incidents aren't discussed), or action items that "usually get done" with no example. Follow-up: "Who ran it?" — tells you whether incident command is a role or a scramble.
"How long does a one-line change take to reach production, end to end?" Green: They know the number. Under a day is excellent, under a week is normal, and knowing it at all means the team measures flow. Red: "It depends" with no range, or a number in weeks with no discomfort about it. Follow-up: "Where does most of that time go?" — the answer is almost always review or environment queueing, and it tells you your first improvement target.
"What's the piece of the codebase everyone avoids, and why is it still like that?" Green: Named without hesitation, with a real reason (no owner, no tests, high risk, one person understands it). Every codebase has one; a team that can name it has healthy self-awareness. Red: "Nothing really" — either not candid with a candidate, or not looking. Follow-up: "What would it take to fix?" — tests whether improvement feels possible here.
"When you disagreed with a technical direction recently, what happened?" Green: A concrete instance where disagreement was heard, whether or not they won. Red: Long pause, or "you just do what you're told." That's the psychological-safety answer (§56.1) and it's the most important thing you'll learn all day.
"What would you fix first if you had a month of unscheduled time?" Green: Immediate and specific — they've been thinking about it, which means they care. Red: No answer, which usually means disengagement rather than contentment.
To future reports — "What do you want from your next lead that you're not getting today?" Green: Specific and actionable — career conversations, faster decisions, shielding from interruptions. Red: "Nothing, things are fine" from someone whose manager just left. Probe gently once, then let it go. What you do with it: This is your first-90-days list, and asking it during the loop means you arrive already knowing.
59.4 To the VP / CTO
"How does engineering show up in company strategy — cost center, product differentiator, or the product itself?" Green: A clear, honest answer with evidence, including an unflattering one delivered honestly ("we're a cost center to the CFO and I'm working on that"). Red: A flattering answer contradicted by everything else you heard in the loop — compare against headcount trends and whether engineering leadership is in strategy conversations.
"What's the bet the company is making that you think is under-appreciated?" Green: Genuine enthusiasm and specifics. This question makes executives talk, and how they talk about strategy tells you whether there is one. Red: Generic market language with no specific bet named.
"How is the org thinking about AI structurally — product capability, productivity layer, or both — and who owns that call?" Green: A named owner and a stated position, including "we've deliberately not made that call yet, and here's why." Red: Enthusiasm without ownership — which means the AI work is unassigned and will land on whoever volunteers, possibly you, without a mandate.
"What would make you say, two years from now, that engineering leadership hiring in this era was a success?" Green: An answer about capability and outcomes rather than headcount.
59.5 The universal closers
"What's the question I should have asked about this role that I haven't?" Green: They give you something real, and it's often the most valuable minute of the interview. Red: "You covered everything" — a missed opportunity, not a red flag. Use it in the second-to-last interview, not the last, so you have time to act on the answer.
"Based on this conversation, is there anything about my fit you're still unsure of? I'd rather address it now." Why it works: Converts a silent objection into an answerable one. Interviewers write feedback within a day; an unaddressed doubt becomes a written doubt. Green: They name something and you get to respond. Red: Deflection ("no, you did great") — accept it gracefully and move on; some interviewers are instructed not to give live feedback. Use it once per loop, with the hiring manager or in the final round. After: if they name something you answered weakly in the moment, address it properly in the follow-up note (§38.7) — that recovery has rescued candidacies.
"What made you stay?" Why it works: Works on anyone with tenure, and the pause before the answer is data. Green: Specific and personal — the people, a particular problem, growth they didn't expect. Red: A long pause, then a benefits-and-comp answer. That's someone staying for the paycheck, and if you hear it twice in one loop, weigh it.
59.6 Reverse due diligence — what to do when the red flags appear
Patterns, not single data points. Three or more across a loop is a signal.
| Pattern | What it predicts | Probe with |
|---|---|---|
| Interviewers describe the team differently | No shared reality; you'll be arbitrating from day one | "How does this team decide what to work on?" asked of three people |
| Nobody can define success for the role | An unwinnable mandate | "What does success look like at six months?" to everyone |
| Two predecessors gone in 18 months, no explanation | Structural problem you'll inherit | "What's changed since they left?" |
| Every problem answered with "we need great people" | The problem isn't headcount and they don't know it | "What have you tried that didn't work?" |
| HM can't describe their own manager's expectations | No air cover | "How does your manager measure this team?" |
| Postmortem items "usually get done," no examples | Reliability theater | "Name one that shipped" |
| All authority routes through one person | You'd be senior hands, not a lead | "Who decided the last significant architecture change?" |
| Visible contempt between product and engineering | Partnership is broken; you'll spend your time on it | Ask each side about the other |
| Can't explain why the level is what it is | Leveling chaos follows you in | "How was this level determined?" |
And the decision rule: you are allowed to decline an offer on this evidence. The most expensive career mistake at this level isn't failing a loop — it's passing one and taking the wrong job.
60. Stated-But-Unexplained — The Answers
Bullets across Parts II–IV assert a requirement without explaining the mechanism, the reasoning, or what to say. Each one below gets the full treatment: what it means, why it's true, how it's implemented, what happens without it, and the candidate's answer.
60.1 Agent determinism and debuggability (from §15.4)
The bullets as originally stated: "Full trace per step: inputs, tool calls, outputs, tokens, cost, model version" and "Replay capability — you cannot debug agents without it." True, and useless without the reasoning.
Why agents are uniquely undebuggable. In a normal service, a bug is reproducible: same input, same code, same output. An agent violates all three assumptions at once. The model is stochastic — the same prompt can produce a different tool call. The trajectory is path-dependent — step 14 depends on the output of step 13, so a single divergence early makes everything after it incomparable. The failure is often silent — the agent completed 20 steps, returned HTTP 200, and did the wrong thing. And the model is not yours — the provider can update it underneath you, changing behavior with no deploy on your side.
The consequence: the trace is not observability, it is the only source of truth about what happened. With a normal service you can re-run the request. With an agent, if you didn't capture it, it is gone permanently.
What "full trace" actually means, field by field, and why each field earns its place:
@dataclass
class StepTrace:
run_id: str # groups the whole trajectory
step_index: int # ordering — traces arrive out of order
parent_step: str|None # sub-agent lineage; without this, sub-agent
# traces are orphaned and unreadable
# --- The exact inputs. Not a summary. The bytes sent. ---
model: str # "claude-sonnet-4-6-20260514" — PINNED VERSION.
# "sonnet" is not a version. When behavior changes
# overnight with no deploy, this field is how you
# prove it and which support ticket you file.
system_prompt_hash: str # hash, plus the prompt in a versioned store
prompt_version: str # git sha of the prompt template
messages: list # full context AS SENT, post-assembly
tools_offered: list[str] # WHICH tools were in scope this step —
# "why didn't it use the search tool?"
# is usually answered here: it wasn't offered
temperature: float
seed: int|None
# --- What came back ---
output_text: str
tool_calls: list # name + arguments, exactly as emitted
stop_reason: str # "max_tokens" here means TRUNCATION, which
# looks like a reasoning failure and isn't
# --- Tool execution, separately from the model's request ---
tool_results: list # what the tool ACTUALLY returned
tool_latency_ms: dict
tool_errors: list
# --- Economics and performance ---
input_tokens: int
cache_read_tokens: int # cache hit rate per step; a drop means a
# prompt change broke prefix stability
output_tokens: int
cost_usd: float
ttft_ms: int
total_latency_ms: int
# --- Decisions your code made ---
policy_decisions: list # which gates fired: authz, approval,
# schema rejection, budget check
retrieved_doc_ids: list # RAG provenance: the answer to
# "was this a retrieval failure or a
# generation failure?" — the single most
# common diagnosis and you cannot make it
# without this field
The separation that matters most: log the model's requested tool call and the tool's actual result as distinct events. Half of agent bugs are "the model asked for the right thing and the tool returned garbage," and if you log them together you cannot tell that from "the model asked for the wrong thing."
What replay means, precisely — and it isn't re-running the agent. Three distinct capabilities, increasingly useful:
- Trace inspection — read what happened. Table stakes.
- Deterministic replay of the harness — re-execute your orchestration code against the recorded model responses and tool results. The model isn't called; you're testing your own code paths. This catches parsing bugs, state-machine bugs, and policy-gate bugs, and it is fully deterministic. This is the one most teams skip and the one that pays off daily.
- Counterfactual replay — re-run from step N with something changed (a new prompt, a different model, a fixed tool). The trajectory diverges after N, which is expected and fine: you're asking "does the new version get past the step where the old one failed?", not diffing the whole run.
class ReplayHarness:
"""Replays orchestration logic against recorded model/tool outputs.
Fully deterministic — no provider calls, no cost, runs in CI."""
def __init__(self, trace: RunTrace, override_from: int|None = None):
self.trace = trace
self.override_from = override_from # None = pure replay
def model_call(self, step, request):
if self.override_from is not None and step >= self.override_from:
return real_model_call(request) # counterfactual branch
recorded = self.trace.steps[step]
# Assert the harness produced the same request it did originally.
# A mismatch means YOUR code changed behavior — often the actual bug.
assert_request_equivalent(request, recorded.messages)
return recorded.response
def tool_call(self, step, name, args):
if self.override_from is not None and step >= self.override_from:
return real_tool_call(name, args)
return self.trace.steps[step].tool_results[name]
Determinism: what you can and cannot have. You cannot make the model deterministic — temperature=0 reduces variance but doesn't eliminate it (batching, hardware, and provider-side changes all introduce nondeterminism, and most providers say so explicitly). What you can make deterministic is everything you own: the context assembly, the parsing, the state machine, the policy gates, the retry logic, the termination conditions. So the design rule is: push all logic into the deterministic layer and treat the model as an untrusted, replayable I/O boundary. That's the same discipline as hexagonal architecture (§35.4) — the model is an adapter, not the core.
What you lose without this, concretely: a user reports the agent deleted the wrong record last Tuesday. Without traces you have logs saying "delete_record called with id=447" and no way to know whether the model hallucinated the ID, retrieval returned the wrong document, the prompt template changed, or the provider silently updated the model. You will not solve it. You will add a guardrail and hope. That's the failure this bullet was pointing at.
Cost and privacy, since an interviewer will raise them: full traces are expensive (prompts are large) and contain user data. The production answer is tiered: sample ~1% of successful runs, keep 100% of runs that errored, escalated to a human, hit a policy gate, or were thumbs-downed, redact PII at the collector (§28.2), and set retention by tier — 7 days hot for everything sampled, 90 days for failures, and a permanent golden set of interesting traces that becomes eval material.
The candidate's answer, three ways:
Screen (45s): "Agents are stochastic and path-dependent, so you can't reproduce a bug by re-running it — the trace is the only record of what actually happened. I'd capture every step: the exact context sent, the pinned model version, tools offered, the requested tool call and the actual tool result as separate events, tokens and cost, and the retrieved document IDs. And I'd build replay of the orchestration layer against recorded responses, so my own code is deterministically testable even though the model isn't."
Depth (2 min): (as above, plus) "The distinction I'd emphasize is between the parts I control and the part I don't. I can't make the model deterministic — temperature zero reduces variance but doesn't remove it, and providers are explicit about that. So I push everything else into a deterministic layer: context assembly, parsing, the state machine, policy gates, termination conditions. The model becomes an untrusted, replayable I/O boundary, which is really just hexagonal architecture applied to a probabilistic dependency. Then replay has three levels: read the trace, deterministically re-run my harness against recorded responses — that's the one that catches most real bugs and runs in CI for free — and counterfactual replay from step N with a changed prompt or model to check whether a fix gets past the failure point. On cost, I'd sample successes at one percent and keep a hundred percent of failures, escalations, policy-gate hits, and thumbs-downs, with PII redacted at the collector."
Story: "We had an agent that started failing a specific task type, and the only thing that changed was nothing on our side — no deploy. Because we pinned and logged the model version per step, we could see the provider had rolled a new model snapshot, and because we had traces from before and after we could show exactly which step's tool selection changed. Without the version field in the trace we'd have spent a week suspecting our own prompt. That's when I stopped treating model version as metadata and started treating it as a deployed dependency with a changelog."
Follow-ups:
- "How do you test an agent in CI without calling the model?" → Replay level 2 against a library of recorded traces. Every past production failure becomes a regression test. This is the agent equivalent of a fixture-based test suite and it's fast and free.
- "How do you know the agent got better after a change?" → Not from one trace. Task-level success rate on the eval suite (§44.4), plus steps-to-completion and cost per successful task. Traces diagnose individual failures; evals measure aggregate quality. Conflating them is the common mistake.
- "What if the trace itself is huge?" → Store prompts by content hash in object storage and reference them from the span; identical system prompts dedupe to one blob. Traces become small pointers.
60.2 "Idempotency keys, exactly-once as at-least-once + dedupe" (§5.1)
What it means. True exactly-once delivery is impossible across a network: the sender can't distinguish "the message was lost" from "the ack was lost," so it must either retry (risking duplicates) or not (risking loss). Every real system chooses at-least-once and removes the duplicates at the receiver. "Exactly-once semantics" as marketed always means at-least-once delivery plus deduplication somewhere.
Mechanism. The client generates a unique key per logical operation; the server records it atomically with the effect.
BEGIN;
INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ($1, $2, 'in_progress')
ON CONFLICT (key) DO NOTHING;
-- 0 rows affected means we've seen this key: return the stored response
-- (or 409 if still in_progress — a concurrent retry)
... perform the effect ...
UPDATE idempotency_keys SET status='complete', response=$3 WHERE key=$1;
COMMIT;
The details that show experience: the key comes from the client (server-generated keys can't survive a client-side retry after a timeout); same key with a different body returns 422 rather than silently overwriting, which catches client bugs; keys expire (24h is Stripe's convention); and the key must be recorded in the same transaction as the effect or you've just moved the race.
Candidate's answer: "I'd say exactly-once doesn't exist on the wire — you get at-least-once plus idempotent receivers, and the honest version is 'effectively once.' Practically that means client-supplied idempotency keys on every mutating endpoint, recorded in the same transaction as the effect, with same-key-different-body returning an error rather than overwriting."
60.3 "Blast radius and cell-based architecture" (§5.1)
Definition. A cell is a complete, independent instance of your stack — compute, data, cache — serving a subset of customers. Cells don't share state. A cell failure affects only its tenants.
Why it exists. Horizontal scaling makes you bigger; it doesn't make failures smaller. In a single large deployment, one poison-pill request, one bad config, or one hot tenant degrades everyone. Cells convert a total outage into a partial one.
Mechanism: a thin routing layer maps tenant → cell (by hash or explicit assignment); deploys roll cell by cell with bake time (§39.1); cells are sized so the largest is a tolerable blast radius (commonly 5–10% of traffic); and there's usually a small "canary cell" of internal or volunteer tenants that gets every change first.
Real usage: AWS is explicit about cell-based architecture in its Well-Architected guidance and uses it internally; Slack's and Shopify's "pods" are the same pattern applied to multi-tenant SaaS, where a pod is a full vertical slice of the stack for a subset of shops.
The cost to name: cells multiply operational surface (N of everything), make cross-tenant features hard, and create uneven utilization. You adopt cells when the cost of a total outage exceeds the cost of running N stacks — which is a business calculation, not an engineering preference.
Candidate's answer: "Scaling out makes you bigger, not safer. Cells make the blast radius a design parameter instead of an accident — I'd size a cell at the largest outage I'm willing to have, route tenants deterministically, and deploy cell by cell with an internal canary cell first. The cost is N of everything and painful cross-tenant features, so I'd only do it where a full outage is genuinely unacceptable."
60.4 "Backpressure, load shedding, admission control" (§5.1) — three different things
Candidates use these interchangeably. They aren't.
| Backpressure | Load shedding | Admission control | |
|---|---|---|---|
| What | Signal upstream to slow down | Drop work you've already accepted | Refuse work at the door |
| Where | Between components you control | Inside the overloaded service | At the entry point |
| Mechanism | Bounded queues, request(n) (§17.2), TCP window, consumer lag | Priority-based dropping, timeouts, queue-depth triggers | Concurrency limits, rate limits, queue-time SLO |
| When it works | Closed systems where the producer can slow down | You're already saturated and must protect the core | You can predict capacity |
| Fails when | The producer is the public internet and cannot be told anything | Everything is equally important (fix your priorities) | Capacity is unpredictable |
The key insight: backpressure is useless against user traffic — you cannot ask the internet to slow down. Against the internet you need admission control and shedding. Backpressure is for internal pipelines.
Do this in the right order: admission control first (cheapest — you never pay for the work), then shed by priority when saturated, then backpressure internally. And shed the newest work, not the oldest — the oldest requests have already consumed resources and may have a user waiting on a partially-streamed response.
Real usage: Netflix's concurrency-limits library derives limits adaptively from observed latency using TCP-congestion-control-style algorithms rather than static thresholds — the reference implementation, and worth naming because static concurrency limits are always wrong at some traffic level.
Candidate's answer: "They're three layers, not synonyms. Admission control refuses at the door; shedding drops accepted work by priority when I'm saturated; backpressure signals upstream in pipelines I control. Against public traffic only the first two apply, because you can't tell the internet to slow down. I'd want adaptive limits derived from observed latency rather than static thresholds — Netflix's concurrency-limits is the model — because any fixed number is wrong at some point on the traffic curve."
60.5 "Feature stores: online/offline parity, point-in-time correctness" (§6.3)
The problem, concretely. You train a model on features computed in a batch job over historical data. You serve it with features computed in a streaming path at request time. The two computations drift — different code, different windows, different null handling — and your model degrades in production while offline metrics look fine. That's training/serving skew, and it's the leading cause of "the model was great in the notebook."
Point-in-time correctness is the subtler one. Building training data by joining today's feature values onto historical labels leaks the future into the past: you're training the model on information it won't have at inference. The result is spectacular offline metrics and a useless model. A feature store's core job is the point-in-time-correct join — for each label at time T, retrieve the feature values as they were at T.
Mechanism: one feature definition compiled to both a batch path (writes to an offline store, typically the warehouse/lakehouse) and a streaming path (writes to an online store, typically Redis or DynamoDB); the offline store is append-only with event timestamps so as-of joins work; the online store holds only current values for low-latency serving.
Named systems: Uber's Michelangelo popularized the pattern; Feast is the open-source reference; Tecton, Databricks Feature Store, and SageMaker Feature Store are the commercial versions.
Candidate's answer: "Two problems, and people usually only name the first. Skew is when the batch and serving computations differ — the fix is a single feature definition compiled to both paths, not two implementations you try to keep in sync. Point-in-time correctness is worse because it's silent: if you build training data by joining current feature values onto historical labels, you've leaked the future and your offline metrics will look excellent for a model that can't work. That's what the as-of join in a feature store is actually for."
60.6 "Sticky sessions can starve the canary" (§39.2)
Why. You set a canary weight of 5%. Session affinity pins each user to the instance they first hit. Returning users are already pinned to stable instances, so only new sessions can land on the canary — and if most of your traffic is returning users, actual canary traffic might be 0.5%, not 5%. You then declare the canary healthy on a tenth of the sample you thought you had.
How to detect: measure the canary's observed request share, never the configured weight. If configured and observed diverge, your canary analysis is invalid.
How to fix: cohort by a stable user hash rather than by session affinity, so a deterministic 5% of users (not sessions) go to the canary and stay there — which also fixes the flapping problem (§39.2). Or drain affinity before the canary. Or, if the app genuinely needs server-side session state, fix that instead: externalize sessions to Redis and the whole class of problem disappears.
Candidate's answer: "I'd verify the canary's observed traffic share rather than trusting the configured weight — affinity means returning users are already pinned elsewhere, so you can be analyzing a tenth of the sample you think you have. The clean fix is cohorting on a stable user hash instead of session affinity, which also stops users flapping between versions mid-session."
60.7 "Deleted nodes degrade HNSW until rebuilt" (§47.2)
Why deletes are hard. HNSW is a proximity graph where search quality depends on connectivity. Removing a node would require repairing every edge that routed through it — expensive and potentially graph-fragmenting. So implementations use soft deletes: mark the node, skip it in results, but keep traversing through it.
The degradation: search still costs the same (you traverse deleted nodes to reach live ones), ef_search effectively shrinks because deleted candidates consume the candidate list, and recall drops. At 20–30% deleted, quality is materially worse. In Lucene-based systems the recovery mechanism is segment merging, which physically drops deleted docs and rebuilds the graph for that segment.
What to do: monitor deleted-vector ratio as an operational metric; force merge or rebuild past a threshold (10–20%); for genuinely high-churn corpora, prefer time-partitioned indices you can drop wholesale (same instinct as TWCS in §46.1), or use IVF where deletes are cheaper.
Candidate's answer: "HNSW deletes are soft, because removing a node from a proximity graph would require repairing every edge through it. So deleted vectors still cost you traversal and eat your candidate list, and recall degrades as the deleted ratio climbs. I'd track that ratio as an SLI and rebuild past ten or twenty percent — and if the corpus is genuinely high-churn, I'd question HNSW and look at time-partitioned indices I can drop whole, or IVF."
60.8 "Prefer composition over deep inheritance" (§35.1)
Why, mechanically. Inheritance couples subclass to superclass implementation, not just interface — a change in the base class silently changes every descendant (the fragile base class problem). It's also single and permanent in most languages: you choose one parent at compile time forever. And deep hierarchies violate Liskov quietly, because each level adds preconditions the base contract didn't have.
Composition instead: the object holds a collaborator and delegates. You can swap it at runtime, hold several, and test with a double. Strategy, Decorator, and dependency injection are all composition.
The honest counter: inheritance is right for genuine is-a relationships with stable contracts — framework extension points, sealed hierarchies (algebraic data types), and template methods where the variation is truly a step in a fixed algorithm. Blanket "inheritance is bad" is as unthinking as deep hierarchies.
Candidate's answer: "Inheritance couples you to the parent's implementation and you get one parent forever, so a base-class change ripples into every descendant. Composition gives you swappable collaborators and testable seams. I'd still use inheritance for genuine is-a with a stable contract — sealed hierarchies and framework extension points — but I default to composition, and if a hierarchy is more than two deep I'd want to know why."
§59–60 close the "stated but not answered" gap for the highest-traffic items. The same treatment is owed to every remaining bullet in Parts II–IV; §51's gap register enumerates them, and the Claude Code build applies this exact pattern — mechanism, reasoning, consequence of absence, candidate's answer in three registers, follow-ups — to each one.
Source: graph ·
graph.md· updated 2026-08-02 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Graph Engineering — A Field Guide from Zero
What the term means, which parts are real, and how to decide whether any of it is worth your time.
Written for someone with no graph database, Cypher, or GNN background. Every term is defined before use.
Table of Contents
- 0. Read This First
- Part I — The Primitive, From Nothing
- Part II — Where the Term Came From
- Part III — Meaning A: Knowledge and Memory Graphs
- Part IV — Meaning B: Orchestration Graphs
- Part V — Meaning C: Graphs of Loops
- Part VI — Where Graph Projects Actually Die
- Part VII — Scenarios on Your Systems
- Part VIII — A Concrete Evaluation Path
- Appendix — Glossary and Sources
0. Read This First
The 60-second version.
- "Graph engineering" is about five weeks old as a term. A quiet blog post on July 4, 2026 used it first; a twelve-word post by Peter Steinberger on July 18 made it viral. He was mocking the buzzword treadmill. The treadmill did not care.
- Within 48 hours it meant three different things. Conflating them is the main reason the discourse is confusing.
- Only one of the three has a decade of research, independent benchmarks, and production deployments behind it: graph-structured knowledge and memory. The term is new; that substance is not.
- The core finding from independent evaluation: graphs win multi-hop, temporal, and corpus-wide synthesis questions. They lose on simple fact lookup and on cost. The practitioner consensus is to route by question type, not to replace your retrieval stack.
- The thing that kills graph projects is not graph algorithms. It is entity resolution — deciding that two mentions are the same thing — and its errors compound multiplicatively across hops.
- A "$3.1M Stanford and Anthropic study" circulated widely in the discourse. It does not exist. If you see it cited, that is a signal about the source.
One sentence to keep: vector search finds things that sound like your question; graphs find things that are connected to your answer.
How to read this document. Part I builds the primitive from nothing. Parts II–V cover the three meanings and how real each is. Part VI is the failure mode. Part VII applies it to retrieval and recommendation work. Part VIII is a decision procedure you can actually run.
Part I — The Primitive, From Nothing
1.1 Nodes and Edges
Strip every buzzword away and a graph is two things.
- Node — a thing you know about. A person, a document, a case, a decision, an incident, a product.
- Edge — a connection between two nodes.
That is the entire primitive. Everything else is engineering on top of it.
flowchart LR
A(("Case<br/>A v. B")) --- B(("Judge<br/>Smith"))
A --- C(("Statute<br/>s.12"))
C --- D(("Amendment<br/>2024"))
style A fill:#2a2a3d,color:#fff
style B fill:#2a2a3d,color:#fff
style C fill:#2a2a3d,color:#fff
style D fill:#2a2a3d,color:#fff
You already work with graphs constantly without calling them that:
| Thing you know | Nodes | Edges |
|---|---|---|
| A git history | commits | parent-of |
| An import tree | modules | imports |
| A citation network | papers | cites |
| Your service topology | services | calls |
| A legal corpus | cases, statutes, judges | cites, overturns, amends |
None of this requires a graph database. A graph is a shape, not a product.
Why it matters for AI specifically: an agent answering a question has to find the relevant knowledge before it can answer. The shape of your knowledge determines what is findable.
1.2 The One-Bit Problem — Typed vs. Untyped Edges
This is the single most important distinction in the whole field, and it is easy to miss.
An untyped edge says "these two things are related." That is one bit of information. Related how? Unknown.
A typed edge names the relationship: supersedes, depends_on, caused, cites, overturns, authored_by.
flowchart TB
subgraph U["UNTYPED — one bit"]
U1(("ADR-007")) --- U2(("ADR-003"))
U2 --- U3(("Incident"))
end
subgraph T["TYPED — meaning"]
T1(("ADR-007")) -->|supersedes| T2(("ADR-003"))
T2 -->|caused| T3(("Incident"))
end
style U fill:#3d2a2a,color:#fff
style T fill:#1f3d2a,color:#fff
Read the untyped version aloud: "a decision is related to another decision, which is related to an incident." Did ADR-007 replace ADR-003, or the reverse? Did the incident cause the decision, or the decision cause the incident? The chain survives; the meaning is gone. An agent has to open and re-read every node and guess.
The typed version is a sentence: ADR-007 supersedes ADR-003, which caused the incident. That is something you can reason over without reading the documents.
Practical rules that hold across every serious system:
- Keep the vocabulary small and controlled — roughly 10–20 verbs, not freeform strings. Freeform types are barely better than untyped, because nothing composes.
- Define inverses — if
supersedesexists,superseded_byshould be derivable. - Direction matters.
A cites BandB cites Aare different facts.
Hold onto this section. Nearly every disappointing graph project has untyped or freeform-typed edges at the bottom of it.
1.3 Hops, Multi-Hop, and Traversal
- Traversal — starting at one node and walking along edges to reach others.
- Hop — one step along one edge.
- Multi-hop question — a question whose answer requires following more than one edge.
An example in your domain:
Question: "Is the reasoning in Case A still good law?"
hop 1: [[Case A]] --relies_on--> [[Case B]]
hop 2: [[Case B]] --overturned_by-->[[Case C]]
hop 3: [[Case C]] --decided_in--> [[2024]]
Three hops. The answer — no, its foundation was overturned in 2024 — exists in none of the three documents individually. It exists in the structure between them.
This is the whole argument for graphs. Most genuinely interesting questions about any real body of knowledge are multi-hop. "Who decided X and what broke because of it" is two hops. "What replaced the thing this depends on" is two hops. Simple lookups — "what is the citation for Case A" — are zero hops, and graphs add nothing there.
1.4 The Only Three Ways to Find Anything
There are exactly three retrieval mechanisms. Each fails in a characteristic way.
| Mechanism | How it works | Fails when |
|---|---|---|
| Keyword search (BM25) | Finds documents containing your words | The answer uses different words. "Vehicle" won't find "automobile" |
| Vector search | Embeds the question, finds documents about similar things | The answer is spread across documents that are individually not similar to the question |
| Graph traversal | Starts at a node, walks the connections | The connections don't exist, or are wrong |
You already run the first two, and you already hybridise them — that is what a BM25-plus-dense pipeline is. Graph traversal is a third leg, not a replacement for either.
The vector failure mode deserves a concrete example, because it is not obvious:
Question: "Why did we drop Redis for the job queue?"
Vector search embeds that and returns the ten chunks most similar to it. You get ten documents that mention Redis and job queues. None of them explains the decision — because the explanation lives in a decision record, the thing it replaced, and the incident that triggered it. Three separate documents, none of which is individually very similar to your question.
Similarity search has no concept of "these three belong to one causal chain." It is not a tuning problem. There is no k large enough and no embedding model good enough to fix it, because the relationship being asked about is structural, not semantic.
1.5 Vocabulary So Far
| Term | Meaning |
|---|---|
| Node | A thing you know about — entity, document, decision |
| Edge | A connection between two nodes |
| Typed edge | An edge that names the relationship (supersedes, caused) |
| Untyped edge | An edge that only says "related" — one bit |
| Direction | Edges point; A cites B ≠ B cites A |
| Traversal | Walking from node to node along edges |
| Hop | One step along one edge |
| Multi-hop | A question requiring more than one hop |
| Entity resolution | Deciding that two mentions refer to the same node |
| Knowledge graph | A graph of entities and typed relationships used as a knowledge store |
| GraphRAG | Retrieval-augmented generation where the retrieval step uses a graph |
| Bi-temporal | Tracking two timelines per fact — see 3.5 |
| Community detection | Clustering a graph into neighbourhoods of related nodes |
Part II — Where the Term Came From
2.1 The Treadmill
"Graph engineering" is the latest stop on a naming treadmill. Each name described a real shift in where practitioners were putting their attention, and each got turned into content slop within weeks.
timeline
title The naming treadmill
2023 : Prompt engineering<br/>craft the words you send
Mid-2025 : Context engineering<br/>curate everything in the window
June 2026 : Loop engineering<br/>design the act-observe-retry cycle
July 2026 : Graph engineering<br/>design what happens between loops
The honest read: these overlap heavily. They are not four disciplines; they are four zoom levels on the same problem — what information reaches the model, in what shape, at what point. Knowing that is useful inoculation against the next term.
2.2 The 48 Hours That Made the Term
The documented sequence, worth knowing so nobody can bluff you with it:
| Date | Event |
|---|---|
| July 4, 2026 | Josh Simmons publishes "We are entering the graph engineering phase" — the earliest documented use |
| July 18, 2026 | Peter Steinberger posts twelve words: "Are we still talking loops or did we shift to graphs yet?" Thousands of likes. He was mocking the treadmill |
| ~10 hours later | Carlos Perez publishes one of the first serious essays expanding it into a network-theory account |
| Same day | The backlash starts. The sharpest reply: "congrats, you reinvented LangGraph" |
| Within 48 hours | Three competing definitions in circulation, plus a fabricated "$3.1M Stanford and Anthropic study" that does not exist |
Two things worth taking from this:
- The term is a meme that landed on something real. Both halves of that sentence are true, and most commentary picks one.
- A fabricated study went viral inside 48 hours. If a source cites it, that source did not check. This is a useful filter for the rest of the discourse.
2.3 Three Meanings, Very Different Maturity
flowchart TD
T["'Graph engineering'<br/>July 2026"]
T --> A["<b>A. Knowledge and memory graphs</b><br/>Knowledge as typed nodes and edges<br/>an agent traverses<br/><i>GraphRAG, temporal agent memory</i>"]
T --> B["<b>B. Orchestration graphs</b><br/>Multi-agent systems as explicit graphs<br/>instead of single loops<br/><i>LangGraph, Temporal, AutoGen</i>"]
T --> C["<b>C. Graphs of loops</b><br/>Networks of feedback cycles<br/>watching and correcting each other"]
A --> A2["<b>Real and measurable.</b><br/>Decade of research, independent<br/>benchmarks, production systems"]
B --> B2["<b>Real but mostly prior art.</b><br/>Shipped in tools well before<br/>the name existed"]
C --> C2["<b>Interesting, not actionable.</b><br/>No tooling, no benchmarks,<br/>no agreed definition"]
style A2 fill:#1f3d2a,color:#fff
style B2 fill:#3d3a1f,color:#fff
style C2 fill:#3d2a2a,color:#fff
| A. Knowledge/memory | B. Orchestration | C. Graphs of loops | |
|---|---|---|---|
| The graph is made of | entities and relationships | agents and control flow | feedback loops |
| Traversed at | query time, to retrieve | run time, to execute | conceptually |
| Prior art | knowledge graphs, 1990s onward; GraphRAG 2024 | LangGraph, Temporal, AutoGen, Google ADK | control theory, cybernetics |
| Independent benchmarks | Yes — GraphRAG-Bench, HippoRAG 2, LongMemEval | Partial — framework benchmarks, not the paradigm | No |
| Production evidence | Yes — SAP shipped a knowledge graph as an agent context layer; enterprise deployments reported | Yes | No |
| Worth your time | Probably — see Part VII | Only if your agent work has outgrown a loop | Read it, don't build on it |
The rest of this document treats them separately, because the useful advice differs completely.
Part III — Meaning A: Knowledge and Memory Graphs
This is the one with substance behind it. Serious people were building it before the name existed, under other labels — Foundation Capital called it "context graphs" in December 2025.
3.1 Why Flat Retrieval Breaks — Structurally
Return to the failure from 1.4, because the reason matters more than the example.
Chunk-and-embed retrieval makes an assumption: that the answer to a question is semantically similar to the question. For a large class of questions that assumption simply does not hold.
flowchart TD
Q["Question:<br/>'Why did we drop Redis for the job queue?'"]
Q --> V["<b>Vector search</b><br/>embed question,<br/>retrieve top-k similar chunks"]
V --> VR["10 chunks mentioning<br/>Redis and job queues.<br/><b>None explains the decision.</b>"]
Q --> G["<b>Graph traversal</b><br/>start at the entity,<br/>walk typed edges"]
G --> G1["[[Job queue]] --decided_by--> [[ADR-007]]"]
G1 --> G2["[[ADR-007]] --supersedes--> [[ADR-003 Redis]]"]
G2 --> G3["[[ADR-003]] --caused--> [[Incident 2026-03-11]]"]
G3 --> GR["<b>3 documents, ~1,000 tokens,<br/>causal chain intact</b>"]
style VR fill:#3d2a2a,color:#fff
style GR fill:#1f3d2a,color:#fff
Note the token count in that comparison. Ten chunks that don't answer the question cost more than three that do. When the graph works, it is often cheaper at query time, not just better — which cuts against the usual assumption that graphs are the expensive option. The expense is at index time, and that is a different budget.
3.2 Microsoft GraphRAG — The Reference Architecture
The system that made GraphRAG a category, published by Microsoft Research in 2024. Worth understanding because everything since is a reaction to it.
flowchart LR
D[Documents] --> C[1. Chunk]
C --> E["2. LLM reads every chunk,<br/>extracts entities + relationships"]
E --> CD["3. Community detection<br/>clusters related entities"]
CD --> S["4. LLM writes a summary<br/>report per community"]
S --> I[(Index)]
I --> Q{"5. Query time"}
Q -->|"broad question"| MR["Map-reduce over<br/>community reports"]
Q -->|"specific question"| EX["Expand from a<br/>matched entity"]
It works. It also has two problems that shaped the whole field:
- Index cost. An LLM call per chunk. Microsoft Research's own LazyGraphRAG post positions full GraphRAG's indexing cost as roughly 1,000× that of vector RAG. Estimates of tens of thousands of dollars to index a single large enterprise corpus circulate widely; treat the specific dollar figures as illustrative rather than measured, but the order of magnitude is the vendor's own framing.
- Free-text edges don't compose. Relationships extracted as arbitrary natural-language strings can't be traversed reliably — this is 1.2 biting at scale.
Lesson one: the naive version is too expensive, and untyped extraction doesn't compose.
3.3 LazyGraphRAG — Microsoft's Own Correction
Microsoft Research published LazyGraphRAG in November 2024, inverting the design.
| Full GraphRAG | LazyGraphRAG | |
|---|---|---|
| Index time | LLM extracts entities and writes community summaries | Zero LLM calls. NLP noun-phrase extraction for concepts and co-occurrence, then graph statistics for community structure |
| Query time | Map-reduce over precomputed summaries | Iterative deepening — best-first plus breadth-first, LLM relevance tests on demand |
| Indexing cost | baseline | Identical to vector RAG — 0.1% of full GraphRAG |
| Tuning | fixed | one parameter: relevance test budget |
Microsoft's reported results: comparable answer quality to GraphRAG global search on global queries at more than 700× lower query cost; at 4% of GraphRAG global search's query cost, it outperformed all competing methods on both local and global queries — including vector RAG, RAPTOR, and GraphRAG's own local, global, and DRIFT search mechanisms.
Lesson two, and the most practically useful thing in this document: you do not need to pre-compute the graph's meaning. A cheap structural graph plus smart traversal at query time captures most of the value. If you pilot anything, pilot this shape.
(Caveat: these are the vendor's own numbers on the vendor's own evaluation. The direction is well-supported; the multipliers are not independently confirmed.)
3.4 HippoRAG 2 — Hybrid, and No Regression
From OSU's NLP group, published February 2025 as "From RAG to Memory: Non-Parametric Continual Learning for Large Language Models." It builds on Personalized PageRank — importance spreads outward along edges from wherever the query touched the graph — combined with deeper passage integration and online LLM use.
The paper's headline claim is a 7% improvement in associative memory tasks over a state-of-the-art embedding model, while also exceeding it on factual and sense-making memory.
The result that actually matters is the negative one. The paper's framing is explicit: earlier graph-augmented approaches improved sense-making and associativity but dropped considerably below standard RAG on basic factual memory. HippoRAG 2's contribution is fixing that regression — improving multi-hop and synthesis without sacrificing performance on simpler tasks.
Lesson three: the winning configuration is hybrid, never graph-only. Most graph systems buy multi-hop performance by giving up simple-lookup performance. That trade is usually a bad deal, because most production traffic is simple lookups.
Correction to what's circulating: several posts cite "HippoRAG 2 beats a strong embedding model by 9.5 F1 points on 2WikiMultiHopQA." I could not confirm that figure in the abstract or the OpenReview record. The 7% associative-memory improvement is the paper's own headline. Use that one.
3.5 Graphiti and Zep — Memory That Knows What Time It Is
GraphRAG answers questions about a fixed corpus. Agent memory is a harder problem: knowledge that changes while the agent is using it.
This is where graphs do something a vector store structurally cannot.
Graphiti — the open-source engine behind Zep, ~20,000 GitHub stars — is bi-temporal. Every edge carries two timelines:
- Valid time — when the fact was true in the world
- Transaction time — when the system learned it
flowchart TD
subgraph VS["Vector store"]
V1["Fact: 'Statute s.12 requires X'"]
V1 --> V2{"New info arrives:<br/>amended in 2024"}
V2 --> V3["<b>Overwrite</b> — old fact gone,<br/>can't answer 'what applied in 2023?'"]
V2 --> V4["<b>Or duplicate</b> — both facts present,<br/>agent cites whichever it retrieves"]
end
subgraph TKG["Temporal knowledge graph"]
T1["Edge: s.12 --requires--> X<br/>valid_from 2019"]
T1 --> T2{"New info arrives"}
T2 --> T3["<b>Close the interval</b><br/>valid_until 2024"]
T3 --> T4["<b>New edge</b><br/>s.12 --requires--> Y<br/>valid_from 2024"]
T4 --> T5["Both questions answerable.<br/><b>Facts are superseded, not deleted.</b>"]
end
style V3 fill:#3d2a2a,color:#fff
style V4 fill:#3d2a2a,color:#fff
style T5 fill:#1f3d2a,color:#fff
Reported results: on the Deep Memory Retrieval benchmark that the MemGPT team established as their own primary metric, Zep scored 94.8% against MemGPT's 93.4%, with further evaluation on LongMemEval. The margin on DMR is modest — the stronger claims are about temporal reasoning tasks that DMR doesn't test.
Why this section matters more than the rest of Part III for legal-domain work. Every long-lived knowledge base has this shape. Decisions supersede decisions. Claims go stale. A system that cannot represent "X replaced Y on this date" slowly fills with contradictions, and an agent reading it will confidently cite the stale half.
Legal information is the extreme case: rulings get overturned, statutes get amended, precedent gets distinguished, regulations have commencement dates. "What is true now" and "what was true when the events occurred" are different questions, and both get asked constantly. A vector store has no native way to express the difference.
Note the vocabulary this requires: supersedes, contradicts, valid_from, valid_until. Typed edges again.
3.6 The Scoreboard, Honestly
Half the numbers in this field are vendor-reported on vendor-designed benchmarks. What follows separates independent evaluation from self-evaluation.
The framing question, from an ICLR'26 paper by Xiang et al. — "When to use Graphs in RAG" — is worth quoting because it starts from the skeptical position: despite GraphRAG's conceptual promise, recent studies report that it frequently underperforms vanilla RAG on many real-world tasks. The paper builds GraphRAG-Bench specifically to find out when graphs help, across fact retrieval, complex reasoning, contextual summarisation, and creative generation.
Where graphs win:
| Task type | Result |
|---|---|
| Multi-hop reasoning | Reported ~53% vs ~43% for vector RAG on GraphRAG-Bench |
| Temporal reasoning | The most lopsided margins in the field — graph-backed memory variants far ahead of flat memory on time-dependent questions |
| Corpus-wide synthesis | Reported ~64% vs ~51% |
Where graphs lose:
| Task type | Result |
|---|---|
| Simple fact lookup | Roughly a tie, with plain vector RAG marginally ahead. The graph adds redundant context and wins nothing |
| Query cost | Microsoft GraphRAG global search has been measured in the hundreds of thousands of tokens per query against vector RAG's high hundreds. Efficient graph systems exist — HippoRAG 2 operates around 1,000 tokens per query — but the naive configuration is brutal |
Two warnings that are worth more than the numbers:
- LightRAG posted large wins on its own benchmark, then collapsed under independent evaluation — reported at 6.6 average F1 versus 59.8 for HippoRAG 2. Never trust a system evaluated only by its authors.
- In Mem0's own paper, the graph variant lost to the non-graph variant on multi-hop questions. Graphs are a tool, not a doctrine. A graph built badly is worse than no graph.
On the specific percentages above: these are widely reported from GraphRAG-Bench and related evaluations, but I have verified the papers' framing and abstracts rather than the individual table cells. Treat the direction and magnitude as well-supported and any single figure as needing a look at the paper before you put it in a slide. Note also that two different papers are both called "GraphRAG-Bench" (Xiang et al. 2506.05690 and Xiao et al. 2506.02404) — check which one a citation means.
The consensus that survives all of this: route by question type. Vector for lookups, graph for chains. The 2026 trend line across serious systems points the same way — lazy indexing, agentic traversal where the agent decides which hops to take live, small controlled edge vocabularies, and honest routing.
Part IV — Meaning B: Orchestration Graphs
The second meaning. Here the graph is not made of knowledge — it is made of your system's control flow.
4.1 Loops vs. Graphs
Loop engineering was the June 2026 framing: you stop hand-writing prompts and start designing the cycle one agent runs.
flowchart LR
subgraph LOOP["A LOOP — one agent"]
L1[Discover] --> L2[Plan]
L2 --> L3[Execute]
L3 --> L4[Verify]
L4 -->|not done| L1
L4 -->|"stop condition"| L5[Done]
end
Four moving parts: a variable you care about, a target, a way to measure the gap, and an action that shrinks it — repeated. The insight of loop engineering was that the verifier, not the model, is the bottleneck. A loop is only as good as its exit test.
Graph engineering in this sense is the layer above: instead of one agent looping, you wire several, each with its own loop, connected by explicit typed transitions over shared state.
flowchart TD
S[("Shared state")]
P[Planner] --> R{Route}
R -->|"needs research"| RE[Researcher]
R -->|"needs code"| CO[Coder]
RE --> RV[Reviewer]
CO --> RV
RV -->|pass| DONE[Done]
RV -->|"fail: rework"| R
P -.-> S
RE -.-> S
CO -.-> S
RV -.-> S
CP{{"Checkpoint —<br/>resume, inspect,<br/>human-in-the-loop"}}
S -.-> CP
The elements that make it a graph rather than a pile of agents:
- Typed nodes — each node has a declared role and contract
- Typed transitions — edges encode when control moves and why, including conditional routing
- Shared state — one state object flows through, rather than each agent holding private context
- Checkpoints — the run can be paused, inspected, resumed, or handed to a human
The best line from the original thread, from Luis Catacora: "Loops are forgiving. Graphs force you to admit how much of the workflow you haven't actually modeled yet." That is the honest value proposition — the discipline is in the modelling, not the runtime.
4.2 The Prior Art Problem
The reply "congrats, you reinvented LangGraph" is mostly correct, and pretending otherwise is exactly the hype the skeptics are calling out.
| Tool | What it already does |
|---|---|
| LangGraph | Officially described as a low-level orchestration framework and runtime for long-running, stateful agents, built from a StateGraph of nodes and edges over shared state. Shipped well before the term |
| Temporal | Durable execution — workflows as code with retries, timers, and full replay. Not AI-specific, and older |
| Microsoft AutoGen | Multi-agent conversation patterns with explicit topologies |
| Google ADK | Agent Development Kit with composable agent graphs |
| Airflow / Dagster / Step Functions | DAG orchestration. Decades of prior art on the general shape |
Nothing in "orchestration graphs" is new as a capability. What changed is that enough people hit the ceiling of single-loop agents in the same quarter to give the pattern a name.
4.3 What Is Genuinely New
Being fair to the position, three things did shift:
- Loops became the default, so their limits became visible. In 2025 most agent systems were one prompt. In 2026 most are a loop. The failure modes of one loop at scale — context exhaustion, no parallelism, no partial recovery, unbounded blast radius — are now common experience rather than theory.
- Checkpointing and durable state became table stakes. Long-running agent work needs resumability. That pushes you toward explicit state graphs whether or not you use the word.
- The modelling discipline is the actual product. Drawing the graph forces you to name every handoff, every failure route, and every piece of state that crosses a boundary. Most teams discover they had not decided those things.
Where this touches your BMAD work: BMAD's Phase 4 is a loop — bmad-build clarifies, plans, implements, reviews. bmad-build-auto is that loop iterated over an ordered stories.yaml. That is loop engineering, sequential. The deep-recon Run mode is closer to a graph: a lead orchestrator fans out isolated subagents in parallel, then converges through verification. Same primitives, drawn explicitly. See 7.5.
The honest recommendation on Meaning B: master the loop first, and split it into a graph only when the work forces your hand. The signals that it's forcing your hand are concrete — you need genuine parallelism, you need to resume mid-run after a failure, you need different models for different steps, or you need a human approval gate in the middle. Absent those, a graph is added complexity.
Part V — Meaning C: Graphs of Loops
5.1 The Idea
The most abstract reading. Here the nodes are not agents or entities — they are feedback loops, and the edges encode how loops watch, constrain, and correct one another.
The motivating observation is real, and it comes from control theory rather than AI: a single feedback loop optimised hard enough degrades. Push any metric and it stops measuring what it used to — Goodhart's law. A loop optimising latency will find ways to be fast that nobody wanted. A loop optimising engagement will find engagement nobody is glad about.
The proposed remedy is a network of loops with explicit edges encoding trust, authority, and cadence — a quality loop constraining a speed loop, an audit loop sampling both, a policy loop that can override.
flowchart TD
Q["Quality loop"] -->|constrains| S["Speed loop"]
A["Audit loop"] -->|samples| Q
A -->|samples| S
P["Policy loop"] -->|"can override"| Q
P -->|"can override"| S
S -->|"reports to"| A
5.2 Why It Is Not Actionable Yet
- No tooling. No framework implements "loops watching loops" as a first-class construct
- No benchmarks. Nothing measures whether a graph of loops outperforms well-designed individual loops
- No agreed definition. Different writers mean noticeably different things
- The prior art is old and not obviously transferable. Cybernetics, control theory, and organisational design have decades on this. Whether those results carry over to LLM agents is an open question nobody has answered empirically
Worth reading for the framing, not worth building on. The useful, portable idea inside it is smaller and older: any single metric you optimise hard will eventually stop measuring what you wanted. You already handle this with guardrail metrics in A/B tests. That is a graph of loops with two nodes.
Part VI — Where Graph Projects Actually Die
If graphs win benchmarks, why isn't everyone running one? Because of a number almost nobody leads with.
6.1 Entity Resolution
Entity resolution is deciding that two mentions refer to the same node.
- "Dr. John Smith," "J. Smith," and "John" — one node or three?
- "Mercury" the planet and "Mercury" the element — one node or two?
- In your domain: Smith v. Jones (1998), Smith v Jones, "the Smith decision," and the neutral citation
[1998] UKHL 12— all one node - Across languages:
Cour de cassation,Kassationshof, "the French Court of Cassation" — one institution
Extraction pipelines get this wrong constantly. Every wrong merge creates a false edge; every missed merge splits a node and breaks chains that should connect.
This, not graph algorithms, is where the engineering budget goes. Graph traversal is a solved problem with decades of literature. Deciding what is the same thing is not.
6.2 The Compounding Math
Here is the number that should govern your architecture decisions.
Entity resolution errors compound multiplicatively over hops. If each hop is independently correct with probability p, an n-hop chain is correct with probability pⁿ.
| Per-hop accuracy | 2 hops | 3 hops | 5 hops |
|---|---|---|---|
| 99% | 98% | 97% | 95% |
| 95% | 90% | 86% | 77% |
| 90% | 81% | 73% | 59% |
| 85% | 72% | 61% | 44% |
flowchart LR
A["85% per-hop<br/>accuracy"] --> B["5-hop traversal"]
B --> C["<b>44% trustworthy</b><br/>Your impressive multi-hop<br/>chain is a coin flip"]
style C fill:#3d2a2a,color:#fff
Three consequences that should shape any design:
- Shallow beats deep. Two- and three-hop questions are where the value/risk ratio is good. Five-hop reasoning demos are marketing.
- Per-hop accuracy dominates everything else. Going from 85% to 95% per-hop nearly doubles five-hop trustworthiness. No amount of clever traversal recovers from bad nodes.
- Where entity resolution is already solved, graphs get dramatically cheaper. This is the practical escape hatch — see below.
Where it is already solved for you:
| Source | Why resolution is free |
|---|---|
| Neutral legal citations | [1998] UKHL 12 is a canonical identifier by construction |
| Explicit link markup | A wikilink or a hyperlink is an author-asserted edge to a specific target — no fuzzy merging |
| Foreign keys | Your existing databases already resolved these entities |
| DOIs, ISBNs, ticker symbols, SKUs | Canonical identifier systems |
| Your own document IDs | You control them |
The strategic read: build your first graph out of edges you already have, not edges you extract. Citation networks, document metadata, foreign keys, taxonomy assignments. Per-hop accuracy near 100%, index cost near zero, and you learn whether traversal helps before spending anything on extraction.
6.3 Staleness and Index Cost
The other two killers, more briefly:
- Staleness. A graph built once and never updated diverges from the corpus. Every re-index is a re-extraction, which is why full GraphRAG's index cost is a structural problem rather than a one-off. This is exactly what bi-temporal modelling (3.5) addresses — close intervals rather than rebuild.
- Index cost. Covered in 3.2 and 3.3. The short version: an LLM call per chunk does not scale, and Microsoft's own correction shows you mostly don't need it.
Part VII — Scenarios on Your Systems
Five concrete applications to multilingual legal search, recommendation, and agent work. Each states what a graph would actually buy, and what it would cost.
7.1 Legal Multi-Hop Retrieval
The question class: "Is the reasoning in this case still good law?" "What line of authority supports this proposition?" "Which subsequent decisions distinguished this holding?"
Why vector retrieval struggles. These are two- and three-hop questions whose answers live in the citation structure, not in any single document's text. A case that was overturned does not say so; the later case says so. Similarity search retrieves the original case and misses the overturning entirely, because the overturning decision is often about different facts and shares little vocabulary.
flowchart LR
Q["'Is Case A still good law?'"]
Q --> A(("Case A"))
A -->|relies_on| B(("Case B"))
B -->|overturned_by| C(("Case C"))
C -->|decided| D["2024"]
A -.->|"vector search<br/>returns this only"| A
style C fill:#3d2a2a,color:#fff
What makes this the strongest case in your stack: the edges already exist. Citation relationships are explicit in the documents and already extracted by legal publishers. Entity resolution is near-solved by neutral citation. You are not building a knowledge graph from scratch — you are traversing one you already have.
Cost: low, if you build on existing citation metadata. High, if you try to LLM-extract relationships from opinion text.
What to measure: on a set of "still good law" style questions, does adding a two-hop citation expansion to your existing hybrid retrieval change answer correctness? That is a bounded experiment.
7.2 Temporal Supersession
The question class: "What did this regulation require in 2021?" "Has this statutory provision been amended since the contract was signed?"
This is the sharpest fit for bi-temporal modelling in your domain, and it is a class of question a vector store cannot represent at all — not "does poorly on," but cannot represent.
Flat retrieval has two options when a fact changes: overwrite the old version, or keep both. Overwriting makes historical questions unanswerable. Keeping both means the retriever returns whichever chunk it happens to rank higher, and the model cites the stale one with full confidence.
flowchart TD
R["Regulation s.12"]
R --> E1["Edge: requires --> X<br/>valid 2019-01 to 2024-06"]
R --> E2["Edge: requires --> Y<br/>valid 2024-06 to present"]
Q1["'What applies today?'"] --> E2
Q2["'What applied when the<br/>contract was signed in 2022?'"] --> E1
style E1 fill:#3a3a2a,color:#fff
style E2 fill:#1f3d2a,color:#fff
What it buys: point-in-time answers, contradiction-free retrieval, and auditable provenance — which in a legal product is not a nice-to-have, it is close to a requirement.
Cost: you need valid-time metadata on your content. If commencement dates, amendment dates, and repeal dates are already in your document metadata, most of the work is done. If they aren't, that is the project.
7.3 Multilingual Entity Linking
The question class: an English query that should surface French and German source documents about the same entity.
Where a graph helps in a way embeddings don't. A shared multilingual embedding space tries to make Cour de cassation and "French Court of Cassation" land near each other in vector space — a fuzzy, probabilistic, per-query bet. A graph makes them the same node, once, at index time.
flowchart TD
N(("Node:<br/>Cour de cassation"))
N --- L1["surface form: 'Cour de cassation' (fr)"]
N --- L2["surface form: 'Kassationshof' (de)"]
N --- L3["surface form: 'French Court of Cassation' (en)"]
N -->|decided| C1(("Case 1 — fr"))
N -->|decided| C2(("Case 2 — fr"))
Q["English query mentioning<br/>the French Court of Cassation"] --> N
Once the query resolves to the node, every document connected to it is reachable regardless of language — no cross-lingual similarity bet required.
This is a genuine alternative framing of the shared-vs-per-language indexing decision. Instead of choosing between a shared embedding space and per-language indices, you can keep per-language indices and add a language-agnostic entity layer over them. Entity resolution does the cross-lingual work; embeddings stay monolingual, where they're strongest.
Cost: entity linking across languages is real work, though authority files, legal identifier systems, and existing taxonomies do much of it. Worth costing before assuming the embedding route is the only option.
7.4 Recommendation and Cold Start
Where graphs are relevant to recommenders — and where they are oversold.
Genuinely useful:
- Cold start via structure. A brand-new item has no interaction history, but it has edges — author, court, jurisdiction, practice area, cited-by. Those edges are available at ingest, before any user has seen it. This directly addresses the failure where new items get no impressions
- Explainability. "Recommended because it cites a case you read" is a traversal path. That is a much better explanation than "high cosine similarity," and in a professional product, explainability has real commercial value
- Multi-hop discovery. "Documents cited by documents your colleagues in this practice area read" is a two-hop query over the interaction graph
Where it is oversold:
- Collaborative filtering is already a graph algorithm. A user–item interaction matrix is a bipartite graph. Matrix factorisation, random walks, and PageRank-style propagation over it are decades old. Rebranding this as graph engineering adds nothing
- GNNs are a separate discipline with their own costs, and nothing in the July 2026 discourse is about them
- For the head of your traffic — popular items, obvious queries — graphs add latency and win nothing
The honest framing: for recommendation, "graph engineering" is mostly a new name for things your field already does. The genuinely new part is the retrieval side in 7.1–7.3, not the ranking side.
7.5 Agent Orchestration for Your BMAD Work
Mapping Meaning B onto something you already run.
| BMAD pattern | Graph reading |
|---|---|
bmad-build | A loop — clarify, plan, implement, review. One agent, one cycle |
bmad-build-auto | The same loop, iterated over an ordered stories.yaml. Sequential, not a graph |
bmad-deep-recon Run mode | Closest thing to a graph: a lead orchestrator fans out isolated subagents in parallel, digests converge to disk, verifiers run on landing, a red-team node attacks the conclusion |
| The plan gate | A checkpoint with human-in-the-loop approval |
| The research firewall | An edge constraint — it defines precisely what may cross from the lead node to the assistant nodes |
That last row is the interesting one. The research firewall is graph engineering in the orchestration sense, done well: it specifies what information is allowed to traverse a particular edge. Most agent systems have implicit edges where everything flows. Making the edge typed and constrained is exactly the discipline Meaning B is pointing at.
The practical takeaway: if you want to understand orchestration graphs, you already have a working reference implementation to read. references/run.md in deep-recon describes a fan-out/converge graph with typed edges and a checkpoint, without using any of the vocabulary.
Part VIII — A Concrete Evaluation Path
Concept done. This part is a decision procedure — four steps in increasing cost, each with a gate, plus explicit kill criteria. Total effort through Step 2 is roughly a week; Step 3 is a quarter.
flowchart TD
S0["<b>Step 0 — Question audit</b><br/>~1 day, no code<br/>What fraction of real traffic is multi-hop?"]
S0 --> G0{"Multi-hop or temporal<br/>share of questions<br/>that matter?"}
G0 -->|"under ~10%"| STOP1["<b>Stop.</b> Route better,<br/>improve reranking instead"]
G0 -->|"meaningful"| S1
S1["<b>Step 1 — Cheapest probe</b><br/>~2 days<br/>Hand-build 20 nodes from<br/>edges you already have"]
S1 --> G1{"Does traversal answer<br/>questions vector missed?"}
G1 -->|no| STOP2["<b>Stop.</b> The structure<br/>isn't carrying the answer"]
G1 -->|yes| S2
S2["<b>Step 2 — Routing baseline</b><br/>~3 days<br/>Classify query type,<br/>route to existing retrieval"]
S2 --> G2{"Does routing alone<br/>capture most of the gain?"}
G2 -->|yes| DONE["<b>Ship the router.</b><br/>You're done and it cost nothing"]
G2 -->|"gap remains"| S3
S3["<b>Step 3 — Bounded pilot</b><br/>~1 quarter<br/>LazyGraphRAG shape on one<br/>corpus slice, hybrid, measured"]
S3 --> G3{"Kill criteria met?"}
G3 -->|yes| STOP3["<b>Stop and write it up.</b>"]
G3 -->|no| SCALE["Scale deliberately"]
style STOP1 fill:#3d2a2a,color:#fff
style STOP2 fill:#3d2a2a,color:#fff
style STOP3 fill:#3d2a2a,color:#fff
style DONE fill:#1f3d2a,color:#fff
8.1 Step 0 — The Question Audit
Cost: about a day. No code. Do not skip this.
Everything in Part III says graphs win a specific slice of question types. So the first question is empirical and about your traffic, not about graphs.
Take 200 real queries from logs — sampled, not cherry-picked — and classify each:
| Class | Test | Graph value |
|---|---|---|
| Zero-hop lookup | The answer is in one document, findable by similarity | None. Graphs lose here |
| Two-hop | Answer requires connecting exactly two documents | High |
| Three-plus-hop | Requires a chain | High value, but see the compounding math |
| Temporal | "As of when" matters to the correct answer | Highest — flat retrieval can't represent it |
| Corpus-wide synthesis | "What are the themes across all X" | High |
| Aggregation | "How many," "list all" | Graphs help, but so would a database |
The gate. If multi-hop plus temporal is under roughly 10% of queries that matter — weight by business value, not volume — stop here. Your effort is better spent on reranking, query understanding, or routing. This is the most common honest outcome, and reaching it in a day is a good result.
A note on weighting. Volume and value diverge sharply in professional research tools. The 5% of queries that are multi-hop may be the ones where a user's alternative is billing three hours to an associate. Count both ways before deciding.
8.2 Step 1 — The Cheapest Possible Probe
Cost: about two days. No graph database. No LLM extraction. No new infrastructure.
The goal is to answer one question: does traversal reach answers that similarity misses, on my data?
The design:
- Take 20 questions from the multi-hop and temporal buckets in Step 0
- Build the relevant subgraph by hand or from metadata you already have — citations, foreign keys, document links, taxonomy assignments. A Python dict is a fine graph.
networkxif you want algorithms - Use only edges with near-100% accuracy. No extraction. This deliberately removes entity resolution as a variable
- For each question, compare: what does your current retrieval return, versus what does a two-hop expansion from the matched entity return?
What you are measuring: not answer quality yet — just reachability. Does the document containing the answer appear in the candidate set at all? A graph cannot help with documents your retrieval never surfaces, and it cannot help if similarity already surfaces them.
The gate. If two-hop expansion doesn't materially change the candidate set on questions vector retrieval got wrong, the structure isn't carrying the answer in your corpus. Stop.
Why this ordering matters. It isolates the one thing you actually want to know from the three things that make graph projects expensive — extraction, infrastructure, and entity resolution. Almost every failed graph project skipped this step and spent a quarter finding out.
8.3 Step 2 — The Routing Baseline
Cost: about three days. This is the step most teams skip, and it is often where the whole return lives.
Before building any graph, build the router that a graph system would need anyway.
flowchart LR
Q[Query] --> C{Classifier}
C -->|"zero-hop lookup"| V["Existing hybrid<br/>BM25 + dense"]
C -->|"temporal"| T["Existing retrieval<br/>+ date filter on metadata"]
C -->|"multi-hop"| M["Existing retrieval<br/>+ 2-hop citation expansion"]
C -->|synthesis| S["Existing retrieval<br/>+ wider k, rerank"]
Why this often captures most of the gain:
- A date filter over metadata you already have answers a large share of temporal questions without any bi-temporal graph
- A two-hop citation expansion over existing citation metadata is a join, not a knowledge graph
- Simply not running expensive retrieval on zero-hop queries improves latency and cost immediately
The gate. Measure the router against your current single-path retrieval. If routing plus cheap expansions closes most of the gap you found in Step 1, ship that and stop. You have captured the value of graph engineering without doing graph engineering, which is a legitimate and common outcome.
8.4 Step 3 — A Bounded Pilot
Cost: about a quarter. Only if Steps 0–2 left a real gap.
Six design commitments, each of which follows from something earlier in this document:
- One corpus slice, not the whole corpus. Pick the subdomain with the highest multi-hop share from Step 0
- LazyGraphRAG shape, not full GraphRAG. Cheap structural graph at index time, thinking deferred to query time (3.3). Index cost comparable to vector RAG
- Small controlled edge vocabulary. 10–20 typed verbs, defined up front, with inverses. Never freeform strings (1.2)
- Hybrid, never graph-only. The graph augments your existing retrieval. HippoRAG 2's lesson is that graph-only systems buy multi-hop performance with simple-lookup regressions (3.4)
- Bi-temporal from day one if temporal questions matter. Retrofitting valid-time onto an existing graph is painful; designing edges with validity intervals from the start is cheap (3.5)
- Instrument per-hop accuracy explicitly. Not just end-to-end quality. Given the compounding math (6.2), per-hop accuracy is the number that predicts whether this scales
What to measure — four metrics, and the second is the one that kills projects:
| Metric | Why |
|---|---|
| Answer quality on multi-hop and temporal questions | The reason you're doing this |
| Answer quality on simple lookups | The regression check. This is where most graph systems quietly lose |
| Query cost — tokens and latency, p50 and p99 | Graph query cost varies enormously by design |
| Index cost and re-index cadence | Staleness is a killer (6.3) |
On evaluation integrity: the two cautionary tales in 3.6 — LightRAG collapsing under independent evaluation, and Mem0's graph variant losing to its own non-graph variant — are both cases of a system evaluated by people who wanted it to win. Fix your question set and metrics before you build, hold out a test set, and have someone who didn't build it run the comparison. The discipline is the same one in the research-firewall appendix of the BMAD guide.
8.5 Kill Criteria
Write these down before starting Step 3, so the decision isn't made under sunk-cost pressure.
Kill the pilot if:
- Simple-lookup quality regresses more than a threshold you set in advance
- Per-hop accuracy lands below ~90% and you have no clear path to raise it — at 85%, three hops is already 61%
- Query cost exceeds the ceiling your product economics allow, at the quality level you need
- The re-index cadence your corpus requires costs more than the quality gain is worth
- Routing alone (Step 2) recovers most of the benefit at a fraction of the complexity
- You cannot articulate which specific question class improved. "Feels better" is the signature of a project that will not survive contact with a proper eval
A kill is a good outcome. You will have spent a quarter to learn something specific about your corpus, and you will be able to answer the question authoritatively for years. Compare that to the alternative: a graph pipeline in production that nobody can prove is earning its cost.
8.6 What to Skip Entirely
Based on everything above:
| Skip | Because |
|---|---|
| Full Microsoft GraphRAG at scale | Microsoft's own correction (LazyGraphRAG) reports comparable quality at 0.1% of index cost. Read GraphRAG for the architecture, build the lazy shape |
| Graph-only retrieval | Every serious result says hybrid. Graph-only regresses on the questions that make up most of your traffic |
| Freeform edge types | Barely better than untyped, and they don't compose. Small controlled vocabulary or nothing |
| Five-plus-hop reasoning | 85% per-hop makes it a coin flip. Demos, not products |
| "Graphs of loops" | No tooling, no benchmarks, no agreed definition. Read the framing, build nothing |
| Rebranding your recommender | Collaborative filtering is already a graph algorithm. New name, no new capability |
| Any vendor number you haven't traced to a paper | Half the figures in this field are self-reported. One of the most-cited studies in the discourse does not exist |
| A graph database, at first | You do not need Neo4j to test whether traversal helps. You need a dict and two days |
Appendix — Glossary and Sources
Glossary
| Term | Definition |
|---|---|
| Node | A thing you know about — an entity, document, decision, or event |
| Edge | A connection between two nodes |
| Typed edge | An edge naming the relationship: supersedes, cites, caused |
| Untyped edge | An edge that only asserts "related" — one bit of information |
| Hop | One step along one edge |
| Multi-hop question | A question whose answer requires following more than one edge |
| Traversal | Walking from node to node along edges |
| Entity resolution | Deciding that two mentions refer to the same node. The hard part |
| Knowledge graph | A graph of entities and typed relationships used as a knowledge store |
| GraphRAG | Retrieval-augmented generation where retrieval uses a graph |
| Community detection | Clustering a graph into neighbourhoods of related nodes |
| Personalized PageRank | Importance propagation from a seed set — how HippoRAG ranks |
| Bi-temporal | Tracking two timelines per fact: when it was true, and when the system learned it |
| Valid time | When a fact was true in the world |
| Transaction time | When the system recorded the fact |
| Supersession | Closing a fact's validity interval and opening a replacement, rather than deleting |
| Relevance test budget | LazyGraphRAG's single cost/quality dial — how many LLM relevance checks per query |
| Lazy indexing | Building a cheap structural graph at index time, deferring expensive reasoning to query time |
| Agentic traversal | The agent deciding which hops to take at query time, rather than a fixed traversal policy |
| StateGraph | LangGraph's construct: nodes and edges operating over shared state |
| Checkpoint | A durable point in an orchestration graph allowing pause, inspection, resume |
| Goodhart's law | A measure that becomes a target ceases to be a good measure |
Primary sources
Papers — the ones worth reading directly:
- "When to use Graphs in RAG: A Comprehensive Analysis for Graph Retrieval-Augmented Generation" — Xiang, Wu, Zhang, Chen, Hong, Huang, Su. arXiv:2506.05690, ICLR'26. The honest scoreboard: where graphs win and lose by task type. Starts from the observation that GraphRAG frequently underperforms vanilla RAG
- "From RAG to Memory: Non-Parametric Continual Learning for Large Language Models" (HippoRAG 2) — Gutiérrez, Shu, Qi, Zhou, Su. arXiv:2502.14802. Personalized PageRank plus deeper passage integration; the key result is improving multi-hop without regressing on simple factual retrieval
- "HippoRAG: Neurobiologically Inspired Long-Term Memory for Large Language Models" — arXiv:2405.14831, NeurIPS'24. The predecessor
- "Zep: A Temporal Knowledge Graph Architecture for Agent Memory" — Rasmussen, Paliychuk, Beauvais, Ryan, Chalef. arXiv:2501.13956. The bi-temporal model; DMR 94.8% vs MemGPT 93.4%, plus LongMemEval
- Note: a second, different paper is also called GraphRAG-Bench (Xiao et al., arXiv:2506.02404). Check which one a citation means
Vendor and framework material — useful, self-reported:
- LazyGraphRAG: Setting a new standard for quality and cost — Microsoft Research, Nov 2024. Index cost identical to vector RAG and 0.1% of full GraphRAG; >700× lower query cost at comparable global-query quality
- Graphiti — open-source temporal knowledge graph engine behind Zep, ~20k GitHub stars
- LangGraph — LangChain's orchestration framework and runtime; StateGraph of nodes and edges over shared state
Origin of the term:
- Josh Simmons, "We are entering the graph engineering phase" — July 4, 2026, earliest documented use
- Peter Steinberger's July 18, 2026 post — the twelve words that made it viral
- Carlos Perez's essay, ~10 hours later — the first serious expansion
Confidence notes
Stated plainly, since this document mixes source types:
- High confidence: the primitive (Part I), the three-way split of the term, the timeline, the qualitative finding that graphs win multi-hop/temporal/synthesis and lose simple-lookup/cost, the entity-resolution compounding math (that one is arithmetic), the evaluation procedure in Part VIII
- Medium confidence: specific benchmark percentages in 3.6. I verified the papers' framing, abstracts, and existence rather than individual table cells. Check the paper before quoting a number in a decision document
- Vendor-reported, directionally supported: LazyGraphRAG's 0.1% and 700× multipliers, Zep's DMR margin. Self-evaluated, though the LazyGraphRAG direction is corroborated by the field's move toward lazy indexing
- Explicitly corrected: the "9.5 F1 on 2WikiMultiHopQA" figure circulating for HippoRAG 2 is not in the abstract or OpenReview record — the paper's own headline is a 7% associative-memory improvement. The "$3.1M Stanford and Anthropic study" cited in some posts does not exist
- My analysis, not sourced: everything in Part VII, the four-step evaluation path, the kill criteria, and the skip list. These are judgment calls built on the sourced material, and reasonable people could sequence them differently
Currency: this field is moving fast and the term is roughly five weeks old as of August 2026. Framework specifics and benchmark leaderboards will drift. The primitive in Part I and the failure modes in Part VI will not.
Source: bmad_e2e ·
bmad_e2e.md· updated 2026-08-02 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
BMAD-METHOD v6 — End-to-End Guide
A working reference for every BMAD skill: what it does, when to reach for it, what prompt to type, what comes back, and how it fits the pipeline.
Sourced from the official v6 docs (docs.bmad-method.org) — Agents, Skills, Core Tools, and Workflow Map references — plus notes on where the docs are internally inconsistent.
Table of Contents
- Mental Model
- The Full Map
- Core Module — The Eight Always-Installed Skills
- 3.1
bmad-help - 3.2
bmad-advanced-elicitation - 3.3
bmad-review - 3.4
bmad-customize - 3.5
bmad-brainstorming - 3.6
bmad-deep-recon - 3.7
bmad-forge-idea - 3.8
bmad-party-mode - 3.9 Choosing Between the Four Thinking Skills
- 3.1
- BMM Agents
- Phase 1 — Analysis
- Phase 2 — Planning
- Phase 3 — Solutioning
- Phase 4 — Implementation
- The Four Entry Paths
- Artifact Flow
- Worked Example — Multilingual Search Feature
- Deprecations, Renames, and Gotchas
- Cheat Sheet
- Appendix A — The Research Firewall
- Appendix B — Why the Firewall Works: Vocabulary, Philosophy, and Scenarios
- B.1 Start Here — One Idea You Already Use
- B.2 The Vocabulary
- B.3 Discovery vs. Justification
- B.4 Why Human Brains Need This
- B.5 How Other Fields Solved It
- B.6 The Machine-Learning Version You Already Know
- B.7 Where BMAD Sits in That Lineage
- B.8 Six Scenarios on a Recommendation System
- B.9 What the Firewall Does Not Fix
- B.10 Glossary and Sources
1. Mental Model
BMAD's premise is context engineering: AI agents make inconsistent decisions when they lack structured context, so each phase produces documents that become the input context for the next. The PRD tells the architect which constraints matter; the architecture tells the dev agent which patterns to follow; spec files give focused, complete context for implementation.
The framework is scale-adaptive. There is no single mandatory sequence — a one-line bug fix and a greenfield platform both run through BMAD, but they touch very different numbers of skills.
1.1 Skills vs. Agent Menu Triggers
Two ways to start work. They are not alternatives to each other so much as two ergonomics for the same underlying workflow files.
| Mechanism | How you invoke | What happens |
|---|---|---|
| Skill | Type the skill name (bmad-prd) in your IDE | Directly loads an agent, runs a workflow, or executes a task |
| Agent menu trigger | Load an agent first, then type a short code (PRD) | The agent interprets the code and starts the matching workflow while staying in character |
flowchart TD
A[You want to do something] --> B{Do you know which<br/>workflow you want?}
B -->|Yes| C["Type the skill name<br/><code>bmad-prd</code>"]
B -->|"No — already mid-conversation<br/>with an agent"| D["Type the trigger code<br/><code>PRD</code>"]
C --> E[Workflow runs directly<br/>no persona loaded]
D --> F[Agent starts the same workflow<br/>persona and context preserved]
E --> G[Same underlying workflow file]
F --> G
Practical rule: use skills when you know the destination. Use triggers when you're already deep in a session with an agent and don't want to lose the conversational context by switching.
1.2 Where Skills Live
The installer writes one skill directory per agent, workflow, task, and tool. Each directory holds a SKILL.md that instructs the AI to load the corresponding source file.
| IDE / CLI | Skills directory |
|---|---|
| Claude Code | .claude/skills/ |
| Cursor | .agents/skills/ |
| Windsurf | .agents/skills/ |
| Other (42 platforms supported as of v6.2+) | See installer output |
.claude/skills/
├── bmad-help/
│ └── SKILL.md
├── bmad-prd/
│ └── SKILL.md
├── bmad-agent-dev/
│ └── SKILL.md
└── ...
The directory name is the skill name. bmad-agent-dev/ registers the skill bmad-agent-dev.
1.3 Install and Discovery
# Standard install
npx bmad-method install
# Prerelease (higher churn)
npx bmad-method@next install
# Non-interactive config override, repeatable
npx bmad-method install --set bmm.output_folder=_bmad-output
# See what options a module exposes
npx bmad-method install --list-options bmm
The canonical list of what you actually have installed:
ls .claude/skills/ | grep bmad
Everything in this guide is the v6 default set. Your installation only contains skills for the modules you selected. If a skill in this document doesn't exist for you, re-run the installer and check your module selection.
2. The Full Map
flowchart TD
subgraph CORE["CORE MODULE — available in every phase, always"]
H["<b>bmad-help</b><br/>what do I do next"]
AE["<b>bmad-advanced-elicitation</b><br/>refine recent output"]
RV["<b>bmad-review</b><br/>multi-lens review"]
CU["<b>bmad-customize</b><br/>TOML overrides"]
end
subgraph P1["PHASE 1 — ANALYSIS (optional)"]
BS["bmad-brainstorming"]
FI["bmad-forge-idea"]
DR["bmad-deep-recon"]
PB["bmad-product-brief"]
PF["bmad-prfaq"]
end
subgraph P2["PHASE 2 — PLANNING"]
PRD["bmad-prd"]
UX["bmad-ux"]
SP["bmad-spec"]
end
subgraph P3["PHASE 3 — SOLUTIONING"]
AR["bmad-architecture"]
PC["bmad-generate-project-context"]
ES["bmad-create-epics-and-stories"]
IR["bmad-check-implementation-readiness"]
end
subgraph P4["PHASE 4 — IMPLEMENTATION"]
SPL["bmad-sprint-planning"]
BD["<b>bmad-build</b><br/>convergence point"]
BA["bmad-build-auto"]
CR["bmad-code-review"]
SS["bmad-sprint-status"]
CC["bmad-correct-course"]
RT["bmad-retrospective"]
end
BS --> PB
FI --> PB
DR --> PB
PB --> PRD
PF --> PRD
PRD --> UX
PRD --> SP
UX --> AR
SP --> AR
AR --> PC
PC --> ES
ES --> IR
IR --> SPL
SPL --> BD
SP -.->|"stories.yaml"| BA
BD --> CR
BD --> SS
SS --> CC
SS --> RT
BA --> BD
PRD -.->|"skip everything —<br/>small scoped work"| BD
The dotted line matters: clear, well-scoped work enters bmad-build directly. Phases 1–3 add context; they do not select a different implementation workflow. Skipping them is intended behavior, not a shortcut.
3. Core Module — The Eight Always-Installed Skills
Four kernel tools plus four thinking skills. No agent session required — type the skill name and it runs.
Doc inconsistency: the Core Tools page prose says "seven core skills" while its own tables list eight (four kernel + four thinking). The tables are correct.
3.1 bmad-help
Your intelligent guide to what comes next. Inspects project state, detects what's been done, and recommends the next required or optional step.
How it works
flowchart LR
A["bmad-help<br/>+ optional NL query"] --> B[Scan project for artifacts<br/>PRD, architecture, stories]
B --> C[Detect installed modules<br/>and their workflows]
C --> D[Rank next steps<br/>required first, then optional]
D --> E[Output: prioritized list<br/>each with skill command]
Prompt examples
bmad-help
bmad-help I have a SaaS idea and know all the features. Where do I start?
bmad-help What are my options for UX design?
bmad-help I inherited a Django monolith with no docs. What's my entry point?
Expected output
A prioritized list, something like:
Detected: no PRD, no architecture, no stories. BMM + CIS installed.
REQUIRED NEXT
1. bmad-prd — You have a clear feature set; go straight to
Create mode. Produces prd.md + addendum.md.
OPTIONAL — worth considering first
2. bmad-product-brief — If the strategic framing isn't settled,
this feeds prd.md and reduces re-explanation.
3. bmad-deep-recon — If competitive positioning is uncertain.
SKIP FOR NOW
- bmad-brainstorming — You said you know the features.
Why it matters more than the phase list: bmad-help reads the actual files on disk. Following a static sequence blindly produces documents you don't need. bmad-help evolves as you install modules, so it also surfaces third-party module capabilities the docs don't cover.
3.2 bmad-advanced-elicitation
Push the LLM to reconsider, refine, and improve its recent output. BMad's shared refinement checkpoint — other skills invoke it at natural pauses, and you can call it directly.
How it works
flowchart TD
A[Target the most recent output<br/>unless you point it elsewhere] --> B[Offer a short menu of<br/>best-fit elicitation methods]
B --> C{You choose<br/>one or more}
C --> D[Apply methods against the target]
D --> E[Hand back the improved version]
E --> F[Invoking flow resumes<br/>where it paused]
Prompt examples
bmad-advanced-elicitation
bmad-advanced-elicitation run a pre-mortem on that architecture section
bmad-advanced-elicitation red team the failure modes in the retry logic above
bmad-advanced-elicitation apply first principles to the caching assumption
Named methods you can request directly: Socratic, first principles, pre-mortem, red team.
Expected output
A method menu, then the enhanced content:
Best-fit methods for this architecture section:
1. Pre-mortem — assume it failed in prod; work backwards
2. Red team — attack the trust boundaries
3. First principles — rebuild the caching decision from constraints
4. Socratic — interrogate the unstated assumptions
> 1
PRE-MORTEM — assuming this shipped and failed within 90 days:
...
REVISED SECTION:
...
Use it when: output feels shallow or generic, you're finalizing something critical, or you want a specific analytical lens by name.
3.3 bmad-review
Multi-lens review over any diff, doc, or artifact. Each lens is a distinct method and stance. Every finding reports in one canonical shape. Zero findings is a valid outcome — it never pads to look thorough.
The shipped lenses
| Lens | Applies to | Method |
|---|---|---|
| Adversarial | Anything | Skeptical review that assumes problems exist — hunts what's missing, not just what's wrong |
| Edge case | Anything | Walks every branching path and boundary condition in content that defines behavior |
| Verification gap | Code | Finds changed behavior that could regress without reliable verification catching it |
| Structure | Documents | Proposes cuts, merges, moves, condensing — does the shape serve the purpose? |
| Prose | Documents | Copy-edits for issues that impede comprehension |
The two editorial lenses hold your content sacrosanct: they never challenge your ideas, only how they're organized and expressed, and they propose rather than execute. Prose runs on top of structure findings when both are selected.
How it works
flowchart TD
A[Load content] --> B[Identify type: diff / file /<br/>function / document — code or docs]
B --> C{Lenses specified?}
C -->|Yes| D[Run named lenses]
C -->|No| E[Every enabled lens whose<br/>applicability + conditions fit]
D --> F[Announce the plan]
E --> F
F --> G[Run independent lenses in parallel<br/>via subagents where supported]
G --> H[Run dependent lenses on top<br/>e.g. prose over structure]
H --> I[Assemble one findings array<br/>overlap = signal, not duplication]
Inputs
| Param | Required | Notes |
|---|---|---|
content | Yes | Diff, branch, uncommitted changes, file, spec, story, or any document |
lenses | No | Lens codes or names; default is every fitting lens |
also_consider | No | Additional areas to keep in mind |
style_guide | No | Editorial lenses only |
reader_type | No | humans (default, clarity/flow) or llm (precision/consistency) |
Prompt examples
bmad-review my uncommitted changes
bmad-review the diff on branch feat/hnsw-tuning, verification gap lens only
bmad-review docs/architecture.md with structure and prose, reader_type llm
bmad-review src/reranker.py — also_consider: multilingual tokenization edge cases
Expected output
JSON findings array where each finding carries lens, location, trigger_condition, guard_snippet, potential_consequence — and/or a markdown report grouped by lens. Editorial lenses render a findings table you accept or reject row by row, plus an estimated reduction when structural changes are proposed.
[
{
"lens": "verification-gap",
"location": "src/reranker.py:88-104",
"trigger_condition": "Query locale differs from index locale and fallback path executes",
"guard_snippet": "assert scores.shape[0] == len(candidates)",
"potential_consequence": "Silent truncation of the candidate set; recall drop invisible to existing tests"
}
]
Note: you rarely invoke this manually in a full flow. Code review workflows in other modules run the code lenses automatically, and the document workflows (PRD, UX, architecture, product brief) run the editorial lenses as their finalize step. Custom lenses can be added — and shipped ones tuned or disabled — through the skill's customize.toml.
3.4 bmad-customize
Create and verify customization overrides. Change how an installed agent or workflow behaves without hand-authoring TOML.
How it works
flowchart LR
A[Natural language<br/>description of change] --> B[Scan installed BMad skills<br/>for customizable surfaces]
B --> C[Select the right override scope]
C --> D["Write override files<br/>under <code>_bmad/custom/</code>"]
D --> E[Verify merged configuration]
Prompt examples
bmad-customize make the dev agent always run our lint config before declaring a story done
bmad-customize add a persistent fact: our OpenSearch cluster is 2.13, no k-NN nmslib engine
bmad-customize disable the prose lens in bmad-review for this project
bmad-customize add a menu item to the architect agent for ADR generation
Expected output
TOML override files under _bmad/custom/, plus a verification pass on the merged config.
What you can override: persistent facts, activation hooks, custom menu items, agent memory/prompts (via *.customize.yaml merging), review lenses, and any module config option.
3.5 bmad-brainstorming
Generate diverse ideas through interactive creative techniques. A facilitated session that loads proven ideation methods from a technique library.
How it works
flowchart TD
A[Topic or problem statement<br/>+ optional context file] --> B[Set up session]
B --> C[Load technique from method library<br/>SCAMPER, reverse brainstorming, etc.]
C --> D[Generate ideas]
D --> E{10 ideas since<br/>last shift?}
E -->|Yes| F["Anti-bias protocol:<br/>shift creative domain"]
F --> C
E -->|No| G{100+ ideas?}
G -->|No| D
G -->|Yes| H[Organize by technique]
H --> I["brainstorm.html keepsake<br/>+ optional brainstorm-intent.md<br/>+ .memlog.md"]
Prompt examples
bmad-brainstorming
bmad-brainstorming ways to cut cold-start latency in a multilingual recommender
bmad-brainstorming onboarding for a legal research tool — context: docs/personas.md
Expected output
brainstorm.html— self-contained keepsake of the sessionbrainstorm-intent.md— optional, for downstream skills to consume.memlog.md— session record
The quantity target is the point. The workflow pushes for 100+ ideas because, in BMAD's framing, the useful material appears in ideas 50–100 — after you've exhausted the obvious. The anti-bias protocol exists to stop you clustering in one creative domain.
3.6 bmad-deep-recon
Decision-grade research on any subject, three ways. The only core skill that goes outside the model's memory.
Absorbed the former bmad-market-research, bmad-domain-research, and bmad-technical-research workflows as research types. Old IDs still forward.
The three modes
flowchart TD
A[Your ask] --> B[Detect mode + infer research type]
B --> C{Mode}
C -->|DRAFT| D["Write a deep-research prompt<br/>for your own tool<br/>(ChatGPT DR, Gemini DR, Perplexity)"]
D --> D2[You run it there<br/>on your flat-rate subscription]
D2 --> E
C -->|PROCESS| E["Ingest a finished report<br/>→ cited summary downstream<br/>skills consume directly"]
C -->|RUN| F[Plan at one gate]
F --> G[Fan out firewalled<br/>research assistants in parallel]
G --> H[Verify claims as they land]
H --> I
E --> I["research.md with metadata frontmatter<br/>+ optional self-contained HTML briefing"]
I --> J{Existing report?}
J -->|REFRESH| K[Update without re-researching]
J -->|DEEPEN| K
Six typed research packs. Each loads its own prioritized dimensions, source craft, and freshness rules: market, domain, technical, competitive, user-voice, literature.
Prompt examples
bmad-deep-recon
bmad-deep-recon draft mode — HNSW vs IVF-PQ tradeoffs for 50M multilingual
embeddings under a 40ms p99 budget
bmad-deep-recon process this report: ~/Downloads/gemini-dr-vector-db.md
bmad-deep-recon run — competitive research on legal research AI tools,
decision: whether to build or license citation extraction
bmad-deep-recon refresh docs/research/vector-db-2026-05/
Expected output
research.mdwith metadata frontmatter and inline citations- Optional self-contained HTML briefing
Mode selection — cost and fidelity tradeoff:
| Mode | Token cost | Use when |
|---|---|---|
| Draft | Lowest | You have a flat-rate deep-research subscription; planning shouldn't burn metered IDE tokens |
| Process | Low | You already have a report from any source and want it distilled into the format downstream skills consume |
| Run | Highest | You want it done in-place, verified as claims land, without leaving the IDE |
Use it when: a decision should rest on evidence instead of the model's memory, or you're choosing between named options and want a structured comparison.
The phrase "firewalled research assistants" in the Run-mode description refers to the research firewall — the trust mechanism that keeps your own project documents out of the evidence chain. See Appendix A.
3.7 bmad-forge-idea
Pressure-test an idea until it hardens, proves out, or dies cheaply. An adversarial interrogator drives a half-formed idea one question at a time.
How it works
flowchart TD
A[The idea — any domain] --> B[Establish the goal up front<br/>steer questioning to match]
B --> C[One question at a time<br/>in dependency order]
C --> D[Put a recommended answer<br/>on the table to push against]
D --> E[Two voices per branch:<br/>one from your installed roster,<br/>one conjured by the topic]
E --> F[Challenge fuzzy terms<br/>test claims against project material]
F --> G{More branches?}
G -->|Yes| C
G -->|No| H{Verdict}
H -->|HARDENED| I["forged-idea.md distillate<br/>+ forge-report.html"]
H -->|KILLED| J[forge-report.html]
H -->|CLEARER| J
Prompt examples
bmad-forge-idea
bmad-forge-idea we should replace our BM25 fallback entirely with dense retrieval
bmad-forge-idea building an internal eval harness rather than adopting one —
goal: decide in one session, I'm biased toward building
bmad-forge-idea leaving my job to do this full time
Expected output
forge-report.html— self-contained keepsake, every runforged-idea.md— distillate, only when the idea hardens (and optional even then)
Three landing states: Hardened, Killed, or Clearer.
The design intent is that killing is a success. Stating your bias up front ("I'm biased toward building") gives the interrogator something specific to attack. The two-voice mechanic — one persona from your installed agent roster, one conjured by the topic itself — is what keeps it from collapsing into agreement.
3.8 bmad-party-mode
Orchestrate multi-agent group discussions. Loads all installed BMad agents and facilitates a conversation where each contributes from their expertise and personality.
How it works
flowchart TD
A[Topic or question<br/>+ optional persona list] --> B[Load agent manifest<br/>all installed personalities]
B --> C[Analyze topic → select<br/>2-3 most relevant agents]
C --> D[Agents take turns<br/>natural cross-talk and disagreement]
D --> E[Rotate participation for<br/>diverse perspectives over time]
E --> F{"goodbye / end party / quit"}
F -->|No| D
F -->|Yes| G[Session ends —<br/>nothing persisted]
Prompt examples
bmad-party-mode
bmad-party-mode should we ship the reranker behind a feature flag or
gate it on the A/B result?
bmad-party-mode our search latency SLO — I want Winston and Amelia specifically
Expected output
Real-time multi-agent conversation with maintained personalities. No artifact is written. Exit with goodbye, end party, or quit.
Use it when: you need multiple expert perspectives, want agents to challenge each other's assumptions, or you're exploring something spanning multiple domains. It is exploratory and pre-artifact — if you want findings you can act on, use bmad-review or bmad-forge-idea instead.
3.9 Choosing Between the Four Thinking Skills
The four are easy to confuse. They differ on where the input comes from and what shape the output takes.
flowchart TD
Q{What's the bottleneck?}
Q -->|"I don't know the facts"| DR["<b>bmad-deep-recon</b><br/>external evidence<br/>→ cited research.md"]
Q -->|"I have no options"| BS["<b>bmad-brainstorming</b><br/>divergence, many→<br/>→ brainstorm.html"]
Q -->|"I have one idea and<br/>I'm too attached to it"| FI["<b>bmad-forge-idea</b><br/>convergence, →one<br/>→ verdict + report"]
Q -->|"I'm only seeing this<br/>from one angle"| PM["<b>bmad-party-mode</b><br/>breadth of perspective<br/>→ no artifact"]
| Input source | Direction | Output | Terminal? | |
|---|---|---|---|---|
bmad-deep-recon | Outside the model — web, or your report | Gathering | research.md, cited | Yes |
bmad-brainstorming | Your head, facilitated | One → many | brainstorm.html | No |
bmad-forge-idea | One existing idea | Many → one | Verdict + report | Yes |
bmad-party-mode | Agent personas | Lateral | Conversation only | No |
The key distinctions:
- Deep-recon vs. the other three — facts vs. thinking. Deep-recon is the only one that can tell you something you didn't already know.
- Brainstorming vs. forge-idea — opposite directions on the same axis. Brainstorming expands, forge contracts.
- Forge-idea vs. party-mode — forge is adversarial and terminal (produces a verdict). Party is exploratory and unstructured (produces discussion). Forge also brings a voice conjured by the topic, not just your installed roster.
- Party-mode vs.
bmad-review— party is speculative and pre-artifact. Review runs structured lenses over something you've already written.
They compose. A realistic sequence: bmad-brainstorming (get options) → bmad-deep-recon (check which are real) → bmad-forge-idea (kill the weak survivors) → bmad-product-brief (write up what's left).
4. BMM Agents
The default BMM (Agile suite) agents installed with BMad Method. Each agent is available as a skill, generated by the installer. The skill ID invokes the agent; triggers are the short menu codes and fuzzy matches shown in each agent's menu.
| Agent | Skill ID | Triggers | Primary workflows |
|---|---|---|---|
| Analyst (Mary) | bmad-agent-analyst | BP MR DR TR CB WB DP | Brainstorm, Market Research, Domain Research, Technical Research, Create Brief, PRFAQ Challenge, Document Project |
| Product Manager (John) | bmad-agent-pm | PRD CE IR CC | Create/Update/Validate PRD, Create Epics and Stories, Implementation Readiness, Correct Course |
| Architect (Winston) | bmad-agent-architect | CA IR | Create Architecture, Implementation Readiness |
| Developer (Amelia) | bmad-agent-dev | BD QA CR SP ER | Build, QA Test Generation, Code Review, Sprint Planning, Epic Retrospective |
| UX Designer (Sally) | bmad-agent-ux-designer | CU | Create UX Design |
Trigger decode:
| Code | Workflow |
|---|---|
BP | Brainstorm Project |
MR / DR / TR | Market / Domain / Technical Research (now routed into bmad-deep-recon) |
CB | Create Brief |
WB | Working Backwards (PRFAQ) |
DP | Document Project |
PRD | Create, update, or validate PRD |
CE | Create Epics and Stories |
IR | Implementation Readiness |
CC | Correct Course |
CA | Create Architecture |
BD | Build |
QA | QA Test Generation |
CR | Code Review |
SP | Sprint Planning |
CU | Create UX Design |
ER | Epic Retrospective |
How a trigger session looks
sequenceDiagram
participant You
participant Skill as bmad-agent-pm
participant John as John (persona)
participant WF as Workflow file
You->>Skill: bmad-agent-pm
Skill->>John: Load persona + activate menu
John->>You: Menu: PRD, CE, IR, CC
You->>John: PRD
John->>WF: Load PRD workflow config
WF->>John: Steps + prompts
John->>You: Step 1 - in character, asking for input
You->>John: (answers)
Note over John,WF: ...steps continue...
John->>You: prd.md written. Next: CE?
You->>John: CE
Note over John: Same session, no context loss
Prompt examples
bmad-agent-pm
bmad-agent-dev
Then within the session:
BD
CR
Notes
- QA test generation is handled by the
bmad-qa-generate-e2e-testsworkflow skill, available through the Developer agent. The full Test Architect (TEA) lives in its own module. - The Technical Writer (Paige) is on hiatus - she will return more capable. Project documentation lives on via the
DP(Document Project) trigger through the Analyst, or by invokingbmad-document-projectdirectly. - Optional modules (BMB, CIS, TEA, Game Dev Studio) add their own agents and skills only if selected at install.
5. Phase 1 - Analysis
Explore the problem space and validate ideas before committing to planning. All Phase 1 steps are optional. They feed context into the PRD.
flowchart LR
A["Vague idea"] --> BS[bmad-brainstorming]
BS --> FI[bmad-forge-idea]
A --> FI
FI -->|Killed| X["Stop - you saved<br/>weeks of work"]
FI -->|Hardened / Clearer| DR[bmad-deep-recon]
A --> DR
DR --> PB[bmad-product-brief]
PB --> PF[bmad-prfaq]
PB --> P2["to Phase 2"]
PF --> P2
| Workflow | Purpose | Produces |
|---|---|---|
bmad-brainstorming | Guided facilitation of a brainstorming coach | brainstorm.html keepsake + optional brainstorm-intent.md |
bmad-forge-idea | Pressure-test until it hardens, proves out, or dies cheaply | forge-report.html every run; forged-idea.md when hardened |
bmad-deep-recon | Research any subject for a decision - six typed packs, verified and cited | Research report or summary + optional HTML briefing |
bmad-product-brief | Capture strategic vision - best when your concept is clear | brief.md + addendum.md, plus optional HTML or presentation output |
bmad-prfaq | Working Backwards - stress-test the concept customer-first | prfaq-{project}.md |
bmad-product-brief
Prompt examples
bmad-product-brief
bmad-product-brief a cross-language document retrieval layer for legal
research - source it from docs/research/multilingual-2026-07/research.md
Expected output
_bmad-output/
├── brief.md <- strategic vision, problem, audience, success
├── addendum.md <- supporting detail that would bloat the brief
└── .memlog.md <- session record
Plus any HTML or presentation hydration you request.
Downstream link: bmad-prd can source-extract from product-brief.md during Discovery, which reduces re-explanation and keeps the two documents aligned. Neither skill requires the other - start with bmad-prd directly if you already know what you're building.
bmad-prfaq
Working Backwards - the press-release-first exercise. Stress-tests the concept from the customer's side before you've built anything.
Prompt examples
bmad-prfaq
bmad-prfaq write the launch announcement for the multilingual search feature
as if it shipped, then interrogate the gap
Expected output: prfaq-{project}.md
Also available as the WB trigger on the Analyst agent.
6. Phase 2 - Planning
Define what to build and for whom.
flowchart TD
A["brief.md / prfaq / raw intent"] --> PRD[bmad-prd]
PRD --> I{Which intent?}
I -->|Create| C["prd.md + addendum.md + .memlog.md<br/>coached discovery from scratch"]
I -->|Update| U["Reconcile with a change signal<br/>surfacing conflicts before applying"]
I -->|Validate| V["validation-report.html + .md<br/>critique against configurable checklist"]
C --> UX[bmad-ux]
C --> SP[bmad-spec]
U --> SP
UX --> SP
SP --> S["SPEC.md + companions<br/>optional stories.yaml"]
S --> P3["to Phase 3"]
| Workflow | Purpose | Produces |
|---|---|---|
bmad-prd | Create, update, or validate a PRD - three intents in one skill | Create/Update: prd.md, addendum.md, .memlog.md; Validate: validation-report.html + .md |
bmad-ux | Design user experience (when UX matters) | DESIGN.md (visual) + EXPERIENCE.md (behavioral) spine pair, .memlog.md |
bmad-spec | Distill any intent input into a succinct SPEC.md contract + companions - locks the WHAT before the HOW | SPEC.md + companions under {output_folder}/specs/spec-{slug}/; optional stories.yaml |
bmad-prd - three intents in one skill
State your intent when invoking, or the skill will ask.
Prompt examples
bmad-prd
bmad-prd create - cross-language retrieval for legal research.
Source from _bmad-output/brief.md
bmad-prd update - legal changed the data residency requirement to
EU-only storage for EU tenants
bmad-prd validate
Expected output by intent
| Intent | Behavior | Output |
|---|---|---|
| Create | New PRD from scratch via coached discovery | prd.md, addendum.md, .memlog.md |
| Update | Reconcile existing PRD with a change signal, surfacing conflicts before applying changes | Updated prd.md, conflict log |
| Validate | Critique against a configurable checklist | Structured HTML findings report + .md |
The Update intent surfacing conflicts before applying is the part worth knowing - it's what stops requirement drift from silently overwriting earlier decisions.
The PRD workflow also includes steps for vision/differentiators and an executive summary.
bmad-ux
Produces a spine pair: DESIGN.md (visual) and EXPERIENCE.md (behavioral). Two files because visual decisions and behavioral decisions change at different rates and have different audiences.
Prompt examples
bmad-ux
bmad-ux the search results surface - cross-language results need to signal
which language the source doc is in without cluttering
Skip this for backend-only or infrastructure work.
bmad-spec
The canonical machine contract. A five-field kernel plus companion files, validated so every load-bearing source claim is preserved.
flowchart LR
subgraph SPEC["SPEC.md - five-field kernel"]
W["<b>Why</b><br/>the reason this exists"]
C["<b>Capabilities</b><br/>what it must do"]
K["<b>Constraints</b><br/>what bounds it"]
N["<b>Non-goals</b><br/>what it explicitly won't do"]
S["<b>Success signal</b><br/>how you know it worked"]
end
SPEC --> COMP["Companion files under<br/>specs/spec-{slug}/"]
SPEC -.->|on request| ST["stories.yaml<br/>ordered, for autonomous dispatch"]
Accepts any intent input: a brief, a PRD, a meeting transcript, a brain dump, a design folder.
Prompt examples
bmad-spec
bmad-spec distill _bmad-output/prd.md into a spec
bmad-spec here's a transcript of yesterday's planning call - notes/2026-07-30.md
bmad-spec and break it into stories.yaml for autonomous dispatch
Expected output
_bmad-output/specs/spec-multilingual-retrieval/
├── SPEC.md <- the five-field kernel
├── <companions> <- preserved load-bearing source claims
└── stories.yaml <- optional, ordered, for bmad-build-auto
Two things that make bmad-spec structurally important:
- It is the only writer of
SPEC.md. Other skills invoke it headless when they need to express or update intent - so the contract stays single-sourced. - It locks the WHAT before the HOW. Everything downstream reads from it.
Note:
bmad-specused to live in the core module. It now ships with BMM as a Phase 2 planning workflow.
7. Phase 3 - Solutioning
Decide how to build it and break work into stories.
flowchart TD
A["SPEC.md / prd.md / DESIGN.md"] --> AR[bmad-architecture]
AR --> SPINE["ARCHITECTURE-SPINE.md<br/>(hydrates to other outputs on request)"]
SPINE --> PC[bmad-generate-project-context]
PC --> CTX["project-context.md<br/>the project constitution"]
CTX --> ES[bmad-create-epics-and-stories]
ES --> EP["Epic files with stories"]
EP --> IR[bmad-check-implementation-readiness]
IR --> D{Gate}
D -->|PASS| P4["to Phase 4"]
D -->|CONCERNS| P4
D -->|FAIL| BACK["Back to the gap -<br/>usually PRD or architecture"]
BACK --> AR
| Workflow | Purpose | Produces |
|---|---|---|
bmad-architecture | Make technical decisions explicit | ARCHITECTURE-SPINE.md by default; can hydrate to other output or presentation formats |
bmad-generate-project-context | Auto-generate the project constitution from architecture or codebase | _bmad-output/project-context.md |
bmad-create-epics-and-stories | Break requirements into implementable work | Epic files with stories |
bmad-check-implementation-readiness | Gate check before implementation | PASS / CONCERNS / FAIL decision |
bmad-architecture
Prompt examples
bmad-architecture
bmad-architecture - constraints: OpenSearch 2.13, no new infra, p99 under 40ms,
must degrade to BM25 on embedding service failure
Expected output: ARCHITECTURE-SPINE.md. The spine is the durable form; hydrate it into a full document or a presentation when you need to communicate it outward.
bmad-generate-project-context - the highest-leverage step
project-context.md works like a constitution for your project. It guides implementation decisions across all workflows and is automatically loaded by implementation workflows.
Two ways to get it:
- Manually - create
_bmad-output/project-context.mdwith your technology stack and implementation rules - Generate it - run
bmad-generate-project-contextto derive it from your architecture or existing codebase
flowchart LR
A[Architecture doc] --> G[bmad-generate-project-context]
B[Existing codebase] --> G
G --> C["_bmad-output/project-context.md"]
C -.->|auto-loaded| D[bmad-build]
C -.->|auto-loaded| E[bmad-code-review]
C -.->|auto-loaded| F[bmad-build-auto]
Prompt examples
bmad-generate-project-context
bmad-generate-project-context - derive from the existing codebase, we have no
architecture doc. Emphasize our error handling and logging conventions.
This is the single best-value step for an existing codebase. It's what stops agents re-litigating your conventions on every story.
bmad-create-epics-and-stories
Prompt examples
bmad-create-epics-and-stories
bmad-create-epics-and-stories from SPEC.md - keep epics under 5 stories each,
we ship weekly
Also: the CE trigger on the PM agent.
bmad-check-implementation-readiness
A genuine gate, not a formality. Returns PASS, CONCERNS, or FAIL.
bmad-check-implementation-readiness
Available on both the PM agent (IR) and the Architect agent (IR) - deliberately, since readiness failures usually trace to either a requirements gap or a design gap.
8. Phase 4 - Implementation
Every implementation path converges on bmad-build. It accepts direct intent, an issue, a specification, or a planned story, then chooses the clarification, planning, implementation, and review depth needed for that input.
flowchart TD
subgraph IN["Inputs - all valid"]
I1[Direct intent]
I2[An issue<br/>JIRA-4412]
I3[A specification<br/>SPEC.md]
I4[A planned story<br/>from epic files]
end
IN --> BD["<b>bmad-build</b>"]
BD --> CL[Clarify: ask for the<br/>choices it needs]
CL --> PL[Plan: you approve or change]
PL --> IM[Implement]
IM --> RV[Review its own work<br/>runs bmad-review code lenses]
RV --> OUT["spec-*.md + working code<br/>you can run and inspect"]
CTX["project-context.md"] -.->|auto-loaded| BD
| Workflow | Purpose | Produces |
|---|---|---|
bmad-build | Turn direct intent or a planned story into implemented, reviewed code | spec-*.md + code |
bmad-build-auto | Automate one unattended iteration of the Build implementation model | Code + iteration log |
bmad-sprint-planning | Initialize tracking (once per project) to sequence the dev cycle | sprint-status.yaml |
bmad-code-review | Ad hoc review of any code change | Findings + applied patches |
bmad-correct-course | Handle significant mid-sprint changes | Updated plan or re-routing |
bmad-sprint-status | Track sprint progress and story status | Sprint status update |
bmad-retrospective | Review after epic completion | Lessons learned |
bmad-build - the convergence point
You keep control of the decisions that shape the result. Build asks for the choices it needs, then gives you a plan to approve or change, implements, reviews its own work, and shows you the result.
Prompt examples
bmad-build
bmad-build add exponential backoff with jitter to the embedding service client,
max 3 retries, fall through to BM25 on exhaustion
bmad-build story 2.3
bmad-build JIRA-4412
bmad-build from _bmad-output/specs/spec-multilingual-retrieval/SPEC.md
Expected output
- A
spec-*.mdcapturing what it decided to build and why - Working code you can run and inspect
- Review findings from the code lenses it ran on itself
The depth adapts to the input. A one-line intent gets light clarification and a short plan. A full planned story with architecture and project-context behind it gets a deeper plan and heavier review. Feeding it more context doesn't route you to a different workflow - it changes how bmad-build behaves.
bmad-build-auto - unattended loops
flowchart TD
A["stories.yaml<br/>(from bmad-spec)"] --> B[bmad-build-auto]
B --> C[Pick next story in order]
C --> D[Run the bmad-build model<br/>unattended]
D --> E[Review]
E --> F{Pass?}
F -->|Yes| G[Mark done, log]
F -->|No| H[Log the failure]
G --> I{More stories?}
H --> I
I -->|Yes| C
I -->|No| J[Stop - report]
Prompt examples
bmad-build-auto
bmad-build-auto - run through stories.yaml, stop on the first review failure
Prerequisite: an ordered stories.yaml, which bmad-spec produces on request.
Judgment call: unattended loops amplify whatever your project-context.md says. Run it only after the constitution is accurate - otherwise you get several stories' worth of consistently wrong conventions.
bmad-sprint-planning
Run once per project to initialize tracking. Produces sprint-status.yaml.
bmad-sprint-planning
bmad-sprint-status
bmad-sprint-status
Returns story-by-story progress against sprint-status.yaml.
bmad-code-review
Ad hoc review of any code change - separate from the review bmad-build runs on itself. Produces findings + applied patches.
Prompt examples
bmad-code-review
bmad-code-review the diff against main
bmad-code-review PR 218 - focus on the concurrency changes
Reach for this when: reviewing code BMAD didn't write, or when bmad-build's built-in review depth wasn't enough for a risky change.
bmad-correct-course
For significant mid-sprint changes - a requirement shifted, an assumption broke, a dependency vanished. Produces an updated plan or re-routes you to an earlier phase.
Prompt examples
bmad-correct-course
bmad-correct-course - the embedding vendor deprecated the multilingual model
we planned on, effective in 60 days
Also: the CC trigger on the PM agent.
bmad-retrospective
Run after epic completion. Produces lessons learned.
bmad-retrospective
Also: the ER (Epic Retrospective) trigger on the Developer agent.
bmad-qa-generate-e2e-tests
Available through the Developer agent (QA) or directly.
bmad-qa-generate-e2e-tests
The full Test Architect (TEA) is a separate module with much deeper test architecture capability - install it if testing is a first-class concern rather than a step.
9. The Four Entry Paths
There is no single correct order. Pick the path by the shape of the work.
flowchart TD
START{What are you doing?}
START -->|"New product, nothing exists"| FULL
START -->|"Small, well-understood change"| QUICK
START -->|"Existing codebase, no BMAD artifacts"| BROWN
START -->|"Batch of defined work, want it unattended"| AUTO
subgraph FULL["FULL METHOD"]
F1[bmad-brainstorming] --> F2[bmad-deep-recon]
F2 --> F3[bmad-forge-idea]
F3 --> F4[bmad-product-brief]
F4 --> F5[bmad-prfaq]
F5 --> F6[bmad-prd]
F6 --> F7[bmad-ux]
F7 --> F8[bmad-spec]
F8 --> F9[bmad-architecture]
F9 --> F10[bmad-generate-project-context]
F10 --> F11[bmad-create-epics-and-stories]
F11 --> F12[bmad-check-implementation-readiness]
F12 --> F13[bmad-sprint-planning]
F13 --> F14[bmad-build loop]
F14 --> F15[bmad-retrospective]
end
subgraph QUICK["QUICK PATH"]
Q1["bmad-build<br/>(that's it)"]
end
subgraph BROWN["EXISTING CODEBASE"]
B1[bmad-document-project] --> B2[bmad-generate-project-context]
B2 --> B3{Scope?}
B3 -->|Small| B4[bmad-build]
B3 -->|Large| B5[rejoin at bmad-prd]
end
subgraph AUTO["AUTONOMOUS"]
A1[bmad-spec] --> A2["stories.yaml"]
A2 --> A3[bmad-build-auto]
end
Path 1 - Full Method (greenfield, Level 3-4)
| # | Skill | Skip when |
|---|---|---|
| 1 | bmad-brainstorming | You already know the direction |
| 2 | bmad-deep-recon | The facts aren't in question |
| 3 | bmad-forge-idea | You've already validated the idea elsewhere |
| 4 | bmad-product-brief | Strategic framing is settled and written |
| 5 | bmad-prfaq | Not customer-facing |
| 6 | bmad-prd | Rarely skip for anything non-trivial |
| 7 | bmad-ux | Backend or infra only |
| 8 | bmad-spec | You don't need a machine contract or stories.yaml |
| 9 | bmad-architecture | No meaningful technical decisions to make explicit |
| 10 | bmad-generate-project-context | Don't skip - highest leverage per minute |
| 11 | bmad-create-epics-and-stories | Work is a single story |
| 12 | bmad-check-implementation-readiness | Small enough that a failed gate costs nothing |
| 13 | bmad-sprint-planning | Already initialized (once per project) |
| 14 | bmad-build | Never - this is the destination |
| 15 | bmad-retrospective | No epic completed yet |
Interleave freely: bmad-code-review for risky changes, bmad-sprint-status between stories, bmad-correct-course when something shifts.
Path 2 - Quick Path (well-scoped change)
bmad-build <your intent>
That is the whole path. bmad-build accepts raw intent and picks its own clarification, planning, implementation, and review depth. Skipping steps 1-13 is the intended behavior, not a compromise.
Path 3 - Existing Codebase
flowchart LR
A[bmad-document-project<br/>or DP via Analyst] --> B[bmad-generate-project-context]
B --> C{Scope of the work?}
C -->|"Bug fix, small feature"| D[bmad-build]
C -->|"New subsystem"| E[bmad-prd] --> F[bmad-architecture] --> G[...]
The two-step front-load (bmad-document-project then bmad-generate-project-context) is what makes every later BMAD invocation aware of your conventions. Do it once.
Path 4 - Autonomous
bmad-spec ... and break it into stories.yaml
bmad-build-auto
Only after project-context.md is accurate.
Three things that matter more than the order
- Run
bmad-helpbetween phases. It inspects actual artifacts on disk and tells you what's genuinely next. That beats following any static list, including this one. bmad-advanced-elicitationisn't a step. Call it any time an output feels thin.bmad-reviewisn't a step either. Document workflows already invoke its editorial lenses as their finalize step, and code workflows run the code lenses automatically.
10. Artifact Flow
Every document becomes context for the next phase. Without this structure, agents make inconsistent decisions.
flowchart TD
R["research.md<br/><i>bmad-deep-recon</i>"] --> BR["brief.md + addendum.md<br/><i>bmad-product-brief</i>"]
BI["brainstorm-intent.md<br/><i>bmad-brainstorming</i>"] --> BR
FG["forged-idea.md<br/><i>bmad-forge-idea</i>"] --> BR
BR -->|"source-extracted<br/>during Discovery"| PRD["prd.md + addendum.md<br/><i>bmad-prd</i>"]
PF["prfaq-{project}.md<br/><i>bmad-prfaq</i>"] --> PRD
PRD --> UX["DESIGN.md + EXPERIENCE.md<br/><i>bmad-ux</i>"]
PRD --> SPEC["SPEC.md + companions<br/><i>bmad-spec</i>"]
UX --> SPEC
SPEC --> ARCH["ARCHITECTURE-SPINE.md<br/><i>bmad-architecture</i>"]
PRD --> ARCH
ARCH --> PCTX["project-context.md<br/><i>bmad-generate-project-context</i>"]
CODE["existing codebase"] --> PCTX
ARCH --> EPICS["epic files with stories<br/><i>bmad-create-epics-and-stories</i>"]
SPEC -.->|"optional"| SY["stories.yaml"]
EPICS --> RDY["PASS / CONCERNS / FAIL<br/><i>bmad-check-implementation-readiness</i>"]
RDY --> SST["sprint-status.yaml<br/><i>bmad-sprint-planning</i>"]
SST --> BUILD["spec-*.md + code<br/><i>bmad-build</i>"]
PCTX -.->|auto-loaded| BUILD
SY --> BAUTO["<i>bmad-build-auto</i>"] --> BUILD
BUILD --> RETRO["lessons learned<br/><i>bmad-retrospective</i>"]
Reading the diagram: solid arrows are the documented feeds. Dotted arrows are automatic or optional. The important observation is that project-context.md feeds sideways into implementation rather than flowing down the chain - it's a constraint layer, not a stage.
Default output location: _bmad-output/. Configurable via --set bmm.output_folder=....
11. Worked Example - Multilingual Search Feature
A realistic end-to-end run for an existing production search system, showing which steps earn their keep and which don't.
Context: existing OpenSearch-backed search service. Task: add cross-language retrieval so an English query surfaces relevant French and German documents. Team of three. Codebase exists; no BMAD artifacts yet.
flowchart TD
S1["<b>1.</b> bmad-document-project<br/><i>one-time, existing codebase</i>"] --> S2
S2["<b>2.</b> bmad-generate-project-context<br/><i>the constitution</i>"] --> S3
S3["<b>3.</b> bmad-deep-recon (draft mode)<br/><i>embedding model choice needs evidence</i>"] --> S4
S4["<b>4.</b> bmad-forge-idea<br/><i>challenge 'dense retrieval replaces BM25'</i>"] --> S5
S5["<b>5.</b> bmad-spec<br/><i>lock the WHAT</i>"] --> S6
S6["<b>6.</b> bmad-architecture<br/><i>the fallback path is a real decision</i>"] --> S7
S7["<b>7.</b> bmad-create-epics-and-stories"] --> S8
S8["<b>8.</b> bmad-sprint-planning"] --> S9
S9["<b>9.</b> bmad-build loop"] --> S10
S10["<b>10.</b> bmad-code-review<br/><i>on the reranker change only</i>"] --> S11
S11["<b>11.</b> bmad-retrospective"]
SK["SKIPPED:<br/>brainstorming - direction is known<br/>product-brief / prfaq - internal feature<br/>prd - spec is sufficient at this scope<br/>ux - backend only"]
style SK fill:none,stroke-dasharray: 5 5
Step-by-step
1. Document the existing project
bmad-document-project
Output: a structured read of the current architecture, entry points, and conventions.
2. Generate the constitution
bmad-generate-project-context - derive from the codebase. Emphasize:
our OpenSearch client wrapper is the only allowed query path, all latency-
sensitive code paths must have a p99 assertion in tests, no new infra.
Output:
_bmad-output/project-context.md, auto-loaded by every later implementation workflow.
3. Research the one thing you don't know
bmad-deep-recon draft mode - multilingual embedding models for legal-domain
retrieval at 50M docs. Decision: which model, and whether to index per-language
or into a shared space. Constraints: 40ms p99, OpenSearch 2.13 k-NN.
Output: a deep-research prompt you paste into your flat-rate subscription. Then:
bmad-deep-recon process ~/Downloads/dr-multilingual-embeddings.md
Output:
research.md, cited, in the shape downstream skills consume.
Why draft+process rather than run: the fan-out in run mode burns metered IDE tokens on work a flat-rate subscription does equally well. Run mode earns its cost when you need verification-as-claims-land inside the IDE loop.
4. Kill the bad version of the idea
bmad-forge-idea we should replace BM25 entirely with dense retrieval -
goal: decide this session. I'm biased toward the clean architecture.
Output:
forge-report.html. Likely verdict: Clearer - hybrid, with BM25 as the fallback path, because exact-match legal citation lookup degrades badly under pure dense retrieval.
Stating your bias up front is what makes this useful. The interrogator attacks the specific thing you're attached to.
5. Lock the WHAT
bmad-spec - from research.md and the forge report. Multilingual retrieval
with BM25 fallback.
Output:
_bmad-output/specs/spec-multilingual-retrieval/SPEC.mdwith the five-field kernel. Non-goals matter most here - explicitly excluding query translation, for instance, prevents scope creep three stories in.
6. Architecture, because the fallback is a real decision
bmad-architecture - constraints from SPEC.md plus: must degrade to BM25 on
embedding service failure without a user-visible error, and the degradation
must be observable in metrics.
Output:
ARCHITECTURE-SPINE.md.
7-8. Break down and initialize
bmad-create-epics-and-stories from SPEC.md - epics under 5 stories, weekly ship cadence
bmad-sprint-planning
9. Build loop
bmad-build story 1.1
bmad-build story 1.2
Between stories:
bmad-sprint-status
10. Extra review where risk concentrates
bmad-code-review the reranker diff - verification gap lens especially,
also_consider: behavior when query locale and index locale disagree
11. Close the epic
bmad-retrospective
What got skipped and why
| Skipped | Reason |
|---|---|
bmad-brainstorming | The direction was known. Generating 100 ideas would produce noise. |
bmad-product-brief, bmad-prfaq | Internal capability, not a launched product. No strategic framing to capture. |
bmad-prd | SPEC.md carries enough for a three-person team on a scoped feature. Add the PRD if stakeholders outside the team need to sign off. |
bmad-ux | Backend only at this stage. Revisit when the results surface changes. |
bmad-check-implementation-readiness | Judgment call - worth adding back if the epic runs longer than two weeks. |
bmad-build-auto | Retrieval quality changes need a human reading the eval numbers each iteration. |
12. Deprecations, Renames, and Gotchas
Merged into bmad-review (old IDs forward)
bmad-editorial-reviewbmad-editorial-review-prosebmad-editorial-review-structurebmad-review-adversarial-generalbmad-review-edge-case-hunterbmad-review-verification-gap
The editorial lenses inside bmad-review replace the separate editorial skill entirely.
Merged into bmad-deep-recon (old IDs forward)
bmad-market-researchbmad-domain-researchbmad-technical-research
These are now research types selected by the pack loader, not separate workflows.
Removed outright
bmad-shard-docbmad-index-docs
Moved
bmad-spec- core module to BMM Phase 2
Auto-removed on upgrade
Pre-v6.2.0 wrapper skills bmad-bmm-* and bmad-agent-bmm-* are deleted automatically on upgrade so they stop erroring with missing-file warnings.
dev vs. build naming
The docs are internally inconsistent on this. Some pages reference bmad-dev / bmad-dev-auto and a "Quick Dev" explanation page; others reference bmad-build / bmad-build-auto and a "Build" page. The current reference material uses build. Check which your installed version actually generated - ls .claude/skills/ | grep -E 'build|dev' settles it in one command.
Gotchas worth knowing
| Gotcha | What to do |
|---|---|
Slash commands (/analyst, /pm, /dev) have known issues in Claude Code | Use the skill names directly, or create custom commands in .claude/commands/ |
| The installer does not delete old skill files when you remove a module | Delete the stale directories manually, or wipe the skills directory and re-run the installer for a clean set |
| Skills may need explicit enabling in your IDE settings before they appear | Check IDE docs; restart or reload the window |
| Missing skills usually means the module wasn't selected | Re-run npx bmad-method install and verify module selection |
| Modules only install what you pick | BMB, CIS, TEA, and Game Dev Studio skills won't exist unless selected |
Cost lever - Web Bundles
Web bundles package selected BMad skills as Google Gemini Gems and ChatGPT Custom GPTs. Do the upfront planning work - brainstorming, product briefs, PRDs, PRFAQs, UX specs, market and industry research - in your web LLM subscription, then bring the polished artifacts into the IDE for implementation.
Planning runs on a flat-rate subscription instead of metered IDE tokens. On a long engagement that is a meaningful saving, and the artifacts are identical.
Current shelf: brainstorming, product brief, PRFAQ, PRD, UX, market and industry research. Available at bmadcode.com/web-bundles.
13. Cheat Sheet
Everything, one place
Core (always installed)
bmad-help what do I do next
bmad-advanced-elicitation refine recent output
bmad-review multi-lens review
bmad-customize TOML overrides
bmad-brainstorming divergent ideation
bmad-deep-recon external evidence
bmad-forge-idea adversarial pressure test
bmad-party-mode multi-agent discussion
BMM agents
bmad-agent-analyst Mary BP MR DR TR CB WB DP
bmad-agent-pm John PRD CE IR CC
bmad-agent-architect Winston CA IR
bmad-agent-dev Amelia BD QA CR SP ER
bmad-agent-ux-designer Sally CU
Phase 1 - Analysis (optional)
bmad-brainstorming brainstorm.html
bmad-forge-idea forge-report.html [+ forged-idea.md]
bmad-deep-recon research.md [+ HTML briefing]
bmad-product-brief brief.md + addendum.md
bmad-prfaq prfaq-{project}.md
Phase 2 - Planning
bmad-prd prd.md + addendum.md | validation-report.html
bmad-ux DESIGN.md + EXPERIENCE.md
bmad-spec SPEC.md + companions [+ stories.yaml]
Phase 3 - Solutioning
bmad-architecture ARCHITECTURE-SPINE.md
bmad-generate-project-context project-context.md
bmad-create-epics-and-stories epic files
bmad-check-implementation-readiness PASS / CONCERNS / FAIL
Phase 4 - Implementation
bmad-build spec-*.md + code
bmad-build-auto unattended iterations
bmad-sprint-planning sprint-status.yaml
bmad-sprint-status progress
bmad-code-review findings + patches
bmad-correct-course updated plan
bmad-retrospective lessons learned
Utility
bmad-document-project structured read of an existing codebase
bmad-qa-generate-e2e-tests e2e test generation
Decision table
| Situation | Skill |
|---|---|
| Don't know what's next | bmad-help |
| Don't know the facts | bmad-deep-recon |
| No options | bmad-brainstorming |
| One idea, too attached | bmad-forge-idea |
| One angle only | bmad-party-mode |
| Output feels thin | bmad-advanced-elicitation |
| Need QA on a deliverable | bmad-review |
| New codebase, no artifacts | bmad-document-project then bmad-generate-project-context |
| Small scoped change | bmad-build |
| Requirement shifted mid-sprint | bmad-correct-course |
| Agent keeps ignoring your conventions | bmad-customize or fix project-context.md |
Verify your own install
ls .claude/skills/ | grep bmad # canonical list
ls .claude/skills/ | grep -E 'build|dev' # settle the naming question
cat .claude/skills/bmad-build/SKILL.md # read what a skill actually does
npx bmad-method install --list-options bmm
Compiled from the BMAD-METHOD v6 documentation. Where this guide states an opinion - which steps to skip, when draft mode beats run mode, sequencing judgment - that is analysis, not doctrine. Your installed version is the authority on what exists; bmad-help is the authority on what's next.
Appendix A - The Research Firewall
The single most consequential design decision in bmad-deep-recon, and the one most likely to be misread. "Firewall" here has nothing to do with network security. It is an epistemic boundary: a rule about what may count as evidence.
A.1 The Rule, Verbatim
From src/core-skills/bmad-deep-recon/SKILL.md, stated as one of two standing rules inherited verbatim by every subagent:
1. Never conclude from training data alone. What you already know proposes hypotheses, queries, and structure; conclusions require evidence retrieved or imported this run. A claim you cannot evidence is stated as an unverified belief or not at all.
2. The research firewall. Project context - briefs, PRDs, code, memory,
{workflow.persistent_facts}- shapes what to ask, never what is true. It is inadmissible as evidence: every claim in a research artifact traces to a digest or import file with a source. Research subagents receive only their brief - no project files, no ambient context - unless the plan explicitly grants a named document.
One sentence: your project decides the questions; it is never allowed to be an answer.
A.2 What It Prevents
The failure mode is subtle because it produces output that looks like good research.
flowchart TD
subgraph BAD["WITHOUT a firewall - motivated reasoning"]
B1["PRD states:<br/>'HNSW handles 50M vectors at 40ms p99'"]
B1 --> B2[Subagent reads the PRD<br/>as background context]
B2 --> B3["Treats it as an established fact"]
B3 --> B4["Searches to CONFIRM:<br/>'HNSW 50M scale success'"]
B4 --> B5["Finds confirming vendor blog posts"]
B5 --> B6["Report: 'Research confirms<br/>the approach is sound'"]
B6 --> B7["<b>Your own assumption,<br/>laundered into a citation</b>"]
end
subgraph GOOD["WITH the firewall"]
G1["PRD states the same thing"]
G1 --> G2["Lead uses it to FRAME:<br/>'research HNSW p99 latency at 50M scale'"]
G2 --> G3["Subagent receives only that question -<br/>never sees the PRD"]
G3 --> G4["Searches openly:<br/>'HNSW latency benchmarks 50M'"]
G4 --> G5["Finds production reports showing<br/>90-140ms at that scale on comparable hardware"]
G5 --> G6["Report: 'Evidence contradicts<br/>a 40ms target at this scale'"]
G6 --> G7["<b>The research can tell you<br/>you are wrong</b>"]
end
style B7 fill:#4a1f1f,color:#fff
style G7 fill:#1f3d2a,color:#fff
The problem is not that the subagent lies. It is that a subagent holding your premise searches to support it. Query formulation is where the bias enters, long before synthesis. By the time a report is written, the sources genuinely do say what it claims - they were just selected by a question that assumed the answer.
The firewall's second effect is on citation integrity: because project files are inadmissible, every claim in research.md must trace to a digest or import file with a real publisher, date, and URL. A claim that traces only to your PRD cannot appear at all.
A.3 Worked Example - With and Without
Setup. You run bmad-deep-recon inside your search-service repo. The repo contains prd.md, ARCHITECTURE-SPINE.md, and project-context.md. Your architecture spine asserts a design decision you made six months ago.
Your input:
bmad-deep-recon run - should we move from per-language indices to a single
shared multilingual embedding space? Decision: whether to re-index 50M docs
this quarter.
What the lead orchestrator does with your project files
| Project material | Permitted use | Forbidden use |
|---|---|---|
prd.md says "sub-40ms p99 required" | Frame a dimension: "what latency do shared-space approaches achieve at 50M scale?" | Assert in the report that 40ms is achievable |
ARCHITECTURE-SPINE.md says "we chose per-language indices for recall" | Frame a dimension: "what recall tradeoffs are reported between per-language and shared-space indexing?" | Treat "per-language gives better recall" as an established finding |
project-context.md says "OpenSearch 2.13, no new infra" | Prune dimensions - skip anything requiring a separate vector DB | Claim OpenSearch 2.13 supports X because your context says so |
| Codebase shows your current recall@10 is 0.71 | Frame: "what recall figures do published multilingual retrieval systems report?" | Cite 0.71 as a benchmark data point in the report |
The brief that actually reaches a subagent
TOPIC: multilingual retrieval - shared embedding space vs per-language indices
DECISION: whether to re-index a 50M-document corpus this quarter
YOUR QUESTIONS (dimension 2 of 4):
- What recall tradeoffs are reported between shared multilingual embedding
spaces and per-language indices at 10M+ document scale?
- Which failure modes are documented for cross-lingual retrieval in
specialized domains?
SEARCH SURFACES: harness web search; <MCP tools if installed>
PREFERRED SOURCES: <from customize.toml> BANNED: <from customize.toml>
PACK CRAFT (technical): read retrospective threads not launch threads;
favor accounts with production numbers over advocacy; before citing a pain
point check whether it was since fixed; freshness - versions <= 1 mo,
ecosystem signals <= 6 mo, landscape <= 12 mo (AI-adjacent <= 3 mo)
TWO-SOURCE CLASSES: performance/scale numbers; claims that an approach failed
BUDGETS: 8 sources, ~10 tool calls
QUERY CRAFT: short queries (<= ~5 words) beat hyper-specific ones; broaden
when sparse, narrow when abundant; never repeat an identical query on the
same tool; after every tool result, pause and evaluate before firing again
EPISTEMICS (verbatim):
1. Never conclude from training data alone.
2. The research firewall. Project context shapes what to ask, never what
is true. It is inadmissible as evidence.
RETURN: a digest, not raw results - findings as claims, each with
{claim, source, publisher, pub_date, accessed, confidence, class};
plus leads worth chasing and what you looked for and could not find.
Notice what is absent. No PRD. No architecture spine. No project-context. No mention that you already run per-language indices, or that you have a preference. The subagent cannot tell whose project this is or which answer would be convenient.
What comes back
A digest file at {doc_workspace}/digests/recall-tradeoffs-r1-1.md, written to disk the moment it lands:
- claim: Shared multilingual spaces show 3-8% recall degradation vs
per-language indices on domain-specific corpora
source: <url> publisher: <name> pub_date: 2026-03
accessed: 2026-08-01 confidence: medium class: performance
- claim: Degradation narrows to under 2% when a per-language reranker
is retained on top of a shared retrieval stage
source: <url> publisher: <name> pub_date: 2026-01
accessed: 2026-08-01 confidence: medium class: performance
LEADS: hybrid stage architectures; "reranker retention" as a mitigation
NOT FOUND: no published figures above 20M documents in a legal-domain corpus
That last line - "absence of evidence is a finding" - is a firewall consequence. Without it, a subagent holding your context would be tempted to fill the gap from your own numbers.
How it lands in the report
The lead writes research.md from digest files only. Your project reappears only in the Recommendations section, where findings are bound to the artifacts that consume them - the pack's Feeds entries. So the flow is:
flowchart LR
P["Project files<br/>PRD, spine, context, code"] -->|"shapes questions"| Q[Dimensions and briefs]
Q --> S[Subagents behind the firewall]
S -->|"digest files with sources"| D["digests/"]
D -->|"only source of claims"| R["research.md"]
P -.->|"NEVER a source of claims"| R
R -->|"recommendations bound<br/>to your artifacts"| P
style P fill:#2a2a3d,color:#fff
Project context flows in at framing time and back at recommendation time. It never flows into the evidence chain in the middle.
A.4 What a Subagent Brief Actually Contains
Per references/run.md, exactly these items - and the list is closed:
| In the brief | Not in the brief |
|---|---|
| The questions it owns | Project files of any kind |
| The decision they serve, and the topic | The conversation history |
| Its search surfaces (specialized tools first, then generic) | Other subagents' findings |
preferred_sources first / banned_sources never | Which answer the user expects |
| The pack's source craft and freshness bars | Prior rounds' conclusions (beyond the leads it is handed) |
| The source-quality card | persistent_facts, unless explicitly configured |
| Its source and tool-call budgets | Anything about the codebase |
| The query craft rules | |
| The two epistemics rules, verbatim | |
| The digest return contract |
A.5 Where the Firewall Applies
Not just the Run-mode research fan-out. Every subagent in the skill runs behind it.
flowchart TD
L["Lead orchestrator<br/>(holds project context -<br/>uses it only to frame)"]
L -->|"brief only"| R1[Research assistant 1]
L -->|"brief only"| R2[Research assistant 2]
L -->|"brief only"| R3[Research assistant N]
L -->|"digest files only"| V["Verifier subagent<br/>fresh context"]
L -->|"conclusion + budget,<br/>NO supporting evidence,<br/>NO run context"| RT["Red-team skeptic<br/>fresh context"]
L -->|"the import file only"| EX["Extraction subagent<br/>(Process mode)"]
L -->|"research.md only"| CC["Citation checker<br/>fresh context"]
R1 --> DG["digests/"]
R2 --> DG
R3 --> DG
EX --> DG
DG --> V
V --> RM["research.md"]
RT --> RM
CC --> RM
style L fill:#2a2a3d,color:#fff
| Subagent | What it receives | Why the isolation matters |
|---|---|---|
| Research assistant | Its brief, nothing else | Cannot search to confirm your premise |
| Verifier | Digest files, fresh context | Cannot verify a claim it already believes; runs per dimension as material lands, never as an end-of-run rewrite pass |
| Red-team skeptic | The conclusion and a search budget - explicitly no supporting evidence, no run context | Cannot be anchored by the case it is meant to attack. This is the strictest application in the skill |
| Extraction subagent (Process) | The import file only | Extracts what the report says, not what your project wants it to say |
| Citation checker (Finalize) | research.md, fresh context | Judges only whether each cited source supports its claim; it never rewrites findings - a mismatch downgrades confidence and logs an event |
A related isolation, worth noting: assistants are also firewalled from each other. Round 1 assistants do not see each other's returns. When two isolated assistants independently land on the same finding, that convergence is real signal. If they had shared context, agreement would mean nothing.
A.6 The Escape Hatches
The firewall is a default, not a wall without a door.
| Mechanism | Effect |
|---|---|
persistent_facts in customize.toml | Standing context for framing research. Defaults to [] - "empty by default so nothing local leaks into research framing unasked." Entries prefixed file: load file contents as facts. Still framing-only; still inadmissible as evidence |
| Explicit named grant at the plan gate | "Research subagents receive only their brief... unless the plan explicitly grants a named document." You can hand a specific document to a specific assistant, deliberately, at the one gate |
The select shape - a deliberate inversion | For choose-between decisions, references/selection.md states that requirements come from the project itself (brief, PRD, spine, persistent_facts, codebase) and the user, and that "web research does not set requirements." Project context is authoritative for the frame; research is authoritative for the evidence. The firewall still holds - it just makes explicit which side owns which half |
A.7 Limits - What It Does Not Guarantee
Worth stating plainly if you are relying on it.
- It is prompt-enforced, not sandboxed. The rule is text in
SKILL.mdthat the lead is instructed to inherit into every brief. Nothing mechanically prevents a leak. There is no assertion inrecon_kit.pythat briefs are context-free, and no test intest_recon_kit.pycovering it - the 6 shipped tests cover citations cross-check, tally last-status-wins, staleness windows, slug determinism, and URL escaping. - It does not protect against a leading question. The firewall keeps your documents out. It cannot keep your framing out. "Research why shared embedding spaces underperform" is a biased dimension, and the firewall passes it through untouched. Framing quality is on you and the plan gate.
- It does not fix bad sources. Isolation improves independence, not source quality. That job belongs to the source-quality card - prefer primary sources, treat answer engines as single aggregators and chase their citations, resolve conflicts by recency and publisher quality rather than averaging.
persistent_factsis a self-inflicted hole. Anything you add there travels into framing on every run. Empty is the safe default for a reason.
A.8 Provenance - PR #2611
The firewall arrived with the research consolidation.
| PR | feat(core): consolidate research trio into bmad-deep-recon (#2611) |
| Author | bmadcode |
| Merged | 2026-07-23, branch research-consolidation -> main |
| Size | 70 files, +1,333 / -5,169 (net -3,836) |
| Review | 27 automated review comments; no second human reviewer |
Replaced bmad-market-research, bmad-domain-research, and bmad-technical-research - three near-duplicate step-file trees whose step-06-*-synthesis.md files alone were ~450-490 lines each, three times over.
The PR body states the firewall as one of two standing rules under "Epistemics and reliability," alongside files-first persistence and verification-at-landing. It also notes persistent_facts defaults to empty, and that the skill moved to src/core-skills/ so research reaches non-software installs.
Where to read it in your own install:
cat .claude/skills/bmad-deep-recon/SKILL.md # the two standing rules
cat .claude/skills/bmad-deep-recon/references/run.md # brief contents, fan-out
cat .claude/skills/bmad-deep-recon/references/verification.md # verifier + red-team
cat .claude/skills/bmad-deep-recon/customize.toml # persistent_facts default
Appendix B - Why the Firewall Works: Vocabulary, Philosophy, and Scenarios
Appendix A described what the research firewall is and how BMAD implements it. This appendix explains why the idea exists at all - where it comes from, what problem in human reasoning it solves, how a dozen other fields arrived at the same answer independently, and what it looks like across several situations on a recommendation system.
No background assumed. Every term is defined before it is used.
B.1 Start Here - One Idea You Already Use
Before any philosophy, here is the firewall in a form that will already be familiar from machine learning.
You would never evaluate a model on its training data.
Not because it would be dishonest. Because the number would be meaningless. A model that has seen an example can reproduce the answer without having learned anything general. The evaluation has to run on data the model has never seen, or it measures memory rather than skill.
The research firewall is a train/test split for reasoning.
- Your project documents are the training data - they shaped what the system expects.
- Your research question is the evaluation.
- If the researching agent has read your documents, its "finding" may just be reproducing your assumption. The number is meaningless in exactly the same way.
- So the researcher runs on held-out context: the question, and nothing else.
flowchart LR
subgraph ML["What you already do"]
T1[Training data] --> M1[Model]
M1 --> E1[Evaluate on<br/>HELD-OUT test set]
E1 --> R1["A number you can trust"]
T1 -.->|"leakage =<br/>meaningless score"| E1
end
subgraph FW["What the firewall does"]
T2["Project docs<br/>PRD, spine, code"] --> M2[Lead orchestrator]
M2 -->|"question only"| E2["Research subagent<br/>HELD-OUT context"]
E2 --> R2["A finding you can trust"]
T2 -.->|"leakage =<br/>meaningless finding"| E2
end
That dotted line has a name in ML: data leakage. The firewall exists to prevent the reasoning equivalent.
Everything below is why that instinct is correct, and how many different fields discovered it separately.
B.2 The Vocabulary
These words get used interchangeably in ordinary speech and mean quite different things here.
Idea
Anything that occurs to you. There is no quality bar - an idea is just a thought that showed up.
"Maybe we should try a two-stage retrieval pipeline."
Could be excellent. Could be terrible. Nobody has checked yet, and nothing about the word "idea" implies anyone will.
Hunch (also: intuition, gut feeling)
A belief you hold without being able to state your reasons. The feeling is real; the articulation is missing.
"Something's wrong with the French results. I can't point at what. I just know."
Hunches are frequently correct - they often compress genuine experience you absorbed without consciously recording it. That is why experienced engineers have better hunches than new ones. But a hunch is the output of a process you cannot inspect, which is exactly why it cannot serve as proof to anyone else, including your future self.
Hypothesis
A guess stated specifically enough that a result could prove it wrong.
That last clause is the whole definition. Compare:
| Statement | Hypothesis? | Why |
|---|---|---|
| "The reranker is bad" | No | Nothing could contradict it. "Bad" has no measurable meaning |
| "The reranker hurts recall" | Barely | Direction but no magnitude - any tiny drop confirms it |
| "The reranker drops recall@10 by more than 5% on French queries" | Yes | You can measure French recall@10. Under 5% kills it |
The test is called falsifiability, associated with the philosopher Karl Popper (1902-1994). His point: a claim that no possible observation could contradict is not a strong claim, it is an empty one. "The recommender works better on Tuesdays because of user energy" survives every possible result, which means it explains nothing.
Why this matters for the firewall: a well-formed hypothesis is what your project documents legitimately contribute. Your architecture spine can supply the guess. It cannot supply the measurement.
Theory
The genuinely confusing word, because it means opposite things in casual and technical speech.
| Usage | Meaning | Strength |
|---|---|---|
| Everyday | A wild guess. "It's just a theory" | Weaker than a hypothesis |
| Scientific | A framework that survived decades of attempts to falsify it and now explains a large body of evidence - germ theory, plate tectonics, evolution | Far stronger than a hypothesis |
Almost nothing in software engineering rises to the scientific sense. When you say "my theory is the cache is cold on first request," you mean hypothesis. That is fine - just know that "theory" in a paper means something else entirely.
Evidence
An observation from outside your own head that bears on whether a claim is true.
The "outside your own head" part is load-bearing. Your recollection that recall dropped is not evidence; it is testimony about a memory. The eval run's output file is evidence. A published benchmark is evidence. A sentence in your own PRD asserting a number is not evidence, because your PRD is a record of what you concluded, not of what was observed.
Warrant / warranted
You have good reason to believe something. It has earned belief.
The same sentence can be warranted or unwarranted depending entirely on how you came to hold it:
"Shared multilingual embeddings hurt recall on legal text."
- Unwarranted: you noticed some French results looked off six months ago
- Warranted: you ran a controlled eval on 10k labelled legal queries and measured a 7.2% drop
The sentence is identical. The warrant is not. This is the distinction the firewall protects, and it is invisible from the sentence alone - which is precisely why documents are dangerous. A doc records the sentence and drops the warrant.
Claim
A statement that asserts something is true. In BMAD's vocabulary, a claim is specifically a research finding tracked in the memlog with {claim, source, publisher, pub_date, accessed, confidence, class}.
The skill's phrasing: "A claim is a sentence with a source. Publisher, publication date, access date. No naked numbers." A number with no source attached is not a claim in this system - it is noise that looks like a claim.
Admissible / inadmissible
Borrowed from courtroom procedure. Admissible evidence is material a court will allow the jury to consider. Inadmissible material may be entirely true and still be excluded, because the process by which it arrived is not trustworthy.
Two examples from law:
- Hearsay - "my colleague told me the vendor's benchmark showed 40ms." Possibly true. Excluded, because the person who actually observed it isn't there to be questioned.
- Fruit of the poisonous tree - evidence obtained through an unlawful search is thrown out even when it proves guilt, because admitting it would reward the bad process.
Both share a principle: courts regulate the pipeline, not just the conclusion. A system that only checked conclusions would be gameable by anyone who controlled the inputs.
When BMAD says project context is "inadmissible as evidence," it is using the word precisely. Your PRD may be entirely correct. It is still excluded, because material that originated inside the project cannot be used to validate the project.
Prior and likelihood
From Bayesian reasoning - two ingredients of an updated belief:
- Prior - what you believed before looking. Where you point your attention.
- Likelihood - what the new data says.
Both are legitimate. The error is letting the prior masquerade as data - counting your existing belief twice, once as a belief and once as evidence for itself.
The firewall in one line of this vocabulary: your project supplies the prior; only retrieved sources supply the likelihood.
Bias
Not "prejudice" in the moral sense. A systematic deviation - an error that leans consistently in one direction rather than scattering randomly.
Random error averages out with more samples. Systematic error does not: run a biased process a hundred times and you get a hundred results wrong in the same direction, plus false confidence from the consistency. That is why bias is treated as a design problem rather than an effort problem.
B.3 Discovery vs. Justification
The distinction
The philosopher Hans Reichenbach (1891-1953) drew a line in Experience and Prediction (1938) between two questions people constantly blur:
| Context of discovery | Context of justification | |
|---|---|---|
| The question | Where did this idea come from? | Is this idea actually true? |
| Governed by | Nothing. Anything goes | Evidence, and only evidence |
| Belongs to | Psychology, biography, luck | Logic, method, measurement |
| Can be messy? | Yes, and usually is | No |
The claim: how you arrived at an idea has no bearing on whether it is correct.
This cuts in both directions, and both directions matter:
- A ridiculous origin does not make an idea wrong. Dreams, analogies, and accidents have produced real discoveries.
- An impressive origin does not make an idea right. Deep expertise, careful reasoning, and a senior title are all origins. None is evidence.
The second half is the one that bites in engineering. "The staff engineer decided this after a lot of thought" is a discovery story. It feels like justification. It is not.
Three examples
The lottery dream
You dream of the number 17. You buy a ticket. It wins.
- Discovery: a dream. Absurd, and it worked.
- Justification: the draw.
- The dream was never evidence. You would still be foolish to bet your savings on tomorrow night's dream - even though last night's paid out. The origin worked once and is still worthless as proof.
Kekule's snake
August Kekule reported working out the ring structure of benzene after a reverie in which a snake seized its own tail.
- Discovery: a daydream about a snake.
- Justification: X-ray crystallography and a century of chemistry.
- No chemist believes in the benzene ring because of the snake. The snake produced a candidate; the instruments produced the warrant.
Your architecture doc
Six months ago you noticed French results looked weak. You formed a hypothesis - shared multilingual embeddings hurt recall on legal text - and wrote it into ARCHITECTURE-SPINE.md, then built per-language indices.
- Discovery: your hunch. Entirely legitimate, and probably good judgment.
- Justification: never happened. You did not run the eval.
- The trap: once written in a document, a hunch stops looking like a hunch. Six months on,
ARCHITECTURE-SPINE.mdreads like a statement of fact. Nothing in the sentence carries a marker saying "this was a feeling on a Tuesday."
flowchart TD
H["A hunch<br/>'French results feel weak'"] --> W["Written into a document"]
W --> T["Time passes"]
T --> L["<b>Reads as established fact</b><br/>The warrant is gone;<br/>the sentence remains"]
L --> D["Cited in decisions,<br/>quoted in reviews,<br/>fed to an AI agent"]
D --> C["<b>Circular:</b> the project<br/>validates itself"]
style L fill:#4a3a1f,color:#fff
style C fill:#4a1f1f,color:#fff
This is not a failure of rigor by anyone. It is what documents do: they preserve conclusions and discard provenance. Every long-lived codebase contains decisions whose original justification nobody can reconstruct.
Why an AI agent makes it worse
A human reading ARCHITECTURE-SPINE.md may remember it was a guess. They were there. An agent has no such memory - it reads the sentence and receives a confident assertion with no epistemic status attached. Then it goes looking for support.
The firewall is a mechanical answer to a mechanical problem: if the agent cannot read the document, it cannot mistake the document's confidence for evidence.
B.4 Why Human Brains Need This
The firewall would be unnecessary if reasoning systems, human or artificial, were naturally even-handed. A century of psychology says otherwise.
Confirmation bias
The tendency to seek, notice, and remember information that supports what you already believe - and to skip past what doesn't.
Peter Wason demonstrated it cleanly in 1960. Subjects were told the sequence 2, 4, 6 followed a rule, and asked to discover the rule by proposing their own triples, receiving only yes/no feedback.
Almost everyone guessed "ascending even numbers," then tested 8-10-12, 20-22-24, 100-102-104 - each answered "yes," each confirming their guess. Very few tried 1-2-3 or 5-4-3, which is what it takes to find out you're wrong.
The actual rule was simply any increasing sequence. Nearly all confident answers were wrong, and the confidence came from a long run of yeses.
This is the exact failure the firewall prevents. An agent holding your hypothesis generates confirming queries - "shared embeddings recall problems legal" - and every result comes back yes. The problem is never in the sources. It is in which questions got asked.
Motivated reasoning
Ziva Kunda (1990) showed that wanting a conclusion changes how thoroughly you scrutinise evidence for it. People do not simply believe what they want - they construct justifications, applying real reasoning, just unevenly. Evidence against a preferred conclusion gets audited hard; evidence for it gets waved through.
An agent inside your repo inherits a preference: your project has clearly already chosen. Even without intent, the asymmetric scrutiny follows.
Anchoring
Tversky and Kahneman (1974): an initial number distorts subsequent estimates, even when it is obviously arbitrary. Subjects who saw a spun wheel land on a high number gave higher estimates for an unrelated quantity than those who saw a low number.
If a subagent reads "sub-40ms p99 required," 40ms becomes the anchor. Sources near it read as confirming; sources far from it read as edge cases about different setups. The red-team subagent in BMAD receives the conclusion but explicitly no supporting evidence - that is anti-anchoring by construction.
The streetlight effect
From the joke about the drunk searching for his keys under a lamppost because the light is better. Research goes where searching is easy rather than where the answer is.
Your documents make certain searches easy - they hand you vocabulary, product names, framings. An agent with your docs searches your vocabulary. An agent without them has to find the field's own terms, which is where unfamiliar options live. This is a substantial part of the firewall's practical value: the third option you had not considered is usually filed under words your project never uses.
HARKing
Norbert Kerr (1998) named it: Hypothesizing After the Results are Known. You run an analysis, notice a pattern, then present it as though you had predicted it in advance.
The result looks far stronger than it is, because a prediction that survives a test is meaningful, while a pattern found in data you already have is just a description of that data. Sequence is doing hidden work.
The research version: run a search, notice which findings fit the plan, present the report as if those were the questions all along. BMAD counters with files-first - digests are written the moment they land, before anyone knows which way the conclusion falls, and the memlog is append-only. Retroactive tidying leaves a trace.
The garden of forking paths
Gelman and Loken (2013) described this subtler cousin: even a researcher who never consciously fishes for results makes dozens of small analytic choices - which subgroup, which cutoff, which outliers to drop - and each choice, made in the presence of a preferred conclusion, tilts slightly the same way. No single decision is misconduct. The aggregate is a foregone conclusion.
In research: which sources to open, which to skim, which to call authoritative, which contradiction is "an edge case." All defensible individually.
The pattern across all six: none requires bad faith. Every one is what ordinary careful reasoning does when the reasoner already holds the answer. Which is why "be objective" fails as a remedy, and why every field below reached for structure instead.
B.5 How Other Fields Solved It
The same answer, arrived at independently, across fields that never talked to each other: do not ask the reasoner to ignore information - withhold the information.
Blinding (medicine)
The assessor is not told which treatment a subject received.
- Single-blind - the subject doesn't know
- Double-blind - neither subject nor assessor knows
- Triple-blind - the statistician analysing the data doesn't know either
The reason is not suspicion of doctors. It is that knowing changes judgment below the level of intention. A physician who knows this patient got the real drug reads an ambiguous chart slightly more generously - and would sincerely deny doing so, because they are not aware of it.
A striking early instance: the 1784 French royal commission investigating Franz Mesmer's "animal magnetism," which included Benjamin Franklin and Antoine Lavoisier. Subjects were blindfolded and told they were or were not being magnetised, sometimes falsely. Effects tracked what subjects believed, not what was actually done. Widely cited as one of the first blinded experiments - and it worked by controlling information, not by asking anyone to be fair.
Map to BMAD: the research subagent is the blinded assessor. It is not told which answer the project is hoping for.
Chinese walls / information barriers (finance and law)
A bank advising Company A on a confidential merger also trades Company A's stock. If the traders learn of the merger, trading on it is illegal.
The remedy is not a memo asking traders not to use inside information. It is structural: separate systems, separate floors, restricted document access, monitored crossings, and a documented process for "wall-crossing" someone deliberately when there is a legitimate need.
Law firms do the same to isolate teams acting for opposing clients. The term is increasingly rendered as ethical wall or information barrier.
Map to BMAD: the lead orchestrator is inside the wall (it holds your project files); research subagents are outside it. The explicit named grant at the plan gate is the wall-crossing procedure - deliberate, logged, exceptional.
Rules of evidence (law)
Courts regulate how material arrives, not only whether it seems true. Hearsay is excluded even when accurate; unlawfully obtained evidence is excluded even when conclusive; chain of custody requires documenting every hand a piece of evidence passed through, because unbroken provenance is what makes it trustworthy.
Map to BMAD: digests/ and imports/ are the chain of custody. Every claim traces to a file with publisher, publication date, and access date. The recon_kit.py citations check is a mechanical audit that no claim entered without one - the "no naked numbers" rule, enforced.
Separation of duties (accounting, security)
The person who approves a payment cannot also issue it. The developer who writes a deployment cannot also approve it. Not because anyone is presumed dishonest, but because concentrating both powers removes the check regardless of intent. Also called maker-checker or four-eyes.
Map to BMAD: the agent that frames the research is not the agent that answers it. Whoever holds the hypothesis does not get to grade it.
Peer review and the devil's advocate
Journals send work to reviewers who did not produce it; many blind reviewers to author identity so reputation doesn't substitute for argument.
The devil's advocate is older still - the advocatus diaboli was a formal office in Catholic canonisation proceedings, a person whose assigned job was to argue against the candidate. Someone was paid to attack the conclusion, because volunteering objections against a consensus is unreliable.
Map to BMAD: the red-team pass in references/verification.md is a paid devil's advocate. A fresh-context skeptic receives the conclusion and a search budget, no supporting evidence, no run context, and hunts for disconfirming material. A conclusion that survives keeps its strongest counter-argument on the record; one that doesn't is revised before the report states it.
Preregistration (science, post-2011)
Researchers publicly record their hypothesis and analysis plan before collecting data. This makes HARKing and forking-path drift visible - the record of what you intended to test exists independently of what you found.
Map to BMAD: the plan gate plus the append-only memlog. The plan is logged as a decision before acquisition begins; mid-run scope changes are logged as further decision entries rather than silently applied.
Adversarial collaboration
Daniel Kahneman promoted this: two researchers who disagree design a study together, agreeing in advance what result each would accept as losing. Neither side gets to specify the test alone.
Map to BMAD: the closest analogue is the select shape's weighted decision matrix, where scoring is shown rather than only totals - so a reader can re-weight and reach a different verdict. The skill's own phrasing: a matrix the user can re-weight is worth more than a verdict they must trust.
Norms of science
Robert Merton (1942) described norms that scientific communities enforce, two of which are directly relevant:
- Organized skepticism - claims are subject to structured criticism as a matter of course, not as an insult
- Disinterestedness - findings are judged apart from what the finder gains from them
Older and blunter: the Royal Society's motto nullius in verba - roughly, take nobody's word for it. And Richard Feynman's formulation, from his 1974 "cargo cult science" address: the first principle is that you must not fool yourself, and you are the easiest person to fool.
flowchart TD
P["<b>The shared problem</b><br/>A reasoner who already holds<br/>the answer will find support for it -<br/>without intending to"]
P --> M["Medicine<br/><b>Blinding</b><br/>don't tell the assessor"]
P --> F["Finance / Law<br/><b>Chinese wall</b><br/>separate the systems"]
P --> L["Courts<br/><b>Admissibility</b><br/>regulate the pipeline"]
P --> A["Accounting<br/><b>Separation of duties</b><br/>split the powers"]
P --> S["Science<br/><b>Peer review,<br/>preregistration,<br/>devil's advocate</b>"]
P --> ML["Machine learning<br/><b>Held-out test set</b><br/>never evaluate on train"]
M --> B["<b>BMAD</b><br/>The research firewall"]
F --> B
L --> B
A --> B
S --> B
ML --> B
style P fill:#2a2a3d,color:#fff
style B fill:#1f3d2a,color:#fff
None of these fields asks the participant to try harder. Every one of them changes what information reaches whom.
B.6 The Machine-Learning Version You Already Know
Recommender and search work has its own vocabulary for exactly this principle. The firewall is not a new idea being imported into your field - it is an idea your field already enforces rigorously, applied to a place where nobody was enforcing it.
Data leakage
Information from outside the training set slips into training, and the model scores brilliantly on your evaluation and poorly in production.
Classic forms in recommender work:
- Target leakage - a feature encodes the label. "Number of times this item was clicked" as a feature for predicting clicks
- Temporal leakage - training on data from after the evaluation window. The model has seen the future
- Group leakage - the same user appears in both train and test, so the model memorises that user instead of generalising to new ones
- Preprocessing leakage - fitting a normaliser or an embedding on the full dataset before splitting, so test statistics bleed into training
Every one is a case of the answer reaching the thing that was supposed to derive the answer independently. That is the firewall's failure mode, exactly.
Held-out sets and temporal splits
The remedy is structural. You do not tell the model to ignore the test set - you make the test set unreachable. For recommenders specifically, a random split is usually the wrong structure, because recommendation is a prediction about the future: the standard is a temporal split, training on everything before time T and evaluating after it.
Note what that reflects: the split is designed so the evaluation can fail. A random split makes the number look better. Practitioners choose the harder split because the flattering number is worthless.
That choice is the firewall's whole philosophy. A research process configured so it cannot contradict you is the random split of reasoning - it will always report good news.
A/B testing discipline
Your work already runs on these rules:
- Fixed sample size and analysis plan in advance. Peeking at a running test and stopping when it turns significant inflates false positives dramatically - this is preregistration under a different name
- A holdout group that receives nothing, so you can measure against reality rather than against your previous variant
- Guardrail metrics defined before launch, so you cannot pick the metric that happens to have moved
- Novelty effects - a new model looks better for two weeks because it is new. Longer horizons exist so the flattering early number does not decide
Every item is a mechanism for stopping the person who wants a result from getting it by accident.
The offline/online gap
Every recommender team knows the pattern: offline metrics improve, the online test shows nothing. Offline evaluation is scored against logged data produced by the old system, so it systematically rewards models that agree with the incumbent. The evaluation is contaminated by the thing it was supposed to judge.
That is the firewall's problem statement in your own domain. An agent researching inside your repo is running offline evaluation against logs produced by your existing decisions - and it will systematically reward conclusions that agree with your incumbent design.
| Your field's term | The firewall's equivalent |
|---|---|
| Data leakage | Project context entering the evidence chain |
| Held-out test set | The subagent's brief-only context |
| Temporal split | Freshness bars per claim class |
| Peeking at a running A/B test | Rewriting findings at the end of a run |
| Guardrail metrics fixed in advance | The plan gate, logged before acquisition |
| Novelty effect | Anchoring on the first sources retrieved |
| Offline/online gap | Research that agrees with your incumbent design |
B.7 Where BMAD Sits in That Lineage
Every mechanism in the skill maps to one of the traditions above.
| Tradition | Its mechanism | BMAD's implementation | File |
|---|---|---|---|
| Discovery / justification | Origin is not warrant | Project context shapes what to ask, never what is true | SKILL.md |
| Blinding | Withhold the arm from the assessor | Subagents receive their brief and nothing else | references/run.md |
| Chinese wall | Structural separation, documented crossings | Lead inside / researchers outside; explicit named grant at the plan gate | SKILL.md |
| Rules of evidence | Admissibility, chain of custody | Claims trace to a digest or import with publisher, pub date, access date | references/synthesis.md |
| Mechanical audit | Verify the pipeline, not the vibe | recon_kit.py citations diffs inline markers against the appendix | scripts/recon_kit.py |
| Separation of duties | Framer is not grader | Lead frames; assistants answer; verifiers check; none is the same context | references/verification.md |
| Devil's advocate | Someone assigned to attack | Red-team skeptic: conclusion + budget, no supporting evidence, no run context | references/verification.md |
| Preregistration | Record the plan before the data | Plan gate approved and logged as a decision before acquisition | references/run.md |
| Files-first / no HARKing | Record before you know the answer | Digests written to disk the moment they land; append-only memlog | SKILL.md |
| Never conclude from training data | Nullius in verba | Standing rule 1: conclusions require evidence retrieved this run | SKILL.md |
| Independent replication | Two sources, different publishers | Two-source classes per type pack; syndication doesn't count as independent | references/verification.md |
| Report null results | Absence of evidence is a finding | "What it looked for and could not find" is part of the digest contract | references/run.md |
| Held-out evaluation | Don't score on training data | Verifier subagents run fresh-context on digest files, at landing | references/verification.md |
One line summarises the design: BMAD did not invent a new epistemology. It took the standard toolkit for stopping motivated reasoning and wired it into an agent harness.
B.8 Six Scenarios on a Recommendation System
Concrete runs on the kind of system you work on - OpenSearch, HNSW vector retrieval, embeddings, reranking, A/B evaluation, multilingual content. Each shows the same structure: what your project supplies, what the subagent receives, what goes wrong without the wall, and what specifically you gain.
Scenario 1 - Vector index parameters
The situation. ARCHITECTURE-SPINE.md says: "HNSW with M=16, efConstruction=200 gives us adequate recall at acceptable latency." Those numbers came from a benchmark on 2M documents eighteen months ago. The corpus is now 50M. You ask whether to retune.
What your project legitimately supplies (discovery): that HNSW parameters are the question; that the corpus is 50M; that OpenSearch 2.13 is the engine; that p99 latency is the binding constraint. All of this shapes what to ask.
What the subagent receives:
QUESTION: How do HNSW recall and latency characteristics change between
2M and 50M vector corpora? What M / efConstruction / efSearch values do
production deployments report at 50M+ scale?
BUDGET: 8 sources
TWO-SOURCE CLASSES: performance and scale numbers
FRESHNESS: versions and compatibility <= 1 month; landscape <= 12 months
Note the absence: no M=16, no efConstruction=200, no mention that anyone already chose values.
Without the wall. The subagent reads the spine, searches "HNSW M=16 efConstruction 200 recall," and finds material discussing those specific values - because people do use them. Report: your configuration is within the commonly recommended range. True, and useless. It never surfaces that the recommended range is a function of corpus size and dimensionality, because it was never asked a question shaped that way.
With the wall. The open question surfaces the actual relationship: graph connectivity requirements scale with corpus size, and efSearch - a query-time parameter your spine does not mention at all - is typically the dominant recall/latency lever in production. The report can now say your build-time parameters are probably not the interesting knob.
The concrete gain: you were asking about the wrong parameter. Only a subagent that had not been handed your parameter list could tell you that.
Scenario 2 - Choosing a reranker
The situation. You need a cross-encoder reranker. You have informally settled on a well-known open model because your team knows it. You want research before committing.
This is a select shape decision - the one place BMAD deliberately inverts the flow.
flowchart TD
subgraph YOURS["Your project OWNS the requirements frame"]
R1["Hard gates:<br/>self-hostable, multilingual,<br/>Apache/MIT licence"]
R2["Weighted preferences:<br/>latency 40%, quality 30%,<br/>ops burden 20%, cost 10%"]
end
subgraph RESEARCH["Research OWNS the evidence"]
E1[Candidate screen]
E2[Score each finalist<br/>against the frame]
E3[Cost and lock-in]
end
YOURS -->|"frame agreed FIRST"| E1
E1 --> E2 --> E3
E3 --> V["Weighted matrix -<br/>scoring shown, re-weightable"]
YOURS -.->|"NEVER supplies<br/>a score"| E2
style YOURS fill:#2a2a3d,color:#fff
references/selection.md is explicit: requirements come from the project and the user, and web research does not set requirements. But the moment a candidate is scored, the firewall applies fully.
Without the wall. The subagent knows your team's preferred model. Its screening queries centre on that model; competitors appear framed as alternatives to it. The matrix is built, your model wins, and the exercise has laundered a preference into a decision.
With the wall. Screening runs against the field, not against your favourite. Your model may still win - and if it does, you now have a defensible reason rather than a familiar one. The named runner-up and the conditions under which it wins instead is the deliverable you could not have produced yourself, because you did not know what the alternatives were good at.
The concrete gain: the pack's two-source classes force pricing and performance figures to be independently confirmed, and the matrix is re-weightable - so when someone challenges your latency weighting six months later, you adjust a number rather than redo the research.
Scenario 3 - Multilingual embeddings (the running example)
The situation. ARCHITECTURE-SPINE.md says shared multilingual embeddings hurt recall on legal text. That was a hunch, six months ago, never measured. You are asking whether to re-index 50M documents into a shared space.
Without the wall.
Queries: "shared multilingual embeddings recall problems legal", "why per-language indices better domain specific"
Report: Research confirms shared embedding spaces degrade recall on specialised corpora. Per-language indexing remains the stronger choice. [3][7][11]
Every citation is real. The sources genuinely say that. The bias entered at query formulation, which is why the output is indistinguishable from good research. You have received your own six-month-old hunch with footnotes attached - and it now looks more authoritative than when you wrote it, because it has citations.
With the wall.
Question: What recall differences are reported between shared multilingual embedding spaces and per-language indices at 10M+ documents?
Report: 3-8% degradation on domain-specific corpora - narrowing to under 2% when a per-language reranker is retained on top of shared retrieval. No published figures above 20M documents in legal-domain corpora.
Three concrete gains:
- A third option. Hybrid architecture - shared retrieval, per-language reranking - was in neither your doc nor your question. It was found because the search was open
- A quantified tradeoff. 3-8% versus under 2% is a number you can weigh against re-indexing cost. "It hurts recall" is not
- An honest gap. "No published figures above 20M in legal-domain corpora" tells you the evidence thins out exactly where your corpus lives - so the responsible next step is your own eval, not more reading. A contaminated run would have filled that silence with your own numbers
Scenario 4 - Post-incident, under pressure
The situation. Recall@10 dropped 12% overnight after a cluster upgrade. Everyone believes the upgrade caused it. You run research while the incident is open.
This is where the firewall matters most, because urgency is when confirmation bias is strongest. Under time pressure, the first plausible explanation gets adopted and everything after it becomes confirmation.
Without the wall. The subagent reads your incident notes - "recall dropped after upgrade to 2.13" - and searches "OpenSearch 2.13 recall regression". It finds something, because in a large project there is always some open issue mentioning recall. Report: a known regression may be responsible. The team spends a day on a rollback.
With the wall. The question goes out as: What changes to k-NN query behaviour, scoring, or default parameters were introduced in OpenSearch 2.13 relative to 2.11? An open question about what changed, rather than a search for a culprit.
Now the report can surface a default parameter change - a silently altered ef_search default, say - which is not a bug, would never appear under "regression," and is fixed with a config line rather than a rollback.
The concrete gain: the difference between hunting for evidence of a suspect and asking what actually changed. Also note the type pack's craft rule here: before citing a pain point, check whether it was since fixed - an old complaint against a current version is a false claim. An eighteen-month-old GitHub issue is exactly what a motivated search surfaces first.
Scenario 5 - Cold start for new items
The situation. New items get no impressions for days. Your PRD asserts: "cold start is a content-embedding problem - we need better item embeddings."
Without the wall. Every query is about embeddings, because the PRD framed the problem as an embedding problem. The report describes content-based embedding approaches for cold start. Entirely accurate, and it never questions the framing.
With the wall. The dimension goes out as: What approaches do production recommender systems use to surface items with no interaction history, and what tradeoffs are reported for each?
The answer space is much wider than embeddings: explicit exploration budgets, bandit approaches, position-boosting for new inventory, popularity priors, hybrid fallbacks. Several are ranking-policy changes rather than representation changes - cheaper to ship and independently testable.
The concrete gain: the firewall protected you from a premise you did not notice you had made. Your PRD had already converted "new items don't surface" into "our embeddings are inadequate," and once written, that conversion was invisible. This is the streetlight effect in practice - your documents made the embedding search easy, and the ranking-policy literature uses vocabulary your project never introduced.
Scenario 6 - Evaluation methodology
The situation. You want to validate your A/B design before a quarter-long test - metric choice, split strategy, minimum detectable effect.
This one is delicate, because your design is project context.
Without the wall. The subagent reads your test plan and searches for support for the metrics you chose. It confirms that NDCG@10 is standard, that your split is common, that your MDE is reasonable. All true. It does not tell you that your metric is known to be insensitive to the specific change you are testing, because it was never asked that question - it was asked to evaluate your plan, and evaluating a plan means finding the plan's merits.
With the wall. Two open dimensions:
- Which offline metrics are reported to correlate with online engagement in recommender A/B tests, and which are reported not to?
- What sample-size and duration considerations do practitioners report for detecting small ranking-quality changes?
Now the offline/online correlation literature can reach you - including the widely reported finding that offline ranking metrics correlate poorly with online outcomes for certain classes of change. That is a finding about your plan that a search for support of your plan would never surface.
The concrete gain: this is the offline/online gap from B.6, operating one level up. Your evaluation design was itself being evaluated against logged assumptions.
What the six have in common
flowchart LR
A["Your project<br/>supplies the question"] --> B["Firewall"]
B --> C["Open search finds<br/>what the field actually says"]
C --> D1["A parameter you<br/>weren't asking about"]
C --> D2["A third option<br/>nobody proposed"]
C --> D3["A gap in the evidence<br/>where your case lives"]
C --> D4["A premise you<br/>didn't know you had"]
style B fill:#1f3d2a,color:#fff
In none of the six did the firewall make the research more thorough. It made it capable of disagreeing. Every gain above came from a question being open rather than loaded - and in five of six, the useful finding was something nobody at the company would have thought to ask for.
B.9 What the Firewall Does Not Fix
Stated plainly, since the mechanism is easy to over-trust.
It does not stop a leading question. The firewall keeps your documents out. It cannot keep your framing out. "Research why shared embeddings underperform" sails straight through - the bias is in the question, and the question is the one thing the firewall is designed to let past. This is the load-bearing weakness. Framing quality rests on you and on the plan gate, which is why that gate is the skill's one hard stop.
It is prompt-enforced, not sandboxed. The lead orchestrator holds all your project context and is trusted to assemble clean briefs. No assertion in recon_kit.py and no test in test_recon_kit.py covers it. A leak fails silently - no error, just a slightly agreeable report. It sits closer to convention-enforced than architecture-enforced.
It does not improve source quality. Isolation buys independence, not credibility. That job belongs to the source-quality card: prefer primary sources; treat answer engines as single aggregators and chase their citations; resolve conflicts by recency, consistency, and publisher quality - never by averaging.
It does not fix training-data bias. Standing rule 1 exists as a separate rule precisely because the firewall does not cover it. Isolation from your documents is not isolation from what the model already believes about HNSW.
persistent_facts is a self-inflicted hole. Anything placed there travels into framing on every run. [] is the safe default for a reason.
It cannot make a decision for you. Research narrows uncertainty; it does not choose. A report saying "3-8%, narrowing to under 2% with a reranker" still leaves you weighing re-indexing cost against recall against team capacity. That weighting is yours, and it should be.
B.10 Glossary and Sources
Terms, in the order they were introduced
| Term | Short definition |
|---|---|
| Idea | Any thought that occurs to you. No quality bar |
| Hunch | A belief whose reasons you cannot state. Often good; never proof |
| Hypothesis | A guess specific enough that a result could prove it wrong |
| Falsifiability | The property of being contradictable by some possible observation (Popper) |
| Theory | Casually: a wild guess. Scientifically: a framework that survived decades of testing |
| Evidence | An observation from outside your own head bearing on a claim's truth |
| Warrant | Good reason to believe something; belief that has been earned |
| Claim | In BMAD: a finding with source, publisher, publication date, access date |
| Admissible | Allowed to count as evidence, judged by how it arrived - not only whether it's true |
| Hearsay | Second-hand testimony; excluded because the original observer can't be questioned |
| Chain of custody | Documented provenance of evidence through every hand it passed |
| Prior | What you believed before looking; legitimately shapes attention |
| Likelihood | What the new data says; the only thing that should update belief |
| Bias | Systematic error - one that leans consistently, rather than scattering |
| Confirmation bias | Seeking and noticing what supports what you already believe |
| Motivated reasoning | Scrutinising evidence more harshly when you dislike its conclusion |
| Anchoring | An initial number distorting later estimates, even when arbitrary |
| Streetlight effect | Searching where searching is easy rather than where the answer is |
| HARKing | Hypothesising after results are known, presented as prediction |
| Garden of forking paths | Many small analytic choices each tilting the same way, without intent |
| Blinding | Withholding treatment assignment from subject and/or assessor |
| Chinese wall | Structural information barrier inside one organisation. Also: ethical wall |
| Separation of duties | Splitting powers so no one party can both act and approve |
| Devil's advocate | Someone formally assigned to argue against a conclusion |
| Preregistration | Publicly recording hypothesis and analysis plan before collecting data |
| Adversarial collaboration | Disagreeing parties designing a test together, in advance |
| Organized skepticism | Structured criticism as routine practice, not as insult (Merton) |
| Nullius in verba | "Take nobody's word for it." Royal Society motto |
| Data leakage | Information reaching a model that should have been held out |
| Held-out set | Data deliberately made unreachable during training, used for honest evaluation |
| Temporal split | Train before time T, evaluate after - because prediction is about the future |
| Offline/online gap | Offline metrics improving while online tests show nothing |
Named works, for anyone who wants to read further
- Hans Reichenbach, Experience and Prediction (1938) - the discovery/justification distinction
- Karl Popper, Logik der Forschung (1934; English: The Logic of Scientific Discovery, 1959) - falsifiability
- Robert Merton, "The Normative Structure of Science" (1942) - organized skepticism, disinterestedness
- Peter Wason, "On the failure to eliminate hypotheses in a conceptual task" (1960) - the 2-4-6 experiment
- Amos Tversky and Daniel Kahneman, "Judgment under Uncertainty: Heuristics and Biases" (Science, 1974) - anchoring
- Richard Feynman, "Cargo Cult Science" (Caltech commencement address, 1974) - you are the easiest person to fool
- Ziva Kunda, "The Case for Motivated Reasoning" (Psychological Bulletin, 1990)
- Norbert Kerr, "HARKing: Hypothesizing After the Results are Known" (1998)
- Andrew Gelman and Eric Loken, "The Garden of Forking Paths" (2013)
- 1784 French Royal Commission on animal magnetism (Franklin, Lavoisier, Bailly) - an early blinded experiment
Where to read BMAD's own version
grep -A4 "research firewall" .claude/skills/bmad-deep-recon/SKILL.md
cat .claude/skills/bmad-deep-recon/references/run.md # brief contents
cat .claude/skills/bmad-deep-recon/references/verification.md # verifier, red team
cat .claude/skills/bmad-deep-recon/references/selection.md # the deliberate inversion
grep -n "persistent_facts" .claude/skills/bmad-deep-recon/customize.toml
Historical and academic attributions in this appendix are summarised from general knowledge rather than retrieved sources; the named works are given so they can be checked directly. The BMAD implementation details are quoted from the skill files shipped in PR #2611 and are verifiable in any v6 install.
Source: kimi ·
kimi.md· updated 2026-07-28 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
From GPT-2 to Kimi K3: The Complete Walkthrough
A layered explanation of the "22580" worklog — built up from zero background to kernel-level detail.
Table of Contents
Part 0 — The Whole Thing in 60 Seconds
Part 1 — Beginner: What Is Even Happening
- 1.1 What a language model actually does
- 1.2 Tokens: chopping text into pieces
- 1.3 Embeddings: turning pieces into numbers
- 1.4 Attention, explained without math
- 1.5 The stack: what a "layer" is
- 1.6 Generation is a loop, and the loop is wasteful
- 1.7 The KV cache: the fix, and the new problem it creates
- 1.8 The one idea the entire article is about
Part 2 — The Central Tension: Notebook vs Whiteboard
Part 3 — Intermediate: The Actual Mechanisms
- 3.1 Softmax attention, properly
- 3.2 Linear attention: the associativity trick
- 3.3 Why the fixed state goes wrong: interference
- 3.4 The delta rule: read before you write
- 3.5 The worked numeric example
- 3.6 Gating: forgetting without a replacement
- 3.7 KDA: per-channel forgetting
- 3.8 The five update rules, side by side
Part 4 — Senior: The Engineering Reality
- 4.1 Prefill vs decode: two completely different problems
- 4.2 Why FLOPs are the wrong metric
- 4.3 The chunking trick
- 4.4 The chunk-size dial: C=1 to C=N
- 4.5 Why the delta rule resists chunking
- 4.6 The WY reparameterization
- 4.7 What gating costs at the kernel level
- 4.8 Numerics: where this quietly breaks
Part 5 — Principal: Architecture and Trade-offs
- 5.1 Nobody ships pure linear attention
- 5.2 MLA: compressing the cache instead of eliminating it
- 5.3 Mixture-of-Experts: decoupling params from compute
- 5.4 SiTU and the latent-space expert
- 5.5 AttnRes: attention over depth
- 5.6 The full Kimi K3 layout
- 5.7 The three axes of retrieval
Part 6 — SME: Sharp Edges, Claims, and Open Questions
- 6.1 Where the article is loose or wrong
- 6.2 The "22,580" number is not what it looks like
- 6.3 What "beats full attention" does and doesn't mean
- 6.4 The state-capacity question nobody has answered
- 6.5 The expressivity ceiling (TC0 and all that)
- 6.6 Things that will probably change next
- 7.1 Track A: the weekend version
- 7.2 Track B: the production version
- 7.3 Track C: above and beyond
- 7.4 How to know if you actually understood it
Appendix A — Glossary Appendix B — Reading List in Order Appendix C — Notation Warning
Part 0 — The Whole Thing in 60 Seconds
A language model reading a long document has to remember what it read.
GPT-2's answer: keep everything. Store a record of every word you've seen. Perfect memory, but the storage grows forever and reading it back gets slower and slower.
The alternative: keep a fixed-size scratchpad. Squash every new word into the same fixed block of memory. Constant storage, constant speed — but things start smearing into each other once you exceed capacity.
Everything between 2019 and 2026 in this article is one question: if your memory is fixed-size, how do you decide what to throw away?
The answers, in order:
| Year | Idea | Eviction policy |
|---|---|---|
| 2017–19 | Softmax attention | None needed — memory grows forever |
| 2020 | Linear attention | None — just add everything on top of everything |
| 2021 | Delta rule | Erase the old value at this key, write the new one |
| 2024 | Gated delta | Also fade everything by a single dial |
| 2025 | KDA | Fade each dimension by its own dial |
| 2026 | Kimi K3 | Use all of the above, plus keep some exact-memory layers around |
That's it. The 2.8-trillion-parameter model is not "GPT-2 but 22,580× bigger." It's the same idea with four generations of increasingly clever forgetting bolted on.
Part 1 — Beginner: What Is Even Happening
Assume you know nothing. This section builds the mental model. If you already know what a KV cache is, skip to Part 2.
1.1 What a language model actually does
A language model does exactly one thing, over and over:
Given some text, guess the next word.
That's the whole job. "The capital of France is ___" → "Paris". You get a long response by doing this repeatedly: guess a word, glue it onto the end, guess again.
Everything in this article is about making that loop fast and accurate when the text gets long.
1.2 Tokens: chopping text into pieces
The model doesn't see words. It sees tokens — chunks of text, usually 3–4 characters. "waterloo" might split into water + loo. "the" is one token.
Why chunks instead of words? Because there are infinite possible words but you want a finite vocabulary. GPT-2's vocabulary is about 50,000 tokens. Every possible input gets expressed as a sequence of numbers from 0 to 50,256.
"waterloo is the best university"
↓ tokenizer
[water][loo][ is][ the][ best][ university]
↓ lookup
[15466, 1092, 318, 262, 1266, 6403]
1.3 Embeddings: turning pieces into numbers
A token ID like 15466 is meaningless as a number — it's just an index. So the model looks it up in a big table and gets back a vector: a list of numbers, in GPT-2's case 768 of them.
token 15466 → [0.13, -0.44, 0.02, ..., 0.91] (768 numbers)
That vector is the model's internal representation of that token. Similar tokens get similar vectors. This table (the "embedding matrix") is learned during training.
The model also adds a position embedding — a second vector encoding "this is the 5th token" — because otherwise the model has no idea what order things came in. GPT-2 adds these two together:
final input for position 5 = token_embedding(word) + position_embedding(5)
Now you have a matrix: one 768-number row per token. That matrix is what flows through the model.
1.4 Attention, explained without math
Here's the core problem attention solves.
Take the sentence: "The trophy didn't fit in the suitcase because it was too big."
What does "it" refer to? Trophy. But if you change "big" to "small", "it" now refers to the suitcase. To understand "it", the model has to look back at other words and figure out which ones matter.
Attention is the mechanism for looking back. For each token, it asks:
- Query — "what am I looking for?" (the token "it" emits a query meaning roughly "I need a noun I could be referring to")
- Key — every previous token advertises what it is ("trophy" emits a key meaning "I am a physical object, a noun")
- Value — the actual information to fetch if selected
The mechanism: compare the query against every key (a dot product — high number means "good match"), turn those scores into percentages that add to 100%, then take a weighted blend of the values.
"it" queries → trophy: 62% suitcase: 31% the: 4% fit: 3%
└────── weighted blend of their values ──────┘
↓
new representation of "it"
That's attention. Query, key, value. Q, K, V. You will see these three letters everywhere.
The word "softmax" just refers to the step that turns raw scores into percentages that sum to 1. It exponentiates each score (e^score) and divides by the total. That's it — softmax attention means "the normal kind."
1.5 The stack: what a "layer" is
One round of attention isn't enough. So models stack it. GPT-2 has 12 layers, each one:
- Normalize the numbers (keeps them from exploding)
- Do attention — mix information between tokens
- Add the result back to what came in (a residual connection)
- Normalize again
- Run an MLP — a small feedforward network that processes each token independently, no mixing
- Add that back too
def forward(self, x):
x = x + self.attn(self.ln_1(x)) # tokens talk to each other
x = x + self.mlp(self.ln_2(x)) # each token thinks alone
return x
The mental split that helps: attention moves information between positions. The MLP processes information at a position. Attention is communication; MLP is computation.
The x = x + ... pattern is the residual stream — think of it as a shared bus running down the whole model. Every layer reads from it and adds its contribution back. This will matter a lot in Part 5.
1.6 Generation is a loop, and the loop is wasteful
To generate text, the model:
- Runs the whole input through all 12 layers
- Takes the vector at the last position and maps it to 50,000 scores — one per possible next token
- Picks a token
- Appends it and runs the whole thing again
Notice something wasteful: the model computes a full representation for every position, but at step 2 it only uses the last row. All the other rows get thrown away.
Worse — on the next iteration, it recomputes those same rows again from scratch. If you've generated 500 tokens, you've recomputed the representation of token #1 five hundred times, and it was identical every time.
1.7 The KV cache: the fix, and the new problem it creates
The fix is obvious once you see it: save the keys and values from previous tokens and reuse them.
That storage is the KV cache. Each new token only computes its own Q, K, V, then attends against the saved K and V from everyone before it.
This turns a huge amount of redundant compute into a lookup. Great.
But now the cache is the bottleneck. Concretely, for GPT-2 at 16-bit precision:
per token = 12 layers × 2 (K and V) × 768 numbers × 2 bytes = ~37 KB
1,000 tokens → ~37 MB
Modern models are far worse — tens of gigabytes at long context. And here's the killer: on every single generated token, you must read the entire cache from memory. Not compute on it — just read it.
GPUs are wildly faster at arithmetic than at moving data. A modern GPU might do ~1,000 arithmetic operations in the time it takes to fetch one number from memory. So decoding becomes memory-bandwidth-bound: the GPU sits mostly idle, waiting for data.
This is the single most important fact in the article. Long-context generation is slow not because of math, but because of memory traffic. Every architecture from here on is trying to shrink or eliminate that traffic.
1.8 The one idea the entire article is about
So: the KV cache grows with sequence length, and reading it dominates your decode time.
What if it didn't grow?
What if instead of keeping every past key and value, you squashed them all into a fixed-size block of memory — say a 128×128 grid of numbers — that stays exactly the same size whether you've read 10 tokens or 10 million?
That's the whole idea. Fixed-size memory. Constant read cost per token. Constant storage.
The catch — and this is what makes the rest of the article interesting — is that you now have to decide what to throw away. A finite container that keeps receiving new things must overwrite, blend, or discard. Every architecture in this story is a different answer to how.
Part 2 — The Central Tension: Notebook vs Whiteboard
2.1 Two ways to remember
Imagine you're taking notes during a two-hour lecture.
Strategy A — the notebook (softmax attention). Write every sentence on a new line. Nothing is ever lost. When someone asks a question, you flip through and find the exact page.
- ✅ Perfect recall of any detail
- ❌ The notebook keeps getting thicker
- ❌ Answering a question means scanning the whole thing — slower every hour
Strategy B — the whiteboard (linear attention). One fixed whiteboard. Every new sentence gets merged into what's already there.
- ✅ Fixed size forever
- ✅ Answering is instant — glance at the board
- ❌ Things smear together
- ❌ Once it's full, new writing sits on top of old writing
That is the entire architectural debate of 2020–2026, and both metaphors map onto real math:
| Notebook | Whiteboard | |
|---|---|---|
| Real name | Softmax attention / KV cache | Linear attention / recurrent state |
| Storage | O(N) — grows per token | O(d²) — constant |
| Read cost per token | O(N) — grows | O(d²) — constant |
| Recall | Exact | Approximate, degrades |
| Prefill cost | O(N²) | O(N) |
2.2 Why nobody just picks one
The naive read is "whiteboard wins, it's constant-time." The reason it took six years and four papers is that the naive whiteboard is genuinely bad at things people need, specifically:
- "What was the API key in the config file I pasted 40,000 tokens ago?" — exact recall of a specific detail
- "Find every mention of
retry_countin this codebase" — precise, non-fuzzy lookup - Copying long strings verbatim
- Anything where "roughly the right answer" is worthless
These are called associative recall tasks, and they are precisely where a squashed fixed-size state falls over. The notebook nails them trivially.
So the arc of the field is: make the whiteboard smarter about what it erases, until it's good enough — and where it isn't, keep a few notebook pages around.
That last clause is why Kimi K3 is a hybrid, not pure linear attention. Hold that thought for Part 5.
2.3 The five-step storyline
Every step below fixes a specific, nameable flaw in the step before it. Memorize this ladder and the article becomes easy:
1. SOFTMAX ATTENTION
Memory grows forever. Reading it dominates decode time.
↓ fix: squash everything into a fixed grid
2. LINEAR ATTENTION
Constant memory! But everything is added on top of everything.
Information smears. Nothing ever leaves.
↓ fix: before writing, erase what's already at this spot
3. DELTA RULE (DeltaNet)
Clean overwrite of a specific fact. But you can ONLY replace —
you can't just free up space or clear the board.
↓ fix: add a global fade dial
4. GATED DELTA
Now you can fade the whole board. But it's one dial for
everything — you can't fade some things and keep others.
↓ fix: one dial PER DIMENSION
5. KDA / KIMI LINEAR
Fine-grained forgetting. Good enough to ship — but still
fundamentally lossy.
↓ fix: don't rely on it alone
6. KIMI K3
Hybrid: mostly fixed-size memory, with periodic exact-recall
layers, plus sparse experts, plus depth-wise retrieval.
Everything else — chunking, WY representations, einsums — is implementation detail for making these run fast on a GPU. Important detail, but detail.
Part 3 — Intermediate: The Actual Mechanisms
Now with math. Nothing here needs more than matrix multiplication and the idea of a dot product.
3.1 Softmax attention, properly
Let:
N= number of tokensd= dimension per head (GPT-2: 64, since 768 / 12 heads)Q, K, V= matrices of shapeN × d
The computation:
scores = Q Kᵀ / √d → N × N ("how much does each token care about each other token")
scores = mask(scores) → zero out the future (causal masking)
A = softmax(scores) → N × N rows sum to 1
out = A V → N × d
The N×N matrix is the problem. At N=100,000 that's 10 billion entries. Both the compute (O(N²d)) and, naively, the memory are quadratic.
Two separate fixes exist and people constantly conflate them:
- KV cache — fixes decode. Don't recompute past K,V. Turns per-step cost from O(N²) to O(N).
- FlashAttention (Dao et al., 2022) — fixes memory. Never materialize the N×N matrix; compute it in tiles inside fast on-chip memory. The FLOPs are still O(N²) — it just stops you from writing 10 billion numbers to slow memory.
⚠️ The article slightly blurs these. FlashAttention did not make attention subquadratic. It made it memory-efficient. That's a huge practical win, but a different one.
The key structural fact
Softmax is a nonlinearity applied after the Q·K product:
softmax(QKᵀ) V
Because softmax sits in the middle, you cannot reassociate this. You are forced to build the N×N matrix. That single fact is why attention is quadratic, and undoing it is the entire next section.
3.2 Linear attention: the associativity trick
Suppose you drop softmax and instead apply some function φ to Q and K separately, before they meet:
out = φ(Q) φ(K)ᵀ V
Now everything is plain matrix multiplication. And matrix multiplication is associative — you can choose where to put the parentheses:
( φ(Q) φ(K)ᵀ ) V ← N×N intermediate. Cost: O(N²d)
φ(Q) ( φ(K)ᵀ V ) ← d×d intermediate! Cost: O(N d²)
Read those two lines until it clicks. That's the entire trick.
φ(K)ᵀ V is (d×N) @ (N×d) = a d×d matrix. It doesn't depend on N at all. You've compressed the entire history into a fixed-size grid.
The article uses φ(x) = ELU(x) + 1. The only requirement is that it produces non-negative outputs, so the "attention weights" stay non-negative like softmax's do.
Written as a recurrence
Because the state is fixed-size and you build it by adding one outer product per token, this is literally an RNN:
S = 0 # d × d state
z = 0 # d normalizer
for each token t:
S = S + kᵀ v # write: outer product, rank-1 update
z = z + k
out = (q S) / (q z) # read: one matrix-vector product
That's the whole thing. Compare to softmax attention, where you'd have to loop over all t previous tokens.
| Softmax | Linear | |
|---|---|---|
| State size | N × d (grows) | d × d (fixed) |
| Decode cost/token | O(Nd) | O(d²) |
| Prefill cost | O(N²d) | O(Nd²) |
| Recall | Exact | Lossy |
The 2020 paper's title says it out loud: "Transformers are RNNs."
What you gave up
Softmax's exponential is sharp. e^10 is 22,026 times bigger than e^0. That means softmax can concentrate nearly all its weight on a single token — genuinely selective retrieval.
ELU+1 is nearly linear. Its scores are soft and flat. It cannot spike hard onto one key. You've traded a sharp selector for a blurry averager. That blurriness is exactly what shows up as bad associative recall.
3.3 Why the fixed state goes wrong: interference
Here's the failure, concretely.
You write two facts to the same fixed grid using the same key k:
S = 0
S = S + kᵀ v₁ # token 1: "the password is HUNTER2"
S = S + kᵀ v₂ # token 5: "actually the password is SWORDFISH"
Now read it back:
read = k S = k(kᵀv₁) + k(kᵀv₂) = ‖k‖²(v₁ + v₂)
You get both values summed together. Not the new one. Not the old one. A meaningless blend of the two.
The article's diagram calls this contamination, and the word is right: v₁ was never removed. It's still in there, corrupting every future read at that key.
Two related failure modes:
- Same-key collision (above) — literal overwrite that isn't an overwrite
- Capacity saturation — a
d×dgrid holds at mostdlinearly independent associations. Write more thandfacts and they must start overlapping. At d=128, that's 128 facts. A 100,000-token document has far more than 128 facts.
Schlag's Fast Weight Programmers paper states this plainly: endlessly adding new associations to a finite memory will inevitably hit a limit, and past that point the model needs to decide what to keep and what to delete.
3.4 The delta rule: read before you write
The fix is beautifully simple: before writing, check what's already there and subtract it.
v_old = k @ S # 1. what does this key currently retrieve?
u = β * (v - v_old) # 2. the DIFFERENCE — only what's actually new
S = S + kᵀ @ u # 3. write the difference, not the raw value
Walk through it:
- If nothing is stored at
k, thenv_old = 0, sou = βvand you write the full value. Same as before. - If
v_oldis already exactlyv, thenu = 0and you write nothing. Correctly recognizing "no new information." - If
v_oldis stale, you write exactly the correction needed to replace it.
Expand the algebra (with β = 1):
S_new = S + kᵀ(v − kS)
= S − kᵀk S + kᵀv
= (I − kᵀk) S + kᵀv
└────────┘
projects out the k direction
(I − kᵀk) — for a unit-length k — is a projection matrix. It surgically deletes the component of the state that lives along k, leaving everything else untouched. Then you write the new value in.
This is a generalized Householder transformation: identity plus a rank-one term. That phrase shows up in the paper and now you know what it means — "erase one direction, leave the rest alone."
What β does
β = sigmoid(W x) is a learned, per-token write strength between 0 and 1:
β = 1→ full replacementβ = 0→ don't write at all (skip this token)β = 0.5→ blend halfway
The model learns which tokens are worth committing to memory. That's already a form of selection.
Why this is called "fast weight programming"
S is a matrix that gets multiplied by a query to produce an output. That's literally what a weight matrix does. So the delta rule is the model writing to its own weights at inference time, using the classic Widrow-Hoff / delta learning rule from 1960s neural networks. Hence the name. It's an old idea repurposed as an architecture.
3.5 The worked numeric example
This is the best panel in the article — it makes everything above concrete. Both paths use key k = [1, 3], with ‖k‖² = 1 + 9 = 10.
Linear attention (broken)
Write v₁ = [2, 4]:
S = kᵀv₁ = [1] ⊗ [2 4] = [2 4]
[3] [6 12]
Write v₂ = [0, 1] at the SAME key:
S = [2 4] + [0 1] = [2 5]
[6 12] [0 3] [6 15]
Read back with q = k = [1, 3]:
[1 3] @ [2 5] = [2+18, 5+45] = [20, 50]
[6 15]
Expected v₂ = [0, 1], scaled by ‖k‖²=10 → [0, 10]. Got [20, 50]. The old value was never removed. Contaminated.
Delta rule (correct)
Step 1 — READ what's there:
v_old = (k / ‖k‖²) S = (1/10)[1 3] @ [2 4] = [2, 4] ✓ exactly v₁, recovered
[6 12]
Step 2 — FORM the correction:
u = v_new − v_old = [0 1] − [2 4] = [−2, −3]
Step 3 — WRITE the correction:
S_new = kᵀu = [1] ⊗ [−2 −3] = [−2 −3]
[3] [−6 −9]
S_total = [2 4] + [−2 −3] = [0 1]
[6 12] [−6 −9] [0 3]
Read back:
[1 3] @ [0 1] = [0, 10] ✓ exactly 10·v₂
[0 3]
The slot now holds exactly the new value. The old association is gone, not layered underneath.
This is the entire delta rule. If you understood this example, you understand DeltaNet.
3.6 Gating: forgetting without a replacement
The delta rule has a real limitation: it can only forget things it has a replacement for.
To erase a fact you must present its key and write something else there. But real scenarios need bulk forgetting:
- A document ends and a new one begins — clear everything
- The state is at capacity and needs room — decay the oldest stuff
- Some information was only ever locally relevant
You need a way to say "fade everything a bit" without naming what to fade.
The Mamba-2 answer
One multiplicative decay factor:
S = alpha * S_old + S_new # alpha ∈ (0, 1), learned per token
That's it. Before every write, shrink everything currently stored.
α = 1→ forget nothing (pure delta rule)α = 0→ wipe the board completelyα = 0.95→ gentle exponential decay
The effect compounds. A fact written at step x and read at step x + t has been multiplied by α_x · α_{x+1} · ... · α_{x+t} — a running product. Old information fades geometrically unless the model keeps choosing α near 1.
The article calls this "the multiplicative analogue of a prefix sum." That's exactly right, and it matters for implementation — you compute it with cumprod, and then divide two cumulative products to get "decay from step i to step j."
Gated Delta = both mechanisms
S_t = α_t · S_{t−1} (I − β_t kkᵀ) + β_t v kᵀ
└──┘ └───────────┘
global fade targeted erase
Two orthogonal knobs. α handles "make room / context switch." β and the projection handle "replace this specific fact."
3.7 KDA: per-channel forgetting
Gated delta's α is a single scalar per token. Every dimension of the state fades at the same rate.
That's crude. The dimensions of a state vector encode different kinds of information. Some dimensions might hold "the current speaker's name" (keep for a long time); others might hold "the syntactic role of the last word" (forget immediately). One shared dial can't express that.
Kimi Delta Attention's contribution: make α a vector, one value per channel.
Gated Delta: S_t = S_{t−1} · α_t · (I − βkkᵀ) + βvkᵀ α_t is a scalar
KDA: S_t = (I − βkkᵀ) · Diag(α_t) · S_{t−1} + βkvᵀ α_t is a vector
└────────┘
Diag(α_t) is a diagonal matrix with the α vector on the diagonal. Multiplying by it scales row i of the state by α_i. Each dimension now has its own independent forget rate.
Conceptually: instead of one fader on the mixing desk, you have one fader per channel. Same idea, finer control.
This is genuinely a small change to write down and, as Part 4 shows, a large change to implement efficiently.
3.8 The five update rules, side by side
The complete arc in one table. S is the state, k the key, v the value.
| Method | Update rule | Can it replace a fact? | Can it bulk-forget? |
|---|---|---|---|
| Linear attention | S ← S + kᵀv | ❌ | ❌ |
| Mamba-2 / gated linear | S ← α S + kᵀv | ❌ | ✅ globally |
| DeltaNet | S ← (I − βkᵀk) S + βkᵀv | ✅ | ❌ |
| Gated DeltaNet | S ← α(I − βkᵀk) S + βkᵀv | ✅ | ✅ globally |
| KDA | S ← (I − βkᵀk) Diag(α) S + βkᵀv | ✅ | ✅ per-channel |
Every row differs from the one above by exactly one term. That's what "each step fixes a concrete limitation" means in practice.
And for orientation, the thing they're all approximating:
| Softmax attention | cache ← concat(cache, [k,v]) | ✅ trivially | ✅ trivially |
Softmax attention doesn't need an eviction policy because it never evicts. Everything above is the cost of giving that up.
Part 4 — Senior: The Engineering Reality
Everything in Part 3 was math. None of it runs fast without this section.
4.1 Prefill vs decode: two completely different problems
LLM inference has two phases with opposite characteristics. Conflating them causes endless confusion.
Prefill — processing the prompt. You have all N tokens at once.
- Massively parallel, big matmuls, GPU runs near peak
- Compute-bound
- Cost: O(N²) for softmax attention
Decode — generating tokens one at a time. Each depends on the last.
- Strictly sequential. Tiny matrices. GPU mostly idle
- Memory-bandwidth-bound — you're reading the whole KV cache per token
- Cost: O(N) per token for softmax
Linear attention's headline win is decode: fixed-size state means constant bandwidth per token regardless of context length. That's where the "6× higher decode throughput" claim comes from.
But it creates a new prefill problem, and that's what Part 4 is mostly about.
4.2 Why FLOPs are the wrong metric
A modern datacenter GPU has roughly:
- ~1,000 TFLOP/s of matrix-multiply throughput (tensor cores, low precision)
- ~3–8 TB/s of memory bandwidth
The ratio is about 200–500 arithmetic ops per byte moved. If your kernel doesn't do at least that much math per byte it touches, you're bandwidth-bound and the arithmetic units idle.
This has three consequences that recur throughout the article:
- Fewer FLOPs ≠ faster. A shape that maps poorly onto tensor cores can be slower despite doing less work.
- Tensor cores want big tiles. They operate on blocks like 16×16 or 64×64. A matrix-vector product wastes ~99% of the hardware — it's the same instruction cost as a matrix-matrix product on a full tile.
- Fusion is everything. Two kernels that each read and write memory are far worse than one kernel that keeps intermediates in registers.
Now, why is that a problem for linear attention?
4.3 The chunking trick
Naive linear attention prefill is a for loop over tokens:
S = zeros(d, d)
for i in range(N):
S = S + k[i].T @ v[i] # rank-1 update — TERRIBLE for a GPU
out[i] = q[i] @ S
Every iteration is a rank-1 outer product and a matrix-vector product. Both are the worst possible shapes for a GPU. You've traded O(N²) FLOPs for O(N) FLOPs and made it slower, because you destroyed all the parallelism.
The chunked formulation splits the sequence into blocks of size C and processes each block with full matrix operations:
S = zeros(d, d)
for i in range(N // C):
q_c, k_c, v_c = chunk_i_of(Q, K, V) # each C × d
o_prev = q_c @ S # ← everything before this chunk,
# read from the state in ONE matmul
attn = (q_c @ k_c.T).tril() # ← WITHIN this chunk, do real
o_curr = attn @ v_c # masked softmax-style attention
o = o_prev + o_curr
S = S + k_c.T @ v_c # fold the chunk into the state
Read that carefully. It's doing two different algorithms at once:
- Inside a chunk: real quadratic attention (
QKᵀthen@V). Score-first ordering. - Across chunks: recurrent state. State-first ordering,
(KᵀV)thenQ@.
The token at position 500 attends to tokens 449–500 exactly (via the intra-chunk term) and to tokens 1–448 approximately (via the state). And every operation is a proper C×d @ d×C matmul — exactly what tensor cores want.
This is the single most important implementation idea in the whole architecture family.
4.4 The chunk-size dial: C=1 to C=N
C is a genuine interpolation knob between two known algorithms:
| C | What you get | Intra-chunk FLOPs |
|---|---|---|
C = 1 | Pure linear attention (recurrent) | 0 |
C = 64 | Typical production setting | small |
C = N | Full quadratic attention | everything |
The FLOP count splits cleanly:
total ≈ 2 L d² + 2 L C d
└──────┘ └─────┘
state work, intra-chunk score
independent of C matrices, grows with C
Set C = L and the second term becomes 2L²d — quadratic. That is full attention. The chunked formulation isn't an approximation of attention with a knob; at C=N it's literally identical to it.
The engineering punchline the article makes: C=1 minimizes FLOPs but not wall-clock time. C=64 or 128 does 64–128× more arithmetic in the intra-chunk term and runs faster, because that arithmetic maps onto tensor cores while the C=1 version maps onto nothing.
Why 64 or 128 specifically? Because that's the granularity of the hardware matrix instructions (wgmma on Hopper, UMMA on Blackwell). Below that you leave silicon idle; above that you start paying real quadratic cost.
4.5 Why the delta rule resists chunking
Chunking works for plain linear attention because the update is purely additive:
S_final = S_0 + Σ kᵢᵀvᵢ
Addition is commutative and associative. Order doesn't matter, so you can batch the whole chunk into one matmul.
The delta rule breaks this:
v_old = k_i @ S # ← depends on S, which depends on ALL previous writes
u_i = β * (v_i − v_old)
S = S + k_iᵀ @ u_i
To compute the correction for token i, you need the state after token i−1. Which needs the state after i−2. It's a hard sequential dependency — you cannot batch it naively.
4.6 The WY reparameterization
The DeltaNet paper's contribution is showing you can batch it, via algebra.
Step 1 — recognize the structure. The update is:
S_t = S_{t−1}(I − β_t k_tᵀk_t) + β_t k_tᵀv_t
Unrolling this over a chunk gives a product of Householder-like matrices:
S_t = S_0 · Π (I − β_j k_jᵀk_j) + (write terms)
Step 2 — apply the WY representation. There's a classical result in numerical linear algebra (Bischof & Van Loan, 1987): a product of C Householder reflections can be written compactly as I − W Tᵀ where T is C×C triangular. Products of rank-one corrections compress into one triangular matrix.
Step 3 — build T by forward substitution. This is what that odd loop in the code is doing:
T = -(K_beta @ K.T).tril(-1)
for i in range(1, C):
T[i, :i] = T[i, :i] + (T[i, :, None] * T[:, :i]).sum(-2)
T += eye(C)
W = T @ K_beta
U = T @ V_beta
The loop runs C times (64, not N), and each iteration is a small vectorized op. You've turned an O(N) sequential dependency into an O(C) one that only touches a C×C matrix.
Step 4 — the chunked forward becomes clean:
for i in range(L // C):
u_i = U[i] - W[i] @ S # ALL C corrections for this chunk, at once
o_inter = q_i @ S # contribution from prior chunks
A_i = (q_i @ k_i.T).tril()
o_intra = A_i @ u_i # within-chunk attention, using corrections
S += k_i.T @ u_i
O[i] = o_intra + o_inter
Compare to the plain linear-attention chunked loop in 4.3. It's structurally identical — the only difference is that v_c has been replaced by u_i, the corrected "pseudo-values."
That's the elegant part, and it's what the paper means by "DeltaNet simply replaces the value vector v with the pseudo value vector u." Once you've built the u's, everything downstream is ordinary linear attention.
4.7 What gating costs at the kernel level
This is where the article's three-way code diff earns its place, and it's the part most explanations skip.
Scalar decay (Gated DeltaNet) is nearly free.
g = alpha.cumprod(-1) # running product of decays
Gm = g[:, :, None] / g[:, None, :] # C × C matrix of pairwise decay ratios
T = ((K_beta @ K.T) * Gm).tril(-1) # ← elementwise multiply into existing matmul
Because α is a scalar per token, the cumulative decay between positions i and j is just a number. It multiplies the key-key product elementwise. You take the matmul you were already doing and scale its entries. Nearly zero marginal cost.
Vector decay (KDA) is not free.
g = alpha.cumprod(...) # now (nb, C, d) — a vector per position
Gm = g[:, :, None, :] / g[:, None, :, :] # rank-3: C × C × d
T = torch.einsum('brd,bsd,brsd->brs', K_beta, K, Gm).tril(-1)
The article's own code comment says it plainly: "Gamma is rank-3 so it must fuse INTO the contraction."
Because each dimension decays differently, the decay factor now varies along the contraction axis. You can't pull it out and apply it afterward — the summation over d has a different weight for each d. The clean A @ B becomes a three-operand einsum.
Practically:
- You lose the ability to call cuBLAS/cuDNN and must write a fused custom kernel (Triton or CUDA)
- Register and shared-memory pressure go up — you're now carrying a
C×C×ddecay tensor conceptually, even if you never materialize it - Tiling gets harder because the decay depends on all three indices
The general lesson: a change that adds one line of math can add weeks of kernel work. The gap between "the paper's equation" and "the thing that's actually faster" is where most of the real work in this field lives.
The article shows the same theme with SiTU: profiler traces of 111 ps vs 297 ps for the old vs new activation — ~3× slower unfused. The math change was trivial; the systems consequence wasn't.
4.8 Numerics: where this quietly breaks
Not in the article, but you will hit all of these the moment you implement it.
Cumulative products underflow. cumprod of a thousand values below 1 goes to zero fast. 0.99^1000 ≈ 4×10⁻⁵; in fp16 (min normal ≈ 6×10⁻⁵) that's already gone. And the implementation divides two cumprods — g_i / g_j — which is 0/0 the moment both underflow.
The fix: work in log space.
cumsum(log α)instead ofcumprod(α), exponentiate the difference. Or parameterize asα = exp(−softplus(x))and keep everything additive. Every production implementation does this.
State accumulation drifts. S accumulates thousands of rank-1 updates. In fp16 the error compounds. Production kernels keep S in fp32 even when Q/K/V are bf16.
The projection isn't exactly a projection. (I − βkᵀk) is only an exact projection when ‖k‖ = 1 and β = 1. That's why every implementation L2-normalizes K (and often Q) — you'll see F.normalize(F.silu(k)) in the code. Skip it and your "erase" leaves residue proportional to ‖k‖² − 1.
Chunk boundaries hide bugs. A common failure: the implementation works perfectly for N ≤ C (single chunk, intra-chunk path only) and is subtly wrong for N > C (state path kicks in). Always test with N spanning at least 3 chunks, and always test against a naive sequential reference.
Test recipe that catches most of it:
# The single most valuable test you can write
out_fast = chunked_impl(Q, K, V, beta, C=64)
out_ref = naive_sequential_loop(Q, K, V, beta) # obviously correct, slow
assert torch.allclose(out_fast, out_ref, atol=1e-4)
# Run with N = 1, 63, 64, 65, 128, 200 — boundaries are where bugs live
Part 5 — Principal: Architecture and Trade-offs
Why the final model looks the way it does — and what someone designing the next one would be thinking about.
5.1 Nobody ships pure linear attention
The most important architectural fact in the article, and it's easy to miss: Kimi K3 is 75% linear attention and 25% real softmax attention.
Structure: 23 macrocycles × 4 layers = 92 layers. In each macrocycle:
Layer 1: KDA (linear)
Layer 2: KDA (linear)
Layer 3: KDA (linear)
Layer 4: MLA (full softmax attention)
A 3:1 ratio. Not an accident, and not unique to Kimi — essentially every shipped "linear attention" model is a hybrid (Jamba, Zamba, Samba, MiniMax-01, Qwen3-Next all do some version of this). The empirical finding across the field is that a small fraction of full-attention layers recovers nearly all the recall quality while retaining most of the speed.
Why it works: the failure mode of fixed-size state is lossy recall, not bad reasoning. If even a few layers can do exact lookup over the full context, the model can route recall-critical work through those layers and use the cheap layers for everything else.
Why you'd want this ratio: KV cache cost is now ¼ of a dense model's. Decode bandwidth drops ~4×. But you still have real attention available at every 4-layer interval, so nothing is more than 3 layers away from exact retrieval.
The honest caveat: the cache still grows with N — just 4× slower. This is not a constant-memory architecture. It's a constant factor improvement on a linear problem. That distinction gets lost in a lot of marketing.
5.2 MLA: compressing the cache instead of eliminating it
Multi-head Latent Attention (from DeepSeek-V2) attacks the same problem from the opposite direction: keep exact attention, shrink what you store.
Standard attention: cache K and V for every head. Big.
MLA: project the input down to a small latent vector, cache that, and reconstruct K and V on the fly during attention.
standard: cache = K (n_heads × d_head) + V (n_heads × d_head) per token
MLA: cache = c (d_latent) per token
K, V = up_project(c) ← recomputed each time, from cache
The compression is typically ~10–20×. You trade a bit of extra compute (the up-projection) for a much smaller memory footprint — and since decode is bandwidth-bound, that trade is strongly favorable.
So Kimi K3 uses two complementary compression strategies simultaneously:
| Layer type | Strategy | Cache behavior |
|---|---|---|
| KDA (75%) | Fixed-size recurrent state | O(1) — constant |
| MLA (25%) | Compressed exact cache | O(N) but ~15× smaller per token |
That's a genuinely thoughtful design. Neither alone would be sufficient.
Gated MLA (K3's addition) puts a learned gate on MLA's output: out = gate ⊙ mla_out, where gate is projected from the input. It controls how much of the retrieved information is allowed onto the residual stream — a relevance filter on top of retrieval.
MLA query LoRA is a compute optimization: factor the query projection as a low-rank product instead of a full matrix. Fewer parameters and FLOPs on a path that doesn't need full rank.
5.3 Mixture-of-Experts: decoupling params from compute
MoE is orthogonal to the attention story but essential to understanding the "2.8 trillion parameters" headline.
Dense MLP: every token goes through the same feedforward network. Parameters and compute scale together.
MoE: you have many parallel feedforward networks ("experts"). A small router looks at each token and picks a few. Only those run.
Kimi K3, per the article: 898 experts total. 2 shared (every token) + 16 selected from the remaining 896.
So each token touches 18 of 898 experts — about 2% of the MoE parameters.
Why this matters:
- Parameters are cheap-ish. They're memory. They store knowledge.
- FLOPs per token are expensive. They're time.
MoE breaks the link between them. You get the knowledge capacity of a huge model at the compute cost of a much smaller one.
The shared experts are a DeepSeek innovation: 2 experts that always run, handling the common/general processing, so the 896 routed experts can specialize instead of all redundantly learning the basics.
What this costs you:
- All 898 experts must be resident in memory even though you use 18. Massive memory footprint, heavy sharding across GPUs.
- Routing is a discrete decision — hard to train. Needs load-balancing losses to stop the router collapsing onto a few favorites.
- At inference, experts for a batch are scattered — you get irregular, all-to-all communication patterns. This is one of the hardest problems in large-scale serving.
5.4 SiTU and the latent-space expert
Two changes bundled together in K3's expert design.
SiTU replaces SwiGLU. The standard gated MLP is:
out = W2( SiLU(W1 x) * (W3 x) )
K3's version:
situ_a = beta * tanh(gate / beta) * sigmoid(gate)
up = linear_beta * tanh(up / linear_beta)
out = situ_a * up
The beta * tanh(x / beta) pattern is a soft clamp: near-linear for small x, saturating smoothly to ±beta for large x. Applied to both the gate and the up path.
Best read as activation-level outlier control. Large-model training is plagued by activation outliers — they destabilize training and wreck low-precision quantization. A learnable soft clamp bounds them without the gradient death of a hard clip. Given the trend toward FP8/FP4 training, this looks like a deliberate quantization-friendliness choice, not a quality tweak.
The cost: three transcendental functions (tanh, tanh, sigmoid) instead of one (SiLU). The article's profiler trace shows 111 ps → 297 ps, roughly 3× slower unfused.
The offset — latent-space experts. The experts don't operate at full model width. Inputs are down_proj'd into a narrow latent space, the experts run there, and the result is up_proj'd back out.
x → down_proj → [experts run in compressed space] → up_proj → out
Narrower experts means smaller matmuls — the article says it nearly halves expert FLOPs. So the deal is: pay 3× on a cheap elementwise op, save ~2× on the expensive matmuls. Net win, assuming you write the fused kernel.
That "assuming you write the fused kernel" is doing a lot of work, and is a recurring theme: modern architecture choices increasingly assume a bespoke kernel exists.
5.5 AttnRes: attention over depth
This is the most novel piece in K3, and the one worth understanding most carefully — including where the framing oversells it.
The setup. The residual stream means layer l sees the sum of everything before it:
h_l = h_1 + Σᵢ₌₁^{l−1} fᵢ(hᵢ)
Every previous layer's output, weighted equally. Two problems:
- No selectivity. A KDA layer and an MLA layer get the identical aggregate, even though they might want different things from history.
- Residual dilution. By layer 80, you're adding your output into a sum of 79 other things. To have any influence, late layers must learn increasingly large outputs — which destabilizes training. (This is the well-documented "residual stream norm growth" problem.)
The fix — learn the weights:
h_l = α₀ · h_1 + Σᵢ αᵢ · fᵢ(hᵢ)
Now each layer picks which earlier representations matter to it.
How the weights are computed. This is where you should look at the code rather than the diagram:
V = torch.stack(blocks + [partial_block]) # [N+1, B, T, D]
K = norm(V) # keys ARE the normalized values
logits = einsum('d, n b t d -> n b t', proj.weight.squeeze(), K)
h = einsum('n b t, n b t d -> b t d', logits.softmax(0), V)
Three observations that matter:
- K and V are the same tensor. No separate key projection — keys are just normalized values.
- The query is one learned vector of shape
[d], fixed per layer. It is not projected from the current hidden state. softmax(0)normalizes over the block dimension.
So this is a learned static probe scored against normalized block outputs. The weights are content-dependent (the logits vary per token because K does), but it's much closer to "learned per-layer weighted average with content-sensitive weights" than to full QKV attention.
Calling it "attention over depth" is a fine intuition. As a literal description of the implementation, it's generous.
Why block granularity. Doing this at every layer would be expensive and would mean storing every layer's output. Instead, K3 accumulates 12 layers into one "block" representation and applies AttnRes at block boundaries. 92 layers ÷ 12 ≈ 8 blocks. You get most of the benefit at ~2% latency.
The cost claim doesn't hold up. The article says AttnRes adds ~2% latency and then, four paragraphs later, that the 8 blocks "increase our inference speed." Those contradict. The stated "1.25× compute advantage" is asserted with no mechanism given. Most plausible charitable reading: it's a quality-per-FLOP or convergence-speed claim, not throughput. As written, it's incoherent — flag it as unverified.
5.6 The full Kimi K3 layout
Assembling everything:
┌─────────────────────────────┐
input tokens ──────► │ embedding │
└──────────────┬──────────────┘
│
╔══════════════════▼══════════════════╗
║ MACROCYCLE (×23) ║
║ ║
║ ┌─────────────────────────────┐ ║
║ │ Norm → KDA → Norm → FFN │ ║ ← FFN dense only
║ ├─────────────────────────────┤ ║ in the very first layer
║ │ Norm → KDA → Norm → MoE │ ║
║ ├─────────────────────────────┤ ║
║ │ Norm → KDA → Norm → MoE │ ║
║ ├─────────────────────────────┤ ║
║ │ Norm → gMLA → Norm → MoE │ ║ ← full softmax retrieval
║ └─────────────────────────────┘ ║
╚══════════════════╤══════════════════╝
│
every 12 layers│(8 boundaries)
┌──────────────▼──────────────┐
│ AttnRes: weighted mix of │
│ all previous block outputs │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Norm → Linear → logits │
└─────────────────────────────┘
Reported specs from the article:
| Property | Value |
|---|---|
| Total parameters | 2.8 T |
| Layers | 92 (23 macrocycles × 4) |
| Attention mix | 3 KDA : 1 gated MLA |
| Experts | 898 (2 shared + 896 routed, top-16) |
| Expert space | compressed latent |
| Activation | SiTU |
| AttnRes | every 12 layers → 8 blocks |
| First-layer FFN | dense (not MoE) |
5.7 The three axes of retrieval
The cleanest way to hold the whole architecture in your head. K3 can retrieve information along three independent axes:
| Axis | Mechanism | Retrieves | Cost |
|---|---|---|---|
| Sequence (approximate) | KDA fixed state | Compressed summary of all prior tokens | O(1) per token |
| Sequence (exact) | Gated MLA | Any specific prior token, precisely | O(N), compressed |
| Depth | AttnRes | Earlier layers' representations | ~2%, every 12 layers |
Each addresses a different failure mode:
- KDA alone → loses specific details
- MLA alone → too expensive to run everywhere
- Both, but no AttnRes → late layers can't cleanly access early-layer features through a diluted residual stream
This is the article's actual thesis, and it's a good one: capacity was added where it has a specific functional role, not uniformly. That's a meaningfully different claim from "we made it bigger."
Part 6 — SME: Sharp Edges, Claims, and Open Questions
What to be skeptical about, and what the field genuinely doesn't know.
6.1 Where the article is loose or wrong
Worth cataloguing, because these are the spots where a casual reader picks up a wrong model.
"That's what Flash Attention fixes." FlashAttention is IO-aware tiling. It eliminates materializing the N×N matrix and slashes HBM traffic. The FLOPs remain O(N²). The thing that actually fixes per-decode-step redundant computation is the KV cache. The article's underlying point — that 2020-era reference implementations often had neither — is correct; the attribution is muddled.
AttnRes latency, self-contradicting. "~2% inference latency" and then "eight AttnRes blocks, which increases our inference speed." Both can't be true. The "1.25× compute advantage" is stated with no mechanism. Treat as unverified.
"Attention grows in O(N²) and this does not."
Said about the chunked formulation. Slightly imprecise — the chunked form has a 2LCd term that is quadratic in C. It's linear in L for fixed C, which is the point, but the phrasing suggests the quadratic term vanished. It didn't; it got bounded.
Notation drift.
The article switches between S = kᵀv / S(I − βkkᵀ) conventions mid-post (see Appendix C). Both are correct; they're transposes of each other. If you try to reconcile the code and the equations literally, you'll waste an hour.
Kimi Linear "outperformed full attention" is reported as the paper's claim without pushback. See 6.3.
6.2 The "22,580" number is not what it looks like
2.8 T / 124 M ≈ 22,580. Arithmetically fine. As a measure of "how much bigger," it's misleading in three ways:
1. Sparse vs dense parameters. GPT-2's 124M are all active on every token. K3's 2.8T are mostly dormant — ~18 of 898 experts fire. The honest comparison is active parameters per token, which the article doesn't give. Based on comparable MoE ratios, active params are plausibly in the tens of billions — a real number, but not 22,580×.
2. Compute per token grew far less than parameters. MoE exists precisely to decouple these. The FLOP ratio is probably 2–3 orders of magnitude smaller than the parameter ratio.
3. Training compute is the metric that actually predicts capability. Parameter count is a red herring in the Chinchilla-and-after era. Tokens × active params is what scaling laws are written in.
The number is a good hook. It is not a measurement of anything.
6.3 What "beats full attention" does and doesn't mean
The Kimi Linear claim deserves careful reading, because this exact claim has been made repeatedly since 2020 and mostly hasn't survived contact with scale.
What controlled comparisons usually control for: parameter count, training tokens, data mixture, sometimes FLOPs.
What they typically don't control for well:
- Tuning asymmetry. The new architecture gets weeks of hyperparameter search; the baseline gets defaults. This is the single most common source of illusory wins in architecture papers.
- Scale. Wins at 1–3B routinely vanish at 100B+. The gap between "linear attention matches attention" and "linear attention matches attention at frontier scale" is where most of these claims have historically died.
- Task selection. Perplexity and standard benchmarks are relatively forgiving of lossy recall. The tasks that punish it — long-context exact retrieval, multi-hop over long documents, in-context learning with many examples — are underweighted in most eval suites.
- The hybrid confound. "Kimi Linear beats full attention" is comparing a hybrid (which contains full attention layers) against pure full attention. That's a fair engineering comparison but a misleading framing — it's not evidence that linear attention alone is competitive.
What's probably genuinely true: a well-designed hybrid at a 3:1 or 7:1 ratio gives you most of full attention's quality at a large fraction of the decode cost. That's a real, valuable, well-replicated result across multiple labs. It's just a weaker claim than the headline.
6.4 The state-capacity question nobody has answered
Here's the question the field has not resolved: how much information fits in a d × d state?
Information-theoretically: a d×d matrix stores at most d linearly independent key-value associations, and realistically fewer once keys aren't orthogonal. At d = 128, that's on the order of ~128 facts per head per layer.
But models have many heads and many layers, and the "facts" are distributed and redundant. So the effective capacity is unknown, and there's no clean theory for it.
Open sub-questions:
- Does capacity need to scale with context length? If a model targets 10M tokens, does the state need to grow? Nobody knows. If yes, "constant memory" is a fiction at scale.
- What's the right head-dim vs head-count trade? More heads with smaller states vs fewer heads with bigger states.
d²per head,hheads → totalh·d². For fixed total, is it better to have 8 heads × 256² or 32 heads × 128²? Almost entirely empirical right now. - What's the optimal hybrid ratio, and does it depend on context length? Everyone uses 3:1 or 7:1. There's no principled derivation.
- Do gates actually learn interpretable retention? Do α values genuinely correlate with "this is a document boundary" or "this is a name worth keeping"? Very little interpretability work exists here.
If you want research-grade work in this area, capacity scaling is the most under-studied and most tractable question on this list.
6.5 The expressivity ceiling (TC0 and all that)
A theory result worth knowing, because it constrains what any of this can do.
Standard transformers with fixed precision are in the complexity class TC⁰ — they cannot solve certain sequential problems (like tracking permutation composition, or state tracking in general) regardless of size, because their computation depth doesn't grow with input length.
Recurrent architectures are theoretically better here — a real recurrence can maintain state across arbitrarily many steps. This is one of the deeper arguments for linear-attention-style models beyond speed.
But: DeltaNet-style updates are constrained. (I − βkkᵀ) with β ∈ [0,1] gives transition matrices with eigenvalues in [0,1] — they can only shrink or preserve, never reflect. Work on extending β to [0,2] (allowing negative eigenvalues, hence true reflections) shows measurably improved state-tracking ability. That's an active research direction and a concrete example of theory driving architecture.
The practical takeaway: linear attention isn't only "cheaper approximate attention." It's a genuinely different computational model with a different expressivity profile — better at some things attention structurally cannot do, worse at recall. Hybrids get both.
6.6 Things that will probably change next
Reasonable extrapolations, flagged as speculation:
- Learned hybrid ratios. Right now 3:1 is hand-picked. Somebody will make it learned, or per-layer adaptive.
- Variable state size by depth. Early layers may need less state than late ones. Nobody varies it.
- Better feature maps.
ELU+1andSiLU+L2normare crude. The gap between linear attention and softmax is fundamentally a kernel approximation problem, and that literature is underexploited. - Test-time training framings. There's a growing view that all of these — delta rule, gating, Mamba — are instances of online gradient descent on a memory objective at inference time. DeltaNet's update is literally one SGD step on
‖kS − v‖². Reframing everything as "what loss is the state minimizing, and with what optimizer?" is producing new architectures (e.g. momentum-based updates, second-order updates). This is probably the most generative current frame. - Hardware co-design. As gated variants demand three-operand einsums, expect either better compilers or hardware primitives that make them cheap.
Part 7 — Doing This Yourself
Three tracks. Start with A even if you intend to end up at C — the intuition from A makes B and C dramatically faster.
7.1 Track A: the weekend version
Goal: implement all five update rules from Part 3.8 and watch linear attention fail on a task the delta rule solves. Nothing here needs a GPU.
Step 0 — Baseline (1 hour)
Clone nanoGPT (karpathy/nanoGPT). Train the Shakespeare character-level model. It's ~10 minutes on any GPU, ~an hour on CPU. You now have a working transformer whose every line you can read.
git clone https://github.com/karpathy/nanoGPT && cd nanoGPT
python data/shakespeare_char/prepare.py
python train.py config/train_shakespeare_char.py --device=cpu --compile=False \
--max_iters=2000 --n_layer=4 --n_head=4 --n_embd=128
Read model.py. There are only ~300 lines. Find CausalSelfAttention.
Step 1 — The diagnostic task (1 hour) ← do this first, it's the whole point
Before touching architectures, build a synthetic associative recall task. This is what separates the methods, and it will show you the difference in minutes.
import torch
def make_mqar(batch, n_pairs, seq_len, vocab=64):
"""Multi-Query Associative Recall.
Sequence: k1 v1 k2 v2 ... kn vn <sep> k3 ? k1 ? ...
Model must output the value that followed each queried key.
This is EXACTLY the capability a fixed-size state struggles with."""
keys = torch.randint(2, vocab//2, (batch, n_pairs))
vals = torch.randint(vocab//2, vocab, (batch, n_pairs))
x = torch.zeros(batch, seq_len, dtype=torch.long)
y = torch.full((batch, seq_len), -100, dtype=torch.long) # -100 = ignore
x[:, 0:2*n_pairs:2] = keys
x[:, 1:2*n_pairs:2] = vals
# query phase
pos = 2 * n_pairs
perm = torch.argsort(torch.rand(batch, n_pairs), dim=-1)
for j in range(n_pairs):
if pos + 1 >= seq_len: break
idx = perm[:, j]
x[:, pos] = keys.gather(1, idx[:, None]).squeeze(1)
y[:, pos] = vals.gather(1, idx[:, None]).squeeze(1) # predict at key position
pos += 2
return x, y
Sweep n_pairs from 4 to 256. Plot accuracy per architecture. You will see the curves separate sharply — and that plot is worth more than any amount of reading.
Step 2 — Linear attention (30 min)
Replace CausalSelfAttention.forward with the recurrent form. Deliberately slow and obvious:
def forward(self, x):
B, T, C = x.size()
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
q, k, v = [t.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
for t in (q, k, v)] # B, nh, T, hd
q = torch.nn.functional.elu(q) + 1
k = torch.nn.functional.elu(k) + 1
hd = C // self.n_head
S = torch.zeros(B, self.n_head, hd, hd, device=x.device, dtype=q.dtype)
z = torch.zeros(B, self.n_head, hd, device=x.device, dtype=q.dtype)
outs = []
for t in range(T):
kt, vt = k[:, :, t], v[:, :, t] # B, nh, hd
S = S + kt.unsqueeze(-1) @ vt.unsqueeze(-2) # outer product
z = z + kt
qt = q[:, :, t]
num = (qt.unsqueeze(-2) @ S).squeeze(-2)
den = (qt * z).sum(-1, keepdim=True) + 1e-6
outs.append(num / den)
y = torch.stack(outs, dim=2).transpose(1, 2).reshape(B, T, C)
return self.resid_dropout(self.c_proj(y))
Train on MQAR. Watch it fail as n_pairs grows. That failure is the entire motivation for everything that follows.
Step 3 — The delta rule (20 min)
Add one projection and three lines:
# in __init__:
self.w_beta = nn.Linear(config.n_embd, config.n_head)
# in forward, replace the feature map:
q = F.normalize(F.silu(q), dim=-1)
k = F.normalize(F.silu(k), dim=-1)
beta = torch.sigmoid(self.w_beta(x)).transpose(1, 2) # B, nh, T
# in the loop, replace the write:
v_old = (kt.unsqueeze(-2) @ S).squeeze(-2) # READ
u = beta[:, :, t, None] * (vt - v_old) # DELTA
S = S + kt.unsqueeze(-1) @ u.unsqueeze(-2) # WRITE correction
out_t = (qt.unsqueeze(-2) @ S).squeeze(-2) # no denominator now
Rerun MQAR. The curve should move dramatically. That moment is the payoff for the whole exercise.
Step 4 — Gating (10 min)
self.w_alpha = nn.Linear(config.n_embd, config.n_head) # scalar gate
alpha = torch.sigmoid(self.w_alpha(x)).transpose(1, 2)
# in the loop, before the write:
S = alpha[:, :, t, None, None] * S
Then KDA — make it per-channel:
self.w_alpha = nn.Linear(config.n_embd, config.n_head * head_dim) # vector gate
# reshape to B, nh, T, hd, then:
S = alpha[:, :, t].unsqueeze(-1) * S # scale each row independently
Step 5 — Sanity checks (30 min)
Verify by construction, not by vibes:
# 1. Delta rule actually replaces
S = torch.zeros(4, 4)
k = F.normalize(torch.randn(1, 4), dim=-1)
v1, v2 = torch.randn(1, 4), torch.randn(1, 4)
S = S + k.T @ (v1 - k @ S)
S = S + k.T @ (v2 - k @ S)
assert torch.allclose(k @ S, v2, atol=1e-5) # ← exactly v2, no trace of v1
# 2. Linear attention does NOT
S = torch.zeros(4, 4)
S = S + k.T @ v1
S = S + k.T @ v2
print(k @ S, "vs", v2) # ← v1 + v2. Contaminated.
By the end of the weekend you'll have: all five rules implemented, a plot showing exactly where each breaks, and the delta-rule identity verified numerically. That's real understanding, not familiarity.
7.2 Track B: the production version
Goal: correct, fast, chunked implementations with real evaluation. Weeks, not days. GPU required.
B1 — Use the reference library first
fla-org/flash-linear-attention is the canonical implementation of everything in this article — DeltaNet, Gated DeltaNet, KDA, Mamba-2, GLA — in Triton.
pip install flash-linear-attention
Read the Triton kernels before writing your own. chunk_delta_rule and chunk_gated_delta_rule are the ones to study. You'll learn more from 200 lines of production Triton than from any paper.
B2 — Write your own chunked forward, then diff it
Non-negotiable methodology:
def test_chunked_matches_reference():
for N in [1, 31, 32, 33, 64, 65, 128, 200]: # boundaries are where bugs live
for C in [16, 32, 64]:
Q, K, V, beta = make_random_inputs(N)
fast = chunk_delta_rule_forward(Q, K, V, beta, C)
ref = naive_sequential(Q, K, V, beta)
assert torch.allclose(fast, ref, atol=1e-4), f"N={N} C={C}"
Write the naive version first. Keep it forever. Every optimization gets diffed against it.
B3 — The numerics work from 4.8
- Log-space gates:
cumsum(log α), nevercumprod(α) - fp32 state accumulation, bf16 everywhere else
- L2-normalize K (and usually Q)
- Test with sequences long enough to underflow: N ≥ 8192
B4 — Real evaluation
The point of evaluation here is to find where it breaks, not to produce a nice number.
| Layer | Benchmark | What it tells you |
|---|---|---|
| Synthetic | MQAR (from Zoology) | Raw associative recall capacity |
| Synthetic | Needle-in-a-haystack | Exact retrieval at position |
| Long-context | RULER | Multi-needle, tracing, aggregation |
| Long-context | LongBench / ∞Bench | Realistic long-document tasks |
| General | lm-evaluation-harness | Nothing regressed |
| Efficiency | Tokens/sec at N = 1K…1M | The actual reason you did this |
Report decode throughput as a curve over context length, not a single number. The whole point of linear attention is that the curve is flat. A single number hides that.
B5 — Controlled comparison, done honestly
If you're claiming an architecture win, you must:
- Match training FLOPs, not just parameters
- Tune the baseline as hard as your method (spend equal search budget — most papers don't, and it's the #1 source of fake wins)
- Run at ≥2 scales and show the trend, not one point
- Include at least one recall-heavy eval, since that's the known weakness
- Report variance across seeds
B6 — Serving
If you're deploying: the KV cache abstraction in every serving stack (vLLM, SGLang, TRT-LLM) assumes O(N) growth. A hybrid model has two memory types — a growing cache for MLA layers and a fixed state for KDA layers. Batching, paging, and preemption logic all need to understand both. This is genuinely nontrivial systems work and is usually the long pole in shipping one of these.
7.3 Track C: above and beyond
Research-grade. Pick one; each is months.
C1 — Write the Triton kernel yourself
The forward pass is tractable. The backward pass is the real work — you must differentiate through the chunked recurrence, and the WY representation's triangular inverse makes it genuinely hard. Compare your kernel's numerics and speed against fla. If you get within 20% you've learned an enormous amount.
Deliverable: a benchmark table across (N, d, C) vs fla and vs FlashAttention-3.
C2 — Measure state capacity empirically
The most under-studied question in the field (see 6.4), and it's tractable on a small budget.
- Fix architecture, vary head dim
d ∈ {32, 64, 128, 256} - On MQAR, find the max
n_pairsat which accuracy stays above 90% - Plot capacity vs
d. Is it linear?d log d?d²? - Repeat per method — does gating buy capacity or just flexibility?
- Repeat with heads/dim traded off at fixed total state
Nobody has published a clean version of this. It's a real contribution.
C3 — Hybrid ratio ablation
Everyone uses 3:1 or 7:1 and nobody says why. Sweep it — 1:1, 3:1, 7:1, 15:1, pure — at matched FLOPs, and also sweep placement (are full-attention layers better early, late, or evenly spread?). Measure quality vs decode throughput vs context length. This is the most immediately useful practical result on this list.
C4 — The test-time-training frame
Reframe every update rule as an optimizer step on a memory objective:
| Update rule | Equivalent to |
|---|---|
| Linear attention | Gradient step on −⟨kS, v⟩ (no normalization) |
| Delta rule | One SGD step on ½‖kS − v‖² with LR β |
| Gated delta | SGD + weight decay α |
| KDA | SGD + per-parameter weight decay |
Once you see it, obvious extensions appear: momentum on the state update, adaptive per-key learning rates, second-order updates, multi-step inner optimization. Several recent architectures are exactly this. It's the most generative frame currently available.
C5 — Expressivity work
Follow the β ∈ [0,2] thread from 6.5 — allowing negative eigenvalues in the transition matrix. Build state-tracking benchmarks (permutation composition, parity, bounded-counter automata) and measure which update rules can and can't learn them. Theory-meets-practice, and there's real room.
7.4 How to know if you actually understood it
Self-test. If you can answer these from memory, you have it:
- Why can't you reassociate
softmax(QKᵀ)Vbut you can reassociateφ(Q)φ(K)ᵀV? - Draw the
d×dstate after writing two values to the same key — under linear attention, and under the delta rule. - Why is
C=1cheapest in FLOPs but not fastest in wall-clock time? - What can gated delta forget that plain delta cannot? Give a concrete scenario.
- Why does making α a vector instead of a scalar break the matmul factorization?
- Why is Kimi K3 3:1 KDA:MLA rather than pure KDA? What breaks at pure?
- Why is "2.8T parameters" not comparable to "124M parameters"?
- What does
(I − βkᵀk)do geometrically, and what has to be true ofkfor it to work? - Where does the delta rule's sequential dependency come from, and how does the WY representation remove it?
- Name three ways a naive implementation silently produces wrong numbers.
If any of these are shaky, the fastest fix is Track A Step 5 — go verify it numerically in five lines. The delta-rule identity in particular is the load-bearing intuition for the whole article.
Appendix A — Glossary
| Term | Meaning |
|---|---|
| Associative recall | Retrieving a value given a key seen earlier in context. The task fixed-size states struggle with. |
| AttnRes | Kimi K3's mechanism for weighting earlier layers' outputs rather than summing them equally. |
| β (beta) | Per-token write strength in the delta rule, 0–1. How strongly to commit this fact. |
| α (alpha) | Decay/forget gate. Scalar in Gated DeltaNet; a vector (one per channel) in KDA. |
| Chunking | Splitting the sequence into blocks of size C to get matmul-shaped work out of a recurrence. |
| Delta rule | Read what's stored at a key, subtract it, write the difference. A 1960s learning rule reused as an architecture. |
| DeltaNet | Linear attention with the delta rule as its write mechanism. |
| Decode | Generating tokens one at a time. Sequential, memory-bandwidth-bound. |
| FlashAttention | IO-aware attention that never materializes the N×N matrix. Fixes memory, not FLOPs. |
| Feature map (φ) | Function applied to Q and K separately in linear attention, replacing softmax. E.g. ELU+1. |
| Gated MLA | MLA whose output is elementwise-multiplied by a learned gate. |
| HBM | High Bandwidth Memory — the GPU's main memory. Fast by normal standards, slow relative to its compute. |
| Householder transformation | I − βvvᵀ. Identity plus rank-one. Reflects/projects along one direction. |
| Hybrid | Model mixing linear-attention and full-attention layers. All shipped "linear" models are hybrids. |
| KDA | Kimi Delta Attention. Gated delta rule with a per-channel decay vector. |
| KV cache | Stored keys and values from previous tokens. Avoids recomputation; grows O(N). |
| Linear attention | Attention with softmax replaced by a separable feature map, enabling a fixed-size state. |
| MLA | Multi-head Latent Attention. Compresses the KV cache into a small latent, reconstructs K/V on read. |
| MoE | Mixture-of-Experts. Many feedforward networks, a router picks a few per token. Decouples params from FLOPs. |
| MQAR | Multi-Query Associative Recall. The standard synthetic benchmark for this capability. |
| Prefill | Processing the input prompt. Parallel, compute-bound. |
| Residual stream | The x = x + f(x) bus running down the model that every layer reads from and writes to. |
| RoPE | Rotary Position Embedding. Modern replacement for GPT-2's learned position embeddings. |
| SiTU | Kimi K3's activation. Soft-clamped (β·tanh(x/β)) gated unit — outlier control for low precision. |
| State (S) | The fixed-size d × d matrix that replaces the KV cache in linear attention. |
| SwiGLU | The standard modern gated MLP activation. W2(SiLU(W1 x) ⊙ W3 x). |
| Tensor core | GPU unit that does small matrix multiplies (e.g. 16×16) as a single instruction. Why chunking wins. |
| WY representation | Compact form for a product of Householder matrices. Lets DeltaNet chunk-parallelize. |
Appendix B — Reading List in Order
Read in this sequence. Each assumes the one before.
Foundation
- The Illustrated Transformer — Jay Alammar. Best zero-background introduction.
- Let's build GPT — Karpathy, video. Build one from nothing.
- Attention Is All You Need (2017) — Vaswani et al. Read it after the two above, not before.
The linear attention line 4. Transformers are RNNs (2020) — Katharopoulos et al. Where linear attention starts. 5. Linear Transformers Are Secretly Fast Weight Programmers (2021) — Schlag, Irie, Schmidhuber. The delta rule and the capacity argument. 6. Parallelizing Linear Transformers with the Delta Rule over Sequence Length (2024) — Yang et al. The WY representation. The hard one. 7. Gated Delta Networks (2024) — Yang, Kautz, Hatamizadeh. Adding the forget gate. 8. Kimi Linear (2025) — Moonshot AI. KDA and the fine-grained gate.
Context 9. FlashAttention (2022) — Dao et al. IO-awareness. Changes how you think about all of this. 10. Mamba / Mamba-2 (2023/2024) — Gu & Dao. The parallel lineage; Mamba-2's SSD framework unifies it with linear attention. 11. Zoology (2023) — Arora et al. Where MQAR comes from and why recall is the diagnostic. 12. DeepSeek-V2 (2024) — MLA and the shared-expert MoE design K3 builds on.
If you go deep
13. flash-linear-attention source (fla-org/flash-linear-attention). The Triton kernels are the real curriculum.
14. The Illusion of State in State-Space Models (2024) — Merrill et al. The expressivity/TC⁰ argument.
15. Test-Time Regression / TTT line — the unifying "state is doing online learning" frame from 7.3 C4.
Appendix C — Notation Warning
You will get confused reconciling the article's equations with its code. Here's why.
The state S can be laid out two ways, and both appear in this literature:
Convention 1 (used in most code): S has shape (d_k × d_v)
read: v = k @ S k is a row vector (1 × d_k)
write: S = S + kᵀ @ v
update: S ← (I − βkᵀk) S + βkᵀv
Convention 2 (used in most papers): S has shape (d_v × d_k)
read: v = S @ kᵀ
write: S = S + vᵀ @ k
update: S ← S(I − βkkᵀ) + βvkᵀ
These are transposes of each other. Same algorithm. The article uses both, sometimes in adjacent paragraphs.
Practical rule when reading any paper in this area: ignore the transposes, track the shapes. Ask only "what's d×d here, and what's the rank-one thing being added?" Everything else is bookkeeping.
Same caution for β:
- In DeltaNet,
βis the write strength (learning rate on the memory update). - In some other papers
βdenotes the decay/forget gate. - The article's SiTU code uses
self.betafor a third thing entirely — the soft-clamp threshold.
Three different βs. Check the definition every time.
Source: bmad-rcon-howto ·
bmad-rcon-howto.md· updated 2026-07-25 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Part 1.5 — Concept-by-concept how-to
Companion to bmad-deep-recon-foundations.md (what the terms mean) and bmad-deep-recon-guide.md (task-driven walkthroughs).
What this document is for. The foundations doc explains what a dimension, a topology, or a mode is. This one shows, for each concept, exactly what you type, exactly what comes back, and exactly what you do next. One concept at a time, in the order you'll meet them.
Section 20 does the same thing without BMAD at all — the same techniques applied in a plain Claude conversation, since dimensions, plan gates, source discipline and staleness are portable ideas, not BMAD features.
Fidelity note. Session excerpts are illustrative reconstructions. The interaction shape — what you're asked, what you can say back, what lands on disk — reflects the documented design. Exact wording lives in the skill's reference files and will differ. TOML key paths should be confirmed against your installed
customize.toml.
Contents
- 0. Setup
- 1. Mode: Draft
- 2. Mode: Process
- 3. Mode: Run
- 4. Type and pack
- 5. Decision shape: explore vs select
- 6. Dimension
- 7. Topology
- 8. The plan gate, line by line
- 9. Effort presets and budgets
- 10. Rounds and leads
- 11. The firewall
- 12. Verification and red team
- 13. Claims and statuses
- 14. Briefs, digests, memlog
- 15. Staleness, refresh, deepen
- 16. The run folder and slug
- 17. Headless and scripting
- 18. Configuration in practice
- 19. Recovering when things go wrong
- 20. All of this in plain Claude, without BMAD
- 21. One-page cheat card
0. Setup
npx bmad-method install # pick your IDE; deep-recon ships in core
Verify and look around:
/bmad-customize bmad-deep-recon
Read the options; change nothing yet. You're building a map of what's adjustable.
1. Mode: Draft
What Draft does: composes a research prompt carrying the pack's craft — pruned dimensions, freshness bars, source policy, a hard citation demand — tuned to the external tool you name. You paste it into that tool. No searching happens in your session.
Use it when: you already pay for ChatGPT/Gemini/Perplexity deep research, and the question is a broad public sweep.
What you type
> /bmad-deep-recon
draft a deep research prompt for Gemini: choosing a machine translation
vendor for EN↔FR, EN↔ES, EN↔JA, at roughly 40M characters/month.
Hard requirements: EU and Canada data residency, glossary and
do-not-translate support, and a DPA we can actually sign.
Type: competitive, select shape.
What comes back (shape)
DRAFTED PROMPT — for Gemini Deep Research
─────────────────────────────────────────
Research the commercial machine-translation vendor landscape for a buyer
with the following profile: [your volume, pairs, residency needs].
Cover these dimensions, in priority order:
1. Pricing structure at 40M chars/month — not list price, the actual
shape (per-character, committed-use tiers, minimums)
2. Per-pair quality evidence for EN↔FR, EN↔ES, EN↔JA specifically;
distinguish vendor-reported from independent
3. Data residency options for EU and Canada; processing locations
4. Glossary / do-not-translate / custom terminology feature parity
5. Contractual posture: DPA availability, sub-processors, training-on-
customer-data defaults, exit and portability terms
Source requirements:
- Every claim must carry publisher, publication date, access date
- Pricing and feature claims older than 6 months: flag as possibly stale
- Mark vendor self-reported quality claims explicitly as self-reported
- Prefer primary sources (vendor docs, pricing pages, trust centres)
over review-aggregator sites
Output: a comparison with an explicit "could not determine" section.
─────────────────────────────────────────
Paste this into Gemini. When you have the report back, run:
process <path to the report>
What you do
Check three things before pasting. This is the whole skill of Draft mode:
| Check | Why | Fix if missing |
|---|---|---|
| Are your numbers in it? | Volume and pairs change the ranking completely. A generic prompt gets a generic ranking | Add them by hand |
| Is the citation demand hard? | A soft demand produces a soft report | Strengthen it: "every claim, no exceptions" |
| Is the freshness bar stated? | Pricing from 2024 is fiction | Add "flag anything older than N months" |
Edit the prompt freely. It's a starting point, not an output.
Why it works this way
The value isn't that Deep Recon can write a prompt — you could. It's that the pack's craft gets embedded automatically: the dimension priorities, the source policy, the self-reported-claim flagging, the "could not determine" demand. Those are the things people forget, and they're the difference between a usable report and a marketing summary.
2. Mode: Process
What Process does: takes a finished report from anywhere, files the original untouched, extracts claims into digests behind the firewall, gap-checks against the pack, and distills the standard cited research.md.
Use it when: you have a document — from Draft mode, from an analyst, from a colleague.
What you type
> there's a research report at ~/Downloads/mt-vendors.pdf, process it
What comes back
PROCESSING — competitive pack
imports/mt-vendors.pdf preserved, unmodified
digests/d01-pricing-structures.md 14 claims extracted
digests/d02-quality-evidence.md 9 claims (6 flagged self-reported)
digests/d03-residency.md 7 claims
digests/d04-terminology-features.md 5 claims
GAP CHECK against the competitive pack
──────────────────────────────────────
COVERED
pricing, language coverage, published quality claims, API surface
GAPS — not covered by the imported material
✗ sub-processor lists and processing-location commitments
✗ training-on-customer-data defaults and opt-out terms
✗ exit terms and data portability
✗ per-pair quality for EN↔JA specifically (only aggregate given)
research.md written — 35 claims, 22 sources, 4 gaps flagged
What you do
Read the gap list, not the report. That's where the value is.
Notice the pattern in that example: every gap is either contractual or specific to you. Hosted research tools crawl broad public commercial surfaces well and are systematically poor at narrow contractual questions and at your particular parameters. That's the division of labour to internalize — and it tells you what to do next:
> run a focused pass on the gaps only — sub-processors, training-on-data
terms, exit/portability, and EN↔JA quality specifically.
Effort standard, validation high.
This lands in the same run folder, because the slug is deterministic across the draft → process → refresh lifecycle. One decision, one artifact.
Why it works this way
Preserving the original untouched is provenance: you can always go back to what the tool actually said, rather than trusting the extraction. Extracting behind the firewall means your project context can't shape which claims get pulled out.
3. Mode: Run
What Run does: native research in your session — plan gate, parallel fan-out, verification at landing, cited synthesis.
Use it when: you want it now, or you need tools only your session has (internal MCP sources, your own files, an authenticated API).
What you type
> /bmad-deep-recon
I need to choose a reranking approach for our recommendation retrieval
stage. Decision horizon ~12 months. Candidates to include but not to
confirm: cross-encoder rerank, learned sparse rerank, MMR-only diversity,
no rerank at all.
Constraints: 200ms p99 for the whole retrieval stage, of which rerank can
have at most 40ms. Multilingual content. We self-host; no hosted APIs.
Type: technical, select shape. Effort: standard. Validation: high.
What comes back
The plan gate — covered in detail in §8. You approve or amend, and then:
ROUND 1 — breadth-first, 3 assistants
[dim 1] latency/throughput ................ 8 sources → digest landed
[dim 2] quality delta evidence ............ 8 sources → digest landed
[dim 3] multilingual behaviour ............ 8 sources → digest landed
verifying load-bearing claims as they land...
§ "Latency and throughput" written
LEADS FROM ROUND 1
- Two sources disagree on cross-encoder latency by ~3x; the difference
appears to be batch size and hardware, not the model
- Learned-sparse rerank quality claims are almost entirely from one
research group
ROUND 2 — depth on the two leads
...
What you do
Watch for two things and speak up:
- Aggregator pileup. If several digests trace to the same summary site, that's one publisher wearing several hats: "dimension 2 is leaning on aggregators — chase the primary papers."
- Leads not being chased. Disagreement is the most valuable signal in the run. If round 2 isn't going after it, say so.
You can interject at any point; you don't have to wait for a checkpoint.
4. Type and pack
How to set it. Three ways, in increasing explicitness:
> research the MT vendor landscape # type inferred
> competitive research on the MT vendor landscape # named in prose
> Type: competitive. # stated as a parameter
How to see what a pack changes
Ask before approving:
> what does the competitive pack prioritise, and what are its freshness
windows?
You'll get the card: dimension priorities, source craft, freshness per claim class. Reading this once for each type you use is 5 minutes well spent — it's how you learn what to add at the plan gate, because you'll see what the pack considers out of scope.
Choosing when it's ambiguous
| Your question | Type | Why not the other one |
|---|---|---|
| "What do DeepL and Google Translate actually offer?" | competitive | Named rivals, teardown shape |
| "How does the localization industry handle post-editing?" | domain | Learning a field's structure and practice, not comparing vendors |
| "Which embedding model should we use?" | technical | Evaluating a technology area for implementation |
| "Does EMA-teacher SSL beat contrastive for retrieval?" | academic-lit | The evidence base is papers |
| "What do users hate about recommendation feeds?" | user-voice | Lived experience, reviews, communities |
| "How big is the legal-tech translation market?" | market | Sizing an opportunity |
When two fit, run two. A vendor decision often needs competitive (what they offer) and domain (what the regulations require of anyone offering it). Merging them gives you a report that's shallow on both, because they want different sources.
5. Decision shape: explore vs select
Two independent axes: type = the subject matter, shape = the form the conclusion takes.
explore — you don't yet know the option space
> technical research: how are teams handling multilingual embedding
drift as models get re-versioned? Explore shape.
Output: a structured understanding. Approaches, trade-offs, who does what, where the disagreements are. No recommendation, because you haven't defined what you're choosing between.
select — you're choosing, and you know the candidates
> technical research, select shape: choose between cross-encoder rerank,
learned sparse rerank, and MMR-only, under a 40ms budget.
Output: everything explore gives you, plus a weighted matrix where the weights are your stated constraints.
The mistake, and how to avoid it
Running select too early narrows prematurely. If you name three candidates before you know whether there's a fourth category you've never heard of, the matrix will confidently rank three options out of a space you haven't mapped.
The sequence when you're genuinely unsure:
1. > ... explore shape # map the space, cheap preset
2. read the report; now you know the real option space
3. > ... select shape, candidates: [what you learned]
Two cheap runs beat one expensive run that answered the wrong question.
6. Dimension
This is the concept with the highest leverage, so here it is as a full interaction.
Seeing them
Dimensions appear at the plan gate. You don't create them from nothing — the pack proposes, you edit.
DIMENSIONS (5)
1. Latency and throughput under our budget
2. Quality delta versus no-rerank baseline
3. Multilingual behaviour and per-language variance
4. Serving cost and hardware requirements
5. Implementation and operational complexity
The four operations
DELETE — the one you'll use most.
> Drop dimension 5. We have the eng capacity; complexity isn't a
differentiator for this decision.
Why: each dimension costs a full fan-out. At deep, cutting one saves roughly 15% of the run. Dimensions that don't bear on the decision aren't neutral — they dilute.
ADD — the one that determines whether the report is useful.
> Add a dimension: behaviour under filtered ANN search. Every benchmark
I've seen measures unfiltered top-k, but our production path always
applies filters, and I suspect that's where the approaches diverge.
Why: the pack is generic by design. Only you know your constraints. In that example the addition is the difference between a report that answers your question and one that answers a similar-looking question.
SPLIT — when one dimension is secretly two.
> Split dimension 2. "Quality delta" for us is two separate questions
with different evidence bases: published benchmark deltas, and
reported production A/B results. Keep them separate — the benchmark
literature and the engineering-blog literature don't overlap.
Why: merged dimensions get one brief, one set of sources, and a shallow answer to both halves.
MERGE — when two would hit identical sources.
> Merge 1 and 4. Latency and hardware cost come from the same
benchmarks; running them separately duplicates the search.
How to tell whether your dimension set is good
Three questions, in order:
- Does it span the decision? If you imagine the report coming back complete, is there any way you'd still be stuck? That's a missing dimension.
- Is anything here not load-bearing? If a dimension's answer wouldn't change your choice, delete it.
- Would two of these send an assistant to the same sources? Merge them.
The test that catches most errors
For each dimension, finish this sentence:
"If this comes back saying ___, I choose differently."
If you can't complete it, the dimension isn't decision-relevant. Delete it or reframe it.
7. Topology
Topology is proposed for you. You mostly review it — but knowing how to override is what lets you fix a badly-shaped run before it costs you 40 minutes.
Seeing it
TOPOLOGY breadth-first (dimensions are largely independent)
The three, and what each looks like in practice
Breadth-first — the default, and right most of the time.
ROUND 1 — breadth-first, 3 assistants
[dim 1] ──→ assistant A ──→ digest
[dim 2] ──→ assistant B ──→ digest all in parallel
[dim 3] ──→ assistant C ──→ digest
Use when the dimensions are genuinely independent. You get even coverage across everything.
Depth-first — one question, multiple angles, iterating.
> Use depth-first. This is really one question — whether the reported
cross-encoder latency numbers hold on our hardware class — and the
disagreement between sources IS the problem.
ROUND 1 — depth-first, 3 assistants, one question
assistant A: vendor and library documentation
assistant B: independent benchmarks and reproductions
assistant C: production engineering reports
↓
synthesise, identify what's actually in dispute
↓
ROUND 2 — chase the disputed mechanism
Use when you have one contested question and the contradiction matters more than breadth.
Straightforward — one assistant, small budget.
> This is a lookup. Straightforward topology, quick preset.
Just: what's the current license on [model], and has it changed
in the last 12 months?
Use for facts. Ten agents on an easy question just burns tokens — this is the most common waste in the whole system.
How to tell the proposal is wrong
| Signal | What it means | What to say |
|---|---|---|
| Breadth-first proposed, but your dimensions all restate one question | The decision was framed too narrowly to fan out | "This is one question — go depth-first" |
| Depth-first proposed for six separable dimensions | The framing collapsed things that should be parallel | "These are independent — breadth-first" |
| Six assistants for something you could Google | Over-provisioned | "Straightforward, quick" |
| Estimate is 50+ minutes for a decision you'll revisit next sprint | Effort/reversibility mismatch | "Drop to quick" |
Why knowing the algorithms helps
BFS and DFS are graph-traversal algorithms (see the foundations doc, §4.6). Their known weaknesses transfer directly:
- BFS holds the whole frontier in memory → breadth-first research burns more tokens per round. If you're context-constrained, fewer dimensions beats a lower preset.
- DFS can go far down a wrong corridor → depth-first research on a question that turns out to be a side issue wastes the entire run. Only go depth-first when you're confident the question is central.
8. The plan gate, line by line
The single hard stop. Here's how to read every line of it.
DECISION ← ① is this actually your decision?
Select a reranking approach for recommendation
retrieval; 12-month horizon, 40ms budget.
DIMENSIONS (5) ← ② prune, add, split, merge
1. Latency and throughput
2. Quality delta vs. no-rerank baseline
3. Multilingual behaviour
4. Serving cost and hardware
5. Implementation complexity
TOPOLOGY breadth-first ← ③ right shape?
EFFORT standard — 3 assistants / 8 sources
per round / 2 rounds ← ④ matches reversibility?
VALIDATION high; red_team off ← ⑤ do you have a prior?
FRESHNESS benchmarks 12mo · libraries 6mo ← ⑥ fast-moving field?
ESTIMATE ~22 minutes ← ⑦ matches your patience?
Approve, or tell me what to change.
The seven checks
| # | Check | Bad answer looks like | What to say |
|---|---|---|---|
| ① | Is the restated decision yours? | Broader or vaguer than what you meant | "The decision is narrower: ..." |
| ② | Do dimensions span it, with nothing dead? | Generic list; nothing specific to you | Delete / add / split / merge (§6) |
| ③ | Right topology? | Fan-out on a lookup | "Straightforward" / "depth-first" |
| ④ | Effort matched to reversal cost? | deep on a sprint-reversible choice | "Drop to quick" |
| ⑤ | Do you already believe the answer? | red_team off when you walked in convinced | "Turn red_team on" |
| ⑥ | Freshness bar tight enough? | 24-month window on a fast field | "Benchmarks older than 12 months are history" |
| ⑦ | Will you actually stay? | 45 min when you have 15 | "Drop to standard" |
A realistic reply
Approve with changes:
- Drop 5; complexity isn't a differentiator here
- Split 2 into published-benchmark evidence vs. production A/B reports
- Add: behaviour under filtered ANN search, not just unfiltered top-k
- Keep breadth-first and standard
- red_team on — I'm going in expecting cross-encoder to win, so argue
against it
Why you get exactly one gate
Constant confirmation trains you to click through. One gate that genuinely matters gets read. Everything after it is light checkpoints. Your attention here is the single highest-value input you give the system — a well-cited report answering a badly-framed question is the most expensive failure mode available.
9. Effort presets and budgets
Setting it
> ... effort: deep
> ... quick preset, this is just a sanity check
The presets
| Preset | Assistants | Sources/round | Rounds |
|---|---|---|---|
quick | 2 | 5 | 1 |
standard | 3 | 8 | 2 |
deep | 6 | 12 | 3 |
Precedence — memorize this
Your request beats a pinned setting beats the preset. Whatever you say in the moment wins. So you can pin standard in TOML and still say "deep for this one" without editing config.
Overriding one axis only
You don't have to take a preset whole:
> standard preset, but 3 rounds instead of 2 — I expect the first round
to surface contradictions and I want them chased properly
Changing effort mid-run
> stop after this round and write up what you have — I have less time
than I thought
The stop-and-write valve exists precisely for this. Files-first means everything already landed is kept.
How to choose
Scale to reversal cost, not to how interesting the question is:
| Reversal cost | Preset |
|---|---|
| Undo next sprint | quick |
| Weeks of work to undo | standard |
| Months, or contractual, or regulatory | deep + validation high |
10. Rounds and leads
A round is one full cycle of parallel searching, followed by reassessment. Leads are what round 1 produces that shapes round 2: contradictions, unexpected connections, gaps.
Watching leads form
LEADS FROM ROUND 1
- Two sources disagree on cross-encoder latency by ~3x
- Learned-sparse quality claims trace to one research group
- No source addresses filtered-search behaviour at all
Steering round 2 — this is where you add most value mid-run
> For round 2: the 3x latency disagreement is the most important thing
here. Chase the mechanism — I suspect it's batch size and hardware
class rather than the model. Deprioritise the sparse-rerank lead;
single-group evidence is enough for me to discount it.
Contradictions are the highest-value lead type. When two credible sources disagree, one of three things is true, and all three are worth knowing:
- The claims are about different conditions (usually — and the conditions are the real finding)
- One source is wrong (which tells you about that source)
- The field genuinely disagrees (which means your decision carries more risk than the report's tone suggests)
When rounds stop
A dimension stops early when its questions are answered or a full round surfaces nothing new. You can force it:
> dimension 3 is done — stop there and move on
11. The firewall
The rule: your project context may shape what gets asked. It may never shape what counts as evidence. Subagents receive only their brief.
Working with it correctly
✅ Our retrieval stage has a 200ms p99 budget and we self-host.
Given those constraints, what reranking approaches are viable?
Your context became a constraint on the question. Legitimate — that's the context of discovery.
Breaking it by hand — what not to do
❌ Here's our current architecture doc [paste]. Research whether our
reranking approach is the right one.
You've supplied the conclusion as context. You'll get a well-cited report explaining that your approach is sound. It will be persuasive and worthless.
The tell: if you're pasting a design in and asking "is this right?", stop. Reverse the order:
✅ 1. What do the constraints and evidence say the right approach is?
2. [read the report]
3. Now compare your design yourself.
That ordering is the discipline. It costs you nothing and it's the difference between research and justification.
When you genuinely need internal context
Sometimes the research genuinely requires internal sources — your own metrics, an internal wiki, a private API. The firewall doesn't forbid this; it's about where the context enters:
> Use our internal metrics MCP for dimension 1 only — I need our actual
p99 distribution, not published benchmarks. Keep dimensions 2-4
external-only.
Scoping internal sources to a specific dimension keeps the rest of the run clean. What you're avoiding is internal context bleeding into dimensions where it would bias the external evidence gathering.
Checking it held
> did any dimension's findings rest on context I supplied rather than
retrieved sources?
A clean run answers "no." If the answer is anything else, that's a finding about the report's reliability.
12. Verification and red team
Setting the level
> ... validation: high
| Level | What it does | Use for |
|---|---|---|
normal | Spot-checks the claims the recommendation rests on | Most runs |
high | Cross-checks the pack's critical claim classes; red-teams major conclusions | Load-bearing decisions |
max | Checks everything; full-breadth red-team | Regulatory, contractual, security |
Turning on red team, and why the phrasing matters
> red_team on. I'm going in believing cross-encoder rerank is the right
answer — argue the strongest case against it.
Naming your prior explicitly gives the adversarial pass a target. "Red team this" is weaker than "here is what I believe; attack it."
The rule for when: turn it on exactly where you already believe the answer. Nobody walks into a security or privacy review neutral — you walk in believing you're fine. That's the condition it exists for. It's off by default because it costs tokens, not because it's optional for high-stakes work.
Challenging a specific claim mid-run
> claim [7] is doing a lot of work in this recommendation and it's from
a vendor blog. Verify it independently or downgrade it.
What verification is not
The system checking a claim is the same system that produced it. That's a coherence check, not independent corroboration. Useful, weaker than the word suggests. Treat high validation as "the obvious errors were caught," not "this is confirmed."
13. Claims and statuses
Every claim carries a status: unverified → verified → disputed → overturned.
Reading them
[4] Cross-encoder rerank adds 35-60ms at batch size 32 on A10-class
hardware. [verified · 2 independent sources · pub 2026-02, 2026-04
· accessed 2026-07-25]
[7] Learned-sparse rerank matches cross-encoder quality at 40% the
latency. [disputed · vendor-reported · single research group ·
pub 2025-11 · accessed 2026-07-25]
What to do with each status
| Status | Meaning | Your move |
|---|---|---|
unverified | Found, not checked | Fine for background. Never let it be load-bearing |
verified | Spot-checked against another source | Usable |
disputed | Credible sources disagree | The disagreement is the finding. Understand the conditions before choosing |
overturned | Superseded by newer evidence | Kept visible on purpose — check whether it fed a downstream decision |
Why overturned claims aren't deleted
Because erasing the old belief destroys the audit trail that makes the new one trustworthy. If you can rewrite history, nobody can tell "this was always true" from "someone changed it." Same reason version control doesn't delete old commits.
Challenging the tally
> how many claims in the recommendation section are still unverified?
That count comes from the deterministic script, not the model's estimate — it's a real count.
14. Briefs, digests, memlog
Inspecting a brief — what one assistant was actually told
cat _bmad/planning/rerank-selection-2026-07/briefs/latency-throughput.md
Why look: if a dimension came back thin, the brief usually shows why — it was too broad, or too narrow, or it didn't mention your hardware class. That diagnoses whether to re-run the dimension or reframe it.
Inspecting a digest — what one assistant found
cat _bmad/planning/rerank-selection-2026-07/digests/d01-latency.md
Why look: trace one claim from research.md back through its digest to its source. Do this once, early. After you've seen how a claim in the report relates to what was actually retrieved, you'll never over-trust a polished report again.
Reading the memlog
tail -40 _bmad/planning/rerank-selection-2026-07/memlog
Append-only, so it shows the sequence — including claims whose status changed. ref= and status= entries are what the deterministic script tallies. Last status wins.
Why files-first exists
Everything lands on disk when it exists, not at the end. Consequences you'll actually feel:
- A run that dies at minute 30 resumes from disk
- The report builds in front of you instead of behind a spinner
- You can inspect intermediate work and intervene
15. Staleness, refresh, deepen
The staleness map
At the bottom of every report:
STALENESS MAP
Fast-moving (re-check ~3 months)
[4] latency figures — hardware and runtime releases move these
[11] library versions and API surface
Medium (~12 months)
[7] published quality benchmarks
Slow (~24 months)
[2] license terms
Turn this into calendar entries the same day. This is the step everyone skips, and skipping it is what turns a living asset into a snapshot.
Refresh — time has passed
> refresh the rerank research
CONFIRMED (9)
CHANGED (2)
[4] latency improved ~25% after a runtime release
[11] library API changed; migration guide published
OVERTURNED (1)
[7] the quality parity claim was retracted by the authors
⚠ Claim [7] is referenced by:
docs/decisions/0021-reranking.md
That warning is why the lifecycle exists. What you do: open the ADR, note the change, decide whether it changes anything. Often it doesn't. The value is that the question got asked.
Deepen — a dimension was under-answered
> deepen the filtered-ANN-behaviour dimension
Drills one dimension without re-running the rest. Minutes, not tens of minutes.
Choosing between them
| Situation | Command |
|---|---|
| Months have passed | refresh |
| A dimension was in scope but thin | deepen |
| A genuinely new question | new run |
| The decision itself changed | new run — the old one's dimensions were built for a different decision |
16. The run folder and slug
Finding your runs
ls _bmad/planning/
# rerank-selection-2026-07/
# mt-vendor-selection-2026-07/
# embedding-model-selection-2026-06/
The slug is deterministic — generated by the script, not the model — so draft, process, and refresh of the same decision all land in the same folder.
Anatomy
rerank-selection-2026-07/
├── imports/ # originals, untouched — provenance
├── digests/ # extracted claims — the working layer
├── briefs/ # what each assistant was told — file-based, never shell
├── memlog # append-only sequence — the truth
└── research.md # the canonical cited report — the artifact
Where to put it in your repo
docs/decisions/
├── 0021-reranking.md # ADR: what we chose, and why
└── 0021-research.md # the evidence, with provenance
The ADR tells you what in six months. The research file tells you whether it still holds.
17. Headless and scripting
output_format = auto renders HTML for interactive runs and plain markdown for headless or skill-invoked ones — no configuration needed.
A quarterly refresh job
# .github/workflows/research-refresh.yml
on:
schedule: [{ cron: "0 6 1 */3 *" }]
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
bmad-deep-recon refresh \
--headless --output-format md \
--workspace docs/decisions/research/
- run: ./scripts/alert-on-overturned.sh
The alerting rule that keeps it alive
Alert on overturned, and on changed where the claim is referenced by a decision record. Ignore confirmed.
Get this wrong and you've built a quarterly notification everyone mutes by the second quarter. Get it right and you have decision-rot detection that costs nothing and requires nobody to remember.
18. Configuration in practice
The three layers
| File | Scope | Wins? |
|---|---|---|
_bmad/custom/bmad-deep-recon.user.toml | Personal, gitignored | Yes |
_bmad/custom/bmad-deep-recon.toml | Team, committed | Middle |
The skill's customize.toml | Defaults | Lowest |
Merge rules: scalars override; tables deep-merge; arrays of tables keyed by code/id replace matches and append new ones; other arrays append. No removal mechanism — you override, you don't delete.
A sensible day-one personal config
# _bmad/custom/bmad-deep-recon.user.toml
[workflow]
output_format = "both"
validation = "normal"
# red_team stays off globally — turn it on per-run, deliberately
A domain source policy — the highest-value override
[[workflow.source_policies]]
code = "regulatory"
preferred = ["eur-lex.europa.eu", "edpb.europa.eu", "ico.org.uk",
"priv.gc.ca", "cai.gouv.qc.ca"]
banned_kinds = ["law-firm marketing", "compliance-vendor content marketing"]
[[workflow.source_policies]]
code = "security"
preferred = ["nist.gov", "owasp.org", "attack.mitre.org", "nvd.nist.gov"]
banned_kinds = ["security-vendor content marketing"]
Write a policy for a domain you know well. You can already tell good sources from bad in your own field — that expertise is exactly what a source policy encodes, and it's the single most effective configuration you can make.
Confirm key paths against your installed customize.toml; the shape and intent are what matter here.
19. Recovering when things go wrong
| Symptom | Cause | Fix |
|---|---|---|
| Report reads like a textbook | No decision stated → nothing to prune against | Restate as a decision with constraints; re-run |
| Report agrees with everything you believed | You pasted your design in | Re-run without it, compare afterward yourself |
| Matrix weights feel generic | Constraints weren't stated as constraints | Say them explicitly at the gate; deepen or re-run |
| A dimension came back thin | Brief too broad or too narrow | cat the brief to diagnose, then deepen with a sharper framing |
| Everything traces to two aggregator sites | Source policy too permissive | Ban the kind; re-run the affected dimension |
| Run died partway | Context or session loss | Resume — files-first means what landed is kept |
| Took 3x the estimate | Preset too high, or too many dimensions | Cut dimensions first, preset second |
| You disagree with the recommendation | Either your weights were wrong, or your prior was | Fix the weights and say why in the ADR. Don't re-run with new framing until it agrees — turn red_team on instead |
That last row is the important one. Re-framing until you get the answer you wanted is the one failure mode the whole system cannot protect you from, because you're driving.
20. All of this in plain Claude, without BMAD
Most of what makes Deep Recon good is discipline, not tooling. You can run a decent approximation in any Claude conversation. Here's the whole method.
20.1 The five moves
Move 1 — State the decision, not the topic.
I'm choosing between cross-encoder rerank, learned sparse rerank, and
MMR-only for a recommendation retrieval stage. Constraints: 40ms budget
for rerank, multilingual content, self-hosted only, 12-month horizon.
Before you research anything: propose 4-6 independent dimensions of
investigation, and tell me which you'd prioritise. Don't search yet.
That last line manufactures your own plan gate.
Move 2 — Prune the plan before any searching.
Drop the "implementation complexity" dimension — not a differentiator
for us. Add: behaviour under filtered ANN search specifically, since
benchmarks measure unfiltered top-k and our path always filters.
Now go.
Move 3 — Demand the source discipline explicitly.
For every claim: publisher, publication date, and note whether it's
vendor self-reported or independent. Flag anything published more than
12 months ago as potentially stale rather than stating it as current.
If several claims trace back to one original source, say so — I want to
know when three citations are really one.
Move 4 — Ask for the negative space. The single highest-value instruction, and the one people never give:
End with two sections:
- What you could not determine, and why
- Which of these claims will go stale fastest, and roughly when I should
re-check them
Move 5 — Red-team it in a separate turn.
I'm inclined toward cross-encoder. Make the strongest case that I'm
wrong, using the evidence you gathered.
Separate turn matters — asking for a recommendation and its refutation in one breath produces a hedge instead of an argument.
20.2 Keeping the firewall
The discipline is entirely on you here, because there's no architectural barrier:
❌ Here's our architecture [paste]. Is our approach right?
✅ What do the constraints and evidence say? [read] ... then compare yourself.
If you must supply internal context, scope it and say why:
Use this only as a constraint on the question, not as evidence:
our p99 budget is 200ms and we self-host. Don't let it shape which
sources you weight.
Weaker than a real firewall — it's an instruction, not a wall — but stating it makes you notice when you're about to break it, which is most of the benefit.
20.3 Approximating the artifact
Ask for the output as a file with the metadata that makes it durable:
Write this to a markdown file with frontmatter: date, the decision it
supports, and a source list with publication and access dates.
Then commit it next to the ADR, exactly as you would with BMAD output. The lifecycle is the part you can fully replicate without any tooling — a dated file in version control and a calendar reminder from the staleness section gets you most of the refresh mechanism.
20.4 Approximating refresh
Three months later, open the file:
Here's a research file from three months ago. Re-check only the claims
it flags as fast-moving. Tell me: confirmed, changed, or overturned —
and flag anything overturned that the linked decision depends on.
20.5 What you can't replicate
Being honest about the gap:
| Feature | Replicable in plain Claude? |
|---|---|
| Decision framing and dimension pruning | ✅ Fully — it's a discipline |
| Source and freshness discipline | ✅ Fully — it's an instruction |
| "Could not determine" and staleness map | ✅ Fully |
| Red-team pass | ✅ Fully — just use a separate turn |
| Durable dated artifact | ✅ Fully — write the file, commit it |
| Refresh with delta | ⚠️ Manually, and you have to remember |
| True firewall | ❌ It's an instruction, not an architectural barrier |
| Deterministic citation cross-check | ❌ No script counting your markers |
| Parallel fan-out with separate contexts | ❌ One context does everything |
| Deterministic run identity across sessions | ❌ You manage filenames yourself |
The honest summary: roughly 70% of the value is discipline you can adopt today, in any conversation, with no installation. The tooling buys you enforcement, parallelism, and lifecycle — real things, but they're the multiplier on the discipline rather than a substitute for it.
If you take one thing from this document into tomorrow's work, take Move 1 and Move 4: state the decision before the topic, and always ask what couldn't be determined.
21. One-page cheat card
Framing
I'm choosing between X, Y, Z. Constraints: A, B, C. Horizon: N months.
Include those candidates but don't treat the list as the frame.
Type: <market|domain|technical|competitive|user-voice|academic-lit>
Shape: <explore|select> Effort: <quick|standard|deep>
Validation: <normal|high|max> red_team: <on|off>
Freshness: claims older than N months are history, not fact.
At the plan gate
Approve with changes:
- Drop dimension N — doesn't bear on the decision
- Add: <the thing only you know matters>
- Split N into <A> and <B> — different evidence bases
- Topology: <breadth-first|depth-first|straightforward>
- red_team on — I'm going in believing <X>
Mid-run
Dimension N is leaning on aggregators — chase the primaries.
Round 2: prioritise the <contradiction>; deprioritise <weak lead>.
Claim [n] is load-bearing and single-sourced — verify or downgrade.
Stop after this round and write up what you have.
Reading the report — in this order
1. Could not determine 4. Staleness map → calendar
2. Selection matrix weights 5. The recommendation, last
3. Self-reported flags
Lifecycle
refresh the <topic> research # time passed
deepen the <dimension> dimension # under-answered
/bmad-deep-recon # genuinely new question
The sentence that gates everything
"I am choosing between ___, ___ and ___, under constraints ___, ___ and ___, and I'll live with it for ___ months."
Can't write it? You're not ready to research — you're ready to brainstorm.
Companions: bmad-deep-recon-foundations.md (what the terms mean) · bmad-deep-recon-guide.md (task-driven walkthroughs, applied domains, epistemology)
Source: llm ·
llm.md· updated 2026-07-25 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
How an LLM Actually Works
A mental model for someone who already knows transformers on paper but can't reconcile "matrix multiplications over vectors" with "it understood what I meant."
Contents
Part I — The object
- 0. The one thing to unlearn first
- 1. Concrete anchor: a real model's dimensions
- 2. Tokenization
- 3. The residual stream
- 4. Attention, dissected properly
- 5. The MLP — where knowledge is stored
- 6. Superposition
- 7. Circuits — worked examples
- 8. Depth = composition, and where intent lives
- 9. From vector to token: the output side
Part II — Putting it together
- 10. Full worked trace
- 11. Why prediction produces intent modeling
- 12. In-context learning
- 13. Chain of thought = serial compute
Part III — How it got that way
Part IV — Applied
- 15. Worked scenario: a landing page
- 16. Worked scenario: a compiler pass
- 17. Failure modes
- 18. What is honestly still unknown
Part V — Reference
- 19. Every concept, defined and modeled
- Appendix A: the compressed mental model
- Appendix B: the analogy that actually holds
- Further reading
0. The one thing to unlearn first
If your intuition for "text → vector" comes from embedding models (sentence-transformers, bi-encoders, HNSW indexes), that intuition is actively blocking you.
| Embedding model | Transformer LM | |
|---|---|---|
| Output per input | One vector for the whole text | One vector per token position |
| Vector meaning | A point in a static semantic space | A running computation, mid-flight |
| How many times written | Once | ~80–120 times (once per layer) |
| Information flow | None between docs | Every position reads from every earlier position, at every layer |
| Nature | A noun — a stored representation | A verb — an unfolding process |
An embedding is a photograph. A transformer forward pass is a film — and the frames modify each other.
That is the whole reason one can only do retrieval while the other can write a compiler pass.
1. Concrete anchor: a real model's dimensions
Vague talk about "dimensions" is why this feels like magic. Here are actual numbers, using a Llama-3-70B-class model:
d_model (residual stream width) 8,192
layers 80
attention heads per layer 64
head dimension 128
MLP hidden width 28,672
vocabulary 128,256
context window 128,000 tokens
total parameters 70,000,000,000
Derived facts worth internalizing:
- Attention head computations per token: 80 × 64 = 5,120. Not one similarity lookup — five thousand of them, each with its own learned notion of what to look for and what to bring back.
- Parameter split: roughly 1/3 attention, 2/3 MLP. Most of the model is memory, not routing.
- FLOPs per generated token: ~2 × 70B = 140 billion. For a 500-token answer, ~70 trillion floating point operations. It is not a lookup. It is a lot of computation.
- 8,192 numbers per position. In fp16 that's 16 KB. That 16 KB is the model's entire working belief about that word in that context, at that depth.
2. Tokenization — the layer everyone skips, and shouldn't
Text is split into subword tokens by BPE. Rough rule: 1 token ≈ 3.5–4 characters of English.
"the tests are failing" → ["the", " tests", " are", " failing"] 4 tokens
"unfortunately" → ["unfort", "unately"] 2 tokens
"strawberry" → ["str", "aw", "berry"] 3 tokens
"getUserByID" → ["get", "User", "By", "ID"] 4 tokens
"128256" → ["128", "256"] 2 tokens
"色即是空" → ["色", "即", "是", "空"] or worse 4+ tokens
Two consequences that explain real behavior:
- The model cannot see letters.
"strawberry"arrives as three opaque chunks. Asking it to count the r's is asking someone to count letters in a word they only ever heard spoken. This single fact explains a whole genre of "how is it so dumb about this" failures. - Non-English and code are tokenized less efficiently. The same sentence in Thai or Tamil may cost 3–5× the tokens of English. Since compute is per-token, this is also a fairness and cost issue in multilingual retrieval systems — the tokenizer is a quiet source of bias before any model math happens.
Each token ID indexes a row of the embedding matrix: 128,256 × 8,192. That row is the starting value of the residual stream at that position. It encodes essentially nothing contextual yet — just "this token, generically."
Position is added separately (modern models use RoPE, which rotates query and key vectors by an angle proportional to position, so attention naturally sees relative distance rather than absolute index).
3. The residual stream — the single most important object
Picture a spreadsheet:
- Columns = token positions in your prompt
- Rows = layers, 80 of them, bottom to top
- Each cell = an 8,192-dimensional vector
"the" "tests" "are" "failing"
┌──────────┬──────────┬──────────┬──────────┐
Layer 80 │ v80,1 │ v80,2 │ v80,3 │ v80,4 │ ← this one becomes the prediction
├──────────┼──────────┼──────────┼──────────┤
... │ ... │ ... │ ... │ ... │
├──────────┼──────────┼──────────┼──────────┤
Layer 2 │ v2,1 │ v2,2 │ v2,3 │ v2,4 │
├──────────┼──────────┼──────────┼──────────┤
Layer 1 │ v1,1 │ v1,2 │ v1,3 │ v1,4 │
├──────────┼──────────┼──────────┼──────────┤
Embedding │ e(the) │ e(tests) │ e(are) │ e(fail…) │
└──────────┴──────────┴──────────┴──────────┘
Critically, the update rule is additive:
x ← x + attention_output(x)
x ← x + mlp_output(x)
Not x ← f(x). The stream is a shared workspace that every component reads from and writes into. Nothing is overwritten; contributions accumulate.
This is why the mental model of "a bus" or "a whiteboard" is better than "a pipeline." Layer 3 can write a fact that nothing touches until layer 61 reads it. Components communicate across depth by leaving things in the stream.
Two directions of information flow:
- Vertical (within a column): MLPs. Enrich this position's representation with knowledge.
- Horizontal (between columns): Attention. This is the only mechanism by which positions see each other. Remove attention and you have 4 independent MLPs that have never heard of one another.
4. Attention, dissected properly
The textbook formula is softmax(QKᵀ/√d)V. The useful decomposition is different — split it into two independent learned circuits:
The QK circuit — "where do I look?"
Each position produces a query ("what am I looking for right now?") and a key ("what am I, advertised to others?"). Dot product, scaled, softmaxed over all earlier positions → attention weights.
This is exactly the retrieval you know from vector search, with three differences that change everything:
- The corpus is your own prompt, not a static index.
- The query is recomputed from the current stream state at every layer — so a position's question at layer 60 is informed by what it learned at layers 1–59.
- It runs 5,120 times per token.
The OV circuit — "what do I bring back?"
Separately, each position produces a value vector, and the head has an output matrix. What gets copied back into the stream is attention_weights × values × W_O.
The decoupling is the point. What matched and what gets copied are different learned functions. A head can attend to the word "France" but write a country-code direction rather than the France representation. Retrieval systems don't do this; they return the document they matched.
Multi-head = parallel specialists
64 heads per layer, each with a 128-dim subspace. They specialize sharply:
| Head type | Job | Observed in |
|---|---|---|
| Previous-token head | Attend to position i−1 | Every model, layer 1–2 |
| Duplicate-token head | Find earlier copies of the current token | Early-mid layers |
| Induction head | Pattern [A][B] … [A] → predict [B] | Layers ~2+ |
| Name-mover head | Copy a specific name to the output position | Mid-late |
| Syntax heads | Subject→verb, noun→adjective, bracket matching | Early-mid |
| Suppression heads | Actively reduce a candidate's probability | Late |
That last row is worth pausing on. Some heads exist to say no. The network implements something like negation and inhibition using attention.
Causal masking
Position i can only attend to positions ≤ i. This is what makes training efficient — one forward pass over a 4,000-token document yields 4,000 training signals at once, each a legitimate "predict the next token" problem. It's also why the whole thing generates left to right.
KV cache — why the first token is slow and the rest are fast
- Prefill: your whole prompt is processed in one parallel pass. All keys and values for all positions are computed and cached. This is compute-bound and is the delay before the first token appears.
- Decode: each new token attends against the cache. Only one new column is computed per step. This is memory-bandwidth-bound.
Cache size scales linearly with context length. At 128K context this is tens of GB. This is the real reason long context is expensive — not the attention math, the memory.
5. The MLP — where knowledge is stored
Two-thirds of the parameters live here, and the structure is a key-value memory (Geva et al., 2021):
h = GELU(W_in · x) # 8,192 → 28,672 : 28,672 pattern detectors
out = W_out · h # 28,672 → 8,192 : each fires, writes its associated vector back
- Each of the 28,672 rows of
W_inis a key: a direction in stream-space it detects. - Each corresponding column of
W_outis a value: what to add to the stream when that key fires.
So: "if the stream currently contains [Paris-ness] + [capital-of-relation-active], add [France-direction]."
Multiply by 80 layers: 2.3 million key-value slots, chained so later ones read the results of earlier ones. That's the fact store — and because it's composed across depth, it does multi-hop lookups, not just single retrievals.
6. Superposition — the answer to "how can dimensions do this"
The intuitive objection: 8,192 dimensions can't hold millions of concepts.
The intuition is wrong, and here's the geometry.
In 8,192 dimensions you can only fit 8,192 perfectly orthogonal vectors. But if you relax to "almost orthogonal" — say, any pair within 85–95° — you can fit an exponential number. This is the Johnson–Lindenstrauss lemma. Concretely, in d dimensions you can pack roughly exp(εd) near-orthogonal directions.
At d = 8,192 that number is astronomically larger than the millions of concepts a model needs.
The tradeoff: features interfere slightly. Every feature bleeds a little noise into every other. This is tolerable if only a handful of features are active at any moment — which is exactly the case, since any given token in any given context is only a few things at once. This is sparse coding, and the network learns it because it's the optimal use of limited width.
This has been verified empirically, not just theorized. Anthropic's sparse autoencoder work on Claude 3 Sonnet extracted tens of millions of interpretable directions from the residual stream: a Golden Gate Bridge feature, a "code with a security vulnerability" feature, a "sycophantic praise" feature, an "inner conflict" feature. Amplify one and the model's behavior changes accordingly and predictably.
So the correct reformulation:
A residual stream vector is not a point in an 8,192-concept space. It is a sparse sum of a few dozen active features drawn from a dictionary of ~10⁷ learned directions.
That reframing is what makes "8,192 numbers" stop feeling too small.
7. Circuits — worked examples of the machinery
These aren't hypotheses. They've been reverse-engineered and causally verified by ablation.
7a. Induction heads — the origin of in-context learning
Setup. A two-head circuit spanning two layers:
- A previous-token head in layer L: at each position, copies the identity of the token before it into the stream. So position i now carries "I am preceded by X."
- An induction head in layer L+1: its query is "find positions preceded by the token I currently am." It matches, then copies that position's own token to the output.
Net effect: given [A][B] … [A], predict [B].
Worked example — why the model can track your made-up variable names:
const zqBuffer = new ArrayBuffer(1024);
...200 lines...
const view = new Uint8Array(zq▮
zqBuffer appears nowhere in training data. There is no stored fact. Yet the completion is Buffer, near-certainly.
Trace:
- Position of the final
zqemits a query: "where else have I seenzq?" - Duplicate-token / previous-token machinery has tagged the earlier occurrence.
- The induction head attends to the token after the earlier
zq— which isBuffer. - OV circuit copies
Bufferinto the output stream. - Unembedding turns it into a very high logit.
Why this matters far beyond variable names: induction heads generalize from exact-match copying to fuzzy, abstract pattern completion. The same circuit family is what lets few-shot prompting work. When you write:
sea otter -> loutre de mer
peppermint -> menthe poivrée
plush girafe ->
induction-style heads recognize the X -> Y structure, identify the transformation as translation, and apply it. No weights changed. The "learning" happened entirely as data movement inside one forward pass.
There is a striking empirical detail: during pretraining, induction heads form abruptly at a specific point, and the loss curve visibly bends at exactly that moment. In-context learning is not a smooth emergent haze — you can watch the mechanism switch on.
7b. The IOI circuit — indirect object identification
Prompt: "When Mary and John went to the store, John gave a drink to ___"
Correct answer: Mary. Both names appear; the model must pick the one that isn't the repeated subject. The full circuit was reverse-engineered in GPT-2 small (Wang et al., 2022) — 26 heads in three groups:
| Group | Function |
|---|---|
| Duplicate-token heads | Detect that John appeared twice |
| S-inhibition heads | Write a signal that suppresses attention to John |
| Name-mover heads | Attend to the remaining name and copy it to the output |
So the algorithm is literally: find all names → detect which one is duplicated → inhibit it → copy the survivor.
This is a genuine algorithm, discovered by gradient descent, implemented in attention patterns, verified by ablating individual heads and watching the answer flip to John. Nobody designed it. Nobody wrote "if duplicate then suppress." It was the lowest-loss way to model English discourse.
When you ask "how does it know what I meant" — this is the shape of the answer. Thousands of circuits like this, composed.
8. Depth = composition, and where intent physically lives
A rough map of what happens as you climb the 80 layers:
| Layers | What forms |
|---|---|
| 0–5 | Detokenization: reassembling word pieces, resolving word sense, basic syntax |
| 5–25 | Phrases, entities, grammatical relations, factual lookups |
| 25–55 | Abstract task representation, relationships between entities, register and intent, plan formation |
| 55–75 | Concretizing the plan into specific content |
| 75–80 | Converting to a distribution over the 128K vocabulary |
The middle band is the interesting one, and there's direct evidence for it.
Task vectors / function vectors
Take a few-shot prompt for some task (say, "translate English to French"). Read the residual stream at a mid layer at the final position. Average across many examples. You get a single vector.
Now take a completely unrelated prompt, with no examples and no instructions, and add that vector into the stream at the same layer.
The model starts translating to French.
Let that land. There is a specific vector, in a specific place, that is the concept "the task right now is: translate to French," represented independently of any wording that expressed it. You can extract it, store it, transplant it.
This is the mechanistic answer to "how does it know what I meant." Your intent stops being words and becomes a vector somewhere in the middle of the network. Different phrasings of the same request converge to nearby vectors — which is exactly why "make this faster," "this is too slow," and "optimize perf here" all get you the same kind of help.
Logit lens — reading the model's mind mid-computation
You can apply the final unembedding matrix to intermediate layers to see what the model would predict if it stopped there. Typical trajectory for a factual question:
Layer 10 : generic function words ("the", "a", "of")
Layer 30 : right category, wrong specifics ("city", "place", "region")
Layer 55 : right answer starting to appear, low confidence
Layer 70 : right answer dominant
Layer 80 : sharpened, high confidence
The prediction is built up, progressively refined. It is not looked up.
9. From vector to token: the output side
At the last position, after layer 80:
- Unembed:
logits = W_U · x→ 128,256 raw scores. - Softmax with temperature:
p = softmax(logits / T). T=0 → always the argmax (deterministic). T=1 → the raw learned distribution. T>1 → flatter, more random. - Truncate: top-k (keep the k best) or top-p / nucleus (keep the smallest set summing to p, typically 0.9–0.95). This cuts the long tail of garbage tokens.
- Sample one token.
- Append it to the input and run the entire thing again.
That last step is the loop, and it's why the model can't un-say something. Each token is committed and becomes input. There's no backtracking, no edit buffer.
Dissecting an actual distribution. For the prompt "The tests are failing after I added the cache":
token logit prob
" layer" 18.2 0.41
" ." 17.6 0.23
" to" 16.9 0.11
" middleware" 16.1 0.05
" and" 15.8 0.04
" invalidation" 15.4 0.03
...128,250 more tokens, together ~0.13
Two things to notice:
- The model is never certain. Even "obvious" continuations carry substantial mass elsewhere. Confidence is a distribution, not a boolean.
- The whole answer is present in embryo. The distribution already encodes "this is a technical debugging context, in English, mid-sentence, about caching." Every subsequent token narrows further.
10. Full worked trace
Prompt: "the tests are failing after I added the cache layer"
Note what is not in that sentence: no question mark, no request, no "please help," no specification of what output you want. Yet you'd get back a ranked list of likely causes with debugging steps. Let's trace how.
Prefill — layers 1 through 80, at the final position
| Depth | What accumulates in the stream |
|---|---|
| L1–4 | Token pieces reassembled. " cache" is disambiguated — CPU cache? cache-money? Attention to " tests", " layer" resolves it toward software caching. |
| L5–12 | Syntax: tests is the subject, failing the predicate, after establishes temporal-causal ordering. added the cache layer binds as a completed action by the speaker. |
| L12–25 | Domain features light up: software-engineering, test-suite, caching, regression. Related knowledge is pulled in by MLPs: cache invalidation, TTL, memoization, test isolation, singletons, mocking. |
| L25–40 | Discourse-intent features. This is the crucial band. The stream acquires: speaker-is-blocked, implicit-request-for-diagnosis, causal-hypothesis-expected, speaker-is-a-developer, register-is-technical-peer, not-a-beginner (they said "cache layer," an architectural term). |
| L40–60 | Response plan. Features for enumerate-probable-causes, ordered-by-likelihood, include-diagnostic-steps, format-as-list, assume-familiarity-with-testing-tools. Simultaneously, specific hypotheses get ranked: shared cache state leaking across tests, cache not cleared between runs, stale reads, serialization of cached objects, timing/TTL flakiness, a singleton surviving teardown. |
| L60–75 | Concretization. Abstract "shared state leaks across tests" becomes lexical material — words like isolation, teardown, fixture, singleton gain probability mass. |
| L75–80 | Projection to vocabulary. |
Where did the "request" come from?
Nowhere in the text. It was inferred, and here's the mechanism, not the hand-wave:
During pretraining, the model saw millions of instances of this exact discourse pattern — a Stack Overflow post, a Slack thread, a GitHub issue, an IRC log — where someone states a symptom and the very next thing in the document is a diagnostic response. To minimize prediction loss on those documents, the model had to learn: "symptom stated by developer" → "diagnosis follows."
Post-training then sharpened which of the many possible continuations it produces (a helpful diagnosis rather than, say, a snarky reply or another user's unrelated question), but the underlying inference was already there in the base model.
Your intent was reconstructed from statistical regularities in how humans structure conversations. Not from a rule. Not from an intent classifier. From compression.
Decode — dissecting the actual answer
Suppose the response begins:
"A few likely culprits, roughly in order of how often they bite:
- Cache state leaking between tests — the cache isn't cleared in teardown, so test B sees what test A wrote."
Dissect where each part came from:
| Fragment | Origin |
|---|---|
| "A few likely culprits" | The enumerate-causes plan feature from L40–60, plus a register feature choosing casual-technical over formal |
| "roughly in order of" | An epistemic-hedging feature — the model represents its own uncertainty and, post-RLHF, expresses it |
| "1." | Format feature; list structure was decided at L~50, before any token was emitted |
| "Cache state leaking between tests" | Highest-ranked hypothesis. This ranking came from frequency in training data — this is genuinely the most common cause, and the model absorbed that base rate |
| "isn't cleared in teardown" | MLP knowledge: cache + test + state-leak → teardown is the associated concept |
| "test B sees what test A wrote" | Concretization — the abstract feature rendered as a minimal example, a style pattern learned from good technical writing |
Nothing here was retrieved. There is no stored answer to "tests failing after cache layer." Every token was computed. The reason it's right is that the machinery for reasoning about cache-and-test interactions is genuinely encoded in the weights, in the form of features and circuits that generalize.
11. Why next-token prediction produces intent modeling
This is the philosophical crux, and it's actually rigorous.
The setup. Minimize −log P(next token | all previous tokens) over ~15 trillion tokens of human-written text.
The critical observation: that text was not generated randomly. It was generated by humans with goals, knowledge, moods, expertise levels, and plans. The text is a downstream shadow of those hidden variables.
So to predict it well, you must model the hidden variables. There is no shortcut:
- To predict the next line of a Stack Overflow answer, you must model what the asker needs.
- To predict the next token of a legal brief, you must model the argument being constructed.
- To predict the closing brace of a C function, you must model the scope structure.
- To predict the punchline, you must model what the audience finds surprising.
- To predict the next move in a chess transcript, you must model the position.
And memorization is unavailable. 70B parameters versus 15T training tokens is roughly 200 tokens compressed per parameter. A lookup table is not on the table. The only representation that fits is general machinery.
This gives the chain:
accurate prediction → requires compression → requires generalization →
requires modeling the generating process → and the generating process is
a human with an intent
Intent modeling is not a feature that was added. It is the lowest-loss solution to the objective, and gradient descent found it because it was cheapest.
The same argument explains capabilities that look unrelated to text: arithmetic, code execution traces, spatial reasoning, theory of mind. Each is a compression win on some slice of the corpus.
12. In-context learning — learning without learning
Weights are frozen at inference. Nothing is stored. Yet:
Prompt:
gxlfj -> 5
hqm -> 3
aabbccdd -> 8
zzz ->
Answer: 3. The task ("count characters") was never named, never trained on with these strings, and no gradient was computed.
What actually happens: induction-style heads recognize the input -> output structure, mid-layer circuits form a task representation from the demonstrations, and later layers apply it to the new input. The forward pass implements a learning algorithm.
There's evidence that in some settings this is literally gradient descent implemented inside the attention layers — a linear-regression task in-context produces internal updates that approximate one or more gradient steps on that task. The transformer learned to run an optimizer as a subroutine, because doing so lowers prediction loss on documents containing patterns.
Practical corollary: your prompt isn't a query. It's a program that reconfigures the model's effective computation. That's why prompt structure matters so much — you're not phrasing a search, you're building a temporary specialist.
13. Chain of thought = renting more serial compute
Fixed architecture means fixed serial depth: 80 layers. Some problems require more sequential steps than that, no matter how wide the model is.
The escape hatch is the output itself. Every emitted token is fed back as input, so:
serial computation available = 80 layers × number of tokens generated
Writing intermediate steps turns the context window into external working memory and buys unbounded serial depth.
Direct: "What is 47 × 83?" → "3,901" ← one 80-layer pass. Often wrong.
CoT: "47 × 83
= 47 × 80 + 47 × 3
= 3,760 + 141
= 3,901" ← ~40 tokens = ~3,200 layers of serial work
Each line is computed, then read back as ground truth for the next line. This is why "think step by step" works, and why reasoning-tuned models generate long internal traces. They're not being verbose — they're allocating compute.
Important caveat: the written trace is not guaranteed to be the actual causal path. Models can reach an answer internally and then generate a plausible-looking justification. Chain of thought is a compute mechanism and a post-hoc narrative, and telling those apart is an open research problem.
14. Training: SGD as a compiler
14.1 The reframe: the training loop is the compiler
If you know compilers, you already have the right structure in your head — it just sits one level up from where you're looking.
gcc pipeline
C source ──► gcc ──────────► binary ──► CPU
human-written passes by hand ~5 MB can reject
LLM pipeline
15T tokens ──► SGD ──────────► weights ──► forward pass
human-written passes by search ~140 GB never rejects
Same shape. Two differences carry all the weight:
- Nobody wrote the passes. In gcc, a human wrote constant folding, a human wrote loop unrolling, and each is provably correct. In an LLM, a stochastic search over a 70-billion-dimensional program space found the passes. The 80 layers genuinely contain bracket matching, scope tracking, type propagation, dead-branch suppression — but they were discovered, not authored, and nobody has fully read them back.
- The runtime has no reject state. More on this in §14.9; it's the single most clarifying fact about why the thing "accepts everything."
The closest thing in your world is superoptimization — searching the space of instruction sequences for one better than a human would write. SGD is superoptimization with a different objective, a vastly larger search space, and no proof of anything.
14.2 What one SGD step actually does
Forget "training" as a vague process. A single step is small and entirely mechanical. Say the batch contains this snippet scraped from GitHub:
def process(items):
result = []
for item in items:
result.append(transform(item))
return ▮
Step 1 — forward pass. The model produces a distribution over all 128,256 tokens at that position. Suppose it assigns p(" result") = 0.31.
Step 2 — loss. Cross-entropy is just the negative log of the probability assigned to the actual next token:
loss = −ln(0.31) = 1.17 nats
If it had been confident: −ln(0.95) = 0.05
If it had been clueless: −ln(1/128256) = 11.76
The loss is literally "how surprised were you." Nothing more.
Step 3 — backward pass. Backpropagation computes ∂loss/∂w for every one of the 70 billion weights — one number per weight saying "nudge me this direction to have been less surprised." This is the chain rule applied to a computation graph, executed in reverse.
Step 4 — update.
w ← w − learning_rate × ĝ
With a learning rate around 1e-4 (decaying on a cosine schedule), each weight moves by roughly one part in ten thousand. In practice the optimizer is AdamW, which keeps running averages of the gradient and its variance so each weight gets its own effective step size.
That's the entire step. In isolation it is nearly worthless — the model became microscopically less surprised by one snippet. Everything comes from doing this ~10⁶ times over ~10¹³ tokens.
14.3 Why one update improves a million unrelated cases
This is the crux, and it has no compiler analogue.
To predict result correctly, the network had to run a real inference chain:
resultwas bound at the top of this scope- it's an accumulator — mutated in a loop, never otherwise read
- Python functions conventionally return their accumulator
- we are at
return, in this function's scope, not the enclosing one
The gradient does not reward "the string result." It rewards whichever circuits performed that inference: the variable-binding tracker, the scope-boundary detector, the accumulator-pattern recognizer.
And those circuits are shared. The identical variable-binding machinery fires for:
- Rust
letbindings - SQL aliases in a CTE
- pronoun resolution in English prose
- "the aforementioned party" in a contract
- a defined term in a mathematical proof
So one Python snippet measurably improves the model's handling of legal documents. Not by analogy — literally, the same weights, updated once, better everywhere.
This is the generalization mechanism, and it explains the otherwise baffling fact that 15 trillion tokens of miscellaneous internet text produces something that can write a compiler pass. There's a well-documented empirical consequence: adding code to the training mixture improves reasoning on non-code benchmarks, because code is unusually dense in explicit logical structure and, crucially, was checked by a compiler before being committed.
14.4 Why it cannot memorize, even if it wanted to
15,000,000,000,000 training tokens
70,000,000,000 parameters
──────────────────────────────────
~200 tokens compressed per parameter
At 2 bytes per parameter in fp16, that's roughly 0.01 bytes of storage per training token. Lossless memorization is off the table by three orders of magnitude.
SGD is therefore under brutal compression pressure, and the resolution is the one you'd reach yourself if you were hand-writing a codec: store the rules, not the instances.
- A generative rule for "how Python scoping works" costs a few thousand parameters and covers billions of tokens.
- Memorizing those same tokens costs billions of parameters and generalizes to nothing.
Gradient descent finds the first solution because the second doesn't fit. Generalization is not an achievement the model unlocks. It is the only thing that fits in the space available.
This is also the cleanest answer to "how does it know almost everything." It doesn't store everything. It stores the generative structure of everything, and re-derives specifics at inference time — which is exactly why it's fluent on common things and confidently wrong on rare ones.
14.5 The numbers, for calibration
| Tokens per batch | ~4 million (thousands of sequences in parallel) |
| Optimizer steps | ~10⁵–10⁶ |
| Sequence length during training | 4K–32K, extended later |
| Total FLOPs | ~6 × params × tokens ≈ 6×10²⁴ |
| Hardware | 10⁴–10⁵ accelerators, weeks to months |
| Cost | tens to hundreds of millions of dollars |
| Optimizer | AdamW — needs ~2 extra states per weight, so ~3× the model's memory just to train |
| Passes over the data | ~1. It sees most tokens exactly once. |
That last row deserves a pause. There is no drilling, no repetition, no curriculum of the same material. The model learns Python scoping from seeing millions of different correct examples one time each — which is precisely the setup that forces it to learn the rule instead of the instances. Repetition would encourage memorization; the single pass forbids it.
The 6 × params × tokens figure decomposes as 2 for the forward pass and 4 for the backward pass (backward costs roughly twice forward). Handy for estimating any training run.
14.6 The loss curve has structure
Training loss is not a smooth glide. Capabilities arrive as phase transitions:
loss
│╲
│ ╲___ learning token frequencies, basic syntax
│ ╲
│ ╲__ ← visible bend: induction heads form
│ ╲___ in-context learning switches ON here
│ ╲__ ← arithmetic circuits consolidate
│ ╲____
└──────────────────────► tokens seen
The induction-head bend is the famous one. Before it, few-shot prompting essentially doesn't work. After it, it does. You can watch a mechanism switch on by looking at a loss curve — the capability is not a smooth emergent haze, it's a circuit forming.
This is the same shape as grokking: a network can sit at memorization-level performance for a long time and then abruptly generalize, when the general circuit finally beats the memorized lookup on the loss. You are watching a search find a better program and swap it in.
14.7 Where the modern gains actually come from
Architecture is roughly frozen. A 2019 transformer with modern normalization and RoPE is close to a 2026 one. Two things moved instead:
1. Data curation — the real secret sauce, and the least published part.
- Aggressive deduplication (near-duplicate web pages are actively harmful — they induce memorization)
- Quality classifiers trained to recognize "textbook-like" writing
- Mixture ratios: how much code vs. web vs. books vs. math vs. multilingual, tuned empirically
- Annealing: saving the highest-quality data for the last few percent of training, where the learning rate is tiny and the effect is disproportionate. The model's final impressions are its most durable ones.
- Synthetic data: using a strong model to generate clean training material for the next one
2. RLVR — reinforcement learning from verifiable rewards. This is the stage that made code good, and it's the one a compiler person will find most familiar:
sample N candidate solutions
↓
run the test suite / check the proof / execute the program
↓
reward the trajectories that passed
↓
gradient-ascend toward those
Unlike RLHF ("did a human prefer this?"), the reward here is ground truth. You can optimize against it hard without immediately Goodharting, because a test suite has no taste to exploit. This is why RLVR adds genuine capability while RLHF mostly adds manners.
It also explains the capability profile you observe in practice: models are disproportionately strong in domains with cheap automated verification (code, math, formal logic) and weaker where correctness is a matter of judgment.
14.8 The post-training stages in detail
| Stage | Data | What it adds | What it doesn't |
|---|---|---|---|
| Pretraining | ~15T tokens of web, books, code | Essentially all knowledge, reasoning ability, world model, intent modeling | Any notion of being an assistant. A base model given a question is as likely to write more questions. |
| SFT | ~10⁴–10⁶ curated dialogues | The assistant format; the mapping "user turn → helpful response" | New knowledge, in any meaningful quantity |
| Reward modeling | Human preference comparisons (A vs. B) | A learned scorer that approximates human judgment | Anything, directly — it's scaffolding for the next stage |
| RLHF / RLAIF | The reward model's scores | Tone, helpfulness, honesty, refusal behavior, calibrated hedging | New capabilities |
| RLVR | Math/code with automatic checking | Genuine reasoning improvement | Anything where correctness isn't checkable |
The mental model for the middle three: the base model can simulate an enormous range of authors. Post-training doesn't teach it anything new; it selects which author it becomes when addressed as an assistant, and makes that selection reliable.
The cost asymmetry is stark — pretraining is 98%+ of the compute, post-training is a rounding error. Yet post-training is what makes the artifact usable. It is a thin, cheap layer of steering over an enormously expensive engine.
The known downside: RLHF optimizes a proxy for human approval, and approval is not truth. This is the mechanistic origin of sycophancy — reversing a correct position under pushback is what the reward signal rewarded.
14.9 The disanalogy that resolves "it accepts everything"
You're conflating two properties. Separate them and most of the mystery dissolves.
Property 1: "It accepts anything." This is architectural, and not impressive.
Look at where a compiler can fail versus where a transformer can:
| Compiler stage | Can fail? | Transformer equivalent | Can fail? |
|---|---|---|---|
| Lexer | yes — invalid character | BPE tokenizer | no — total function, always emits tokens |
| Parser | yes — syntax error | (none exists) | — |
| Type checker | yes — type error | (none exists) | — |
| Linker | yes — undefined symbol | (none exists) | — |
| Codegen | yes | softmax | no — always a valid distribution |
There is no code path in a transformer for "I don't understand this input." No grammar, no parser, no error state, no partial-failure mode. Every stage is a total function over its input domain. Feed it line noise and it will confidently predict the next token of line noise.
That's not intelligence — it's the absence of a rejection mechanism. And it's the direct architectural cause of hallucination: a system that cannot fail to parse also cannot fail to answer. When the model doesn't know something, there is no representational slot for "unknown" and no branch to take. The output head produces a peaked distribution regardless, because that's the only thing it can do.
Everything that looks like the model saying "I'm not sure" is learned behavior from post-training, generated as ordinary tokens — not a structural check. It's a string, not a status code.
Property 2: "It knows almost everything." This is capacity plus compression — and it's less true than it feels.
It knows the high-frequency structure of nearly everything, and degrades smoothly into the tail:
frequency in training data
high ──────────────────────────────► low
│ │
reliable plausible
and correct and wrong
│ │
"how does a "LLVM "the signature of
dominator dominator getAnalysisUsage
tree work" tree API" in LLVM 17.0.3"
And the confidence is flat across that entire axis, because nothing in the architecture measures how well-supported a fact was. The useful heuristic that falls out: trust its structure, verify its specifics. Algorithms and architecture generalize from circuits. API signatures, version numbers, and citations are memorized facts, and memorized facts decay with training frequency and go stale with time.
15. Worked scenario: "build me a landing page for a dog-walking service"
Why does this work, when the exact page was never in training data?
The abstractions that exist in the weights:
landing-pageschema: hero, value proposition, social proof, pricing, CTA, footerlocal-service-businessschema: service area, trust signals, booking mechanism- HTML/CSS syntax circuits: matched tags, valid property names, correct nesting
- Design conventions: contrast, hierarchy, whitespace, mobile-first
What the forward pass does:
- L25–45: composes
landing-page∩local-service∩pet-industryinto a joint plan. Pet industry contributes warm colors, photography-forward, informal-but-trustworthy tone. - L45–65: instantiates concrete sections in order.
- Decode: emits HTML. Here the syntactic circuits do heavy lifting — an open
<div>creates a strong attention signal that must eventually be discharged by</div>. This is bracket-matching implemented as attention, and it's why generated code is structurally valid even when semantically wrong. - Each token conditions the rest. Having written a hero section with a specific headline, the CTA later in the page will echo its phrasing — because that's what coherent documents do, and coherence is what was optimized.
Where it breaks and why: it will confidently produce a plausible-looking page that may use a CSS property that doesn't exist, or a framework API from a version that changed. It's generating from a distribution over plausible code, and plausible ≠ correct. It has no compiler.
16. Worked scenario: a compiler pass
Prompt: "Write an LLVM pass that eliminates redundant loads."
Knowledge assembled from the MLPs: SSA form, dominance relations, alias analysis, LoadInst and StoreInst APIs, the runOnFunction signature, memory dependence.
Reasoning assembled from circuits: the actual algorithm — walk the dominator tree, track available loads, kill on any aliasing store, replace dominated redundant loads with the earlier value.
Why it can do this: the LLVM codebase, its documentation, dozens of textbooks, and thousands of blog posts are in the training data. But more importantly, the abstractions generalize: "dominance," "available expressions," "kill sets" are structures the model represents, not strings it memorized. Ask for the same pass targeting a different IR and it adapts, because the structure transfers.
Where it breaks: it will get the algorithm right and the API version wrong. Reasoning circuits generalize; specific API signatures are memorized facts, and memorized facts decay with training-data frequency and go stale with time. The intuition to carry: trust its structure, verify its specifics.
17. Failure modes, and what each one reveals about the architecture
Failures are the best evidence for the mechanism, because they map onto architectural constraints exactly.
| Failure | Root cause |
|---|---|
| Can't count letters in "strawberry" | Tokenization — it never sees characters |
| Arithmetic on large numbers | Fixed serial depth; digits are badly tokenized. CoT partially fixes it |
| Confident wrong facts (hallucination) | Sparse-in-training-data facts have weak feature representations, but the output head still produces a peaked distribution. Fluency and truth are separate axes |
| Loses the thread in very long contexts | Attention mass is finite and spread over more positions; "lost in the middle" is real |
| Repetition loops | Low-temperature sampling entering a self-reinforcing attractor |
| Fails at genuinely novel deep reasoning | No search, no backtracking. Layer-by-layer refinement isn't tree search |
| Reverses its position under pushback | Post-training partially selected for agreeableness — Goodharting on human preference |
| Cites a real-looking but nonexistent paper | Author, venue, year, and title-style are each high-probability independently; the joint doesn't exist |
Notice that none of these are random. Every one falls out of a structural property. That's a good sign your mental model is correct — a correct model predicts the failures.
18. What is honestly still unknown
Intellectual honesty matters here, because a lot of writing on this topic overclaims.
Established: the architecture; superposition; specific circuits (induction, IOI, and dozens more); feature extraction via sparse autoencoders; task vectors; scaling laws.
Not established:
- No end-to-end account of any complex behavior. Nobody can trace why a specific 500-token compiler answer came out correct. We have footholds, not a map.
- Why in-context learning is as good as it is. Mechanisms are known in pieces; the full story isn't.
- Whether chain of thought is faithful to the actual computation.
- What the mid-layer "planning" representations really are — evidence exists that models plan several tokens ahead (e.g. picking a rhyme word before writing the line that leads to it), but the general structure is unclear.
- Whether anything like understanding is present in a sense beyond functional. This is partly empirical and partly a question about what the word means, and pretending it's settled in either direction is unjustified.
19. Every concept, defined and modeled
Each entry: what it literally is, then the mental model that makes it make sense. Where a compiler analogy helps, it's there.
19.1 The substrate: numbers and shapes
| Term | What it literally is | Mental model |
|---|---|---|
| Parameter | One number, learned during training. A 70B model has 70 billion. | A single knob. Meaningless alone — like asking what byte 4,182,993 of a compiled binary "means." Meaning lives in groups. |
| Weight | A parameter used as a multiplier on an input. The overwhelming majority of parameters. | How strongly one thing influences another. Learned "conductance" in a circuit. |
| Bias | A parameter added, not multiplied. A constant offset. | A default lean. "Assume this is slightly true unless told otherwise." Many modern models drop them — barely matters. |
| Weight matrix | A 2D grid of weights, e.g. W_in at 8,192 × 28,672. | A learned transformation from one space to another. Each row is a pattern being detected; each column is a pattern being written. |
| Activation | A number computed during a forward pass. Not learned; depends on your input. | The runtime values. Weights are the program, activations are the stack and registers. |
| Neuron | One dimension of an MLP's hidden layer. 28,672 per layer. | A single pattern detector with a learned trigger and a learned response. Individually usually polysemantic — it fires for several unrelated things (see superposition). |
| Tensor | An n-dimensional array. Scalars, vectors, matrices generalized. | Just the shape of the data. [batch, sequence, d_model] is the shape you'll see everywhere. |
| Matrix multiplication | The core operation. Rows dotted with columns. | Asking "how much does this vector resemble each of these learned patterns?" — thousands of dot products at once. GPUs exist for this. |
| d_model | The width of the residual stream. 8,192 in our reference model. | The bandwidth of the shared workspace. How much information one position can carry at once. |
| Precision | Bits per parameter: fp32, bf16, fp8, int4. | How finely each knob is calibrated. bf16 is the training default. Inference often drops to int8/int4 — cheaper, slightly lossier. |
19.2 Architecture
| Term | What it literally is | Mental model |
|---|---|---|
| Token | A subword unit, ~3.5–4 chars. The model's atomic symbol. | The model's alphabet. It cannot see below this level — the reason letter-counting fails. |
| Vocabulary | The full token set, ~128K entries. | The model's fixed symbol table, built once by BPE before training. |
| BPE | Byte-pair encoding: iteratively merge the most frequent adjacent pair. | A learned compression scheme for text. It is a total function — unlike a lexer, it can never reject input. |
| Embedding matrix | vocab × d_model. One learned vector per token. | The lookup that converts a symbol into a starting vector. Context-free — pure "this token, generically." |
| Positional encoding / RoPE | Rotates Q and K vectors by an angle proportional to position. | How the model knows word order. RoPE makes attention naturally see relative distance, which is why it extrapolates to longer contexts. |
| Layer / block | One attention sublayer + one MLP sublayer. 80 of them stacked. | One optimization pass — except nobody wrote it. Each reads the workspace and writes back. |
| Residual stream | The running vector at each position that every sublayer adds into. | The shared whiteboard. The single most important object. Not overwritten, only accumulated. |
| Skip / residual connection | x ← x + f(x) instead of x ← f(x). | What makes the whiteboard a whiteboard. Also what lets gradients reach layer 1 from layer 80 without vanishing — the reason deep networks train at all. |
| Attention | softmax(QKᵀ/√d)V | Learned retrieval, recomputed at every layer. The only mechanism moving information between positions. |
| Query | "What am I looking for right now?" | Your search query — but recomputed at every layer from the current state. |
| Key | "What am I, advertised to searchers?" | The index entry. |
| Value | "What gets copied if you match me." | The payload — deliberately decoupled from the key. You can match on "France" and return a country-code vector. |
| Attention head | One independent Q/K/V/O set, 128 dims. 64 per layer. | One specialist doing one job: previous-token, duplicate-detection, name-moving, bracket-matching. 5,120 of them per token. |
| Attention pattern | The softmaxed weight matrix — who attended to whom, how much. | The wiring diagram for this specific input. Fixed weights, variable data flow. This is the programmability. |
| Causal mask | Position i can't attend to j > i. | Enforces left-to-right. Also why one document yields thousands of training signals in one pass. |
| GQA / MQA | Multiple query heads sharing fewer key/value heads. | A KV-cache size optimization. Pure engineering; almost no capability cost. |
| MLP / FFN | W_out · GELU(W_in · x). ~2/3 of all parameters. | A key-value memory. ~28,672 slots per layer: "if you see pattern P, add fact F." Where knowledge lives. |
| Activation function | GELU, SwiGLU — a nonlinearity. | The thing that makes 80 layers more expressive than one. Without it, stacked linear maps collapse into a single linear map. |
| LayerNorm / RMSNorm | Rescale a vector to a standard magnitude. | Automatic gain control. Keeps values in a workable range so training doesn't explode. Boring but load-bearing. |
| Unembedding / LM head | d_model × vocab matrix at the very end. | Projects the final vector onto every possible token. Often tied to the embedding matrix. |
| Logits | Raw pre-softmax scores, one per vocab entry. | Unnormalized evidence for each token. Differences matter; absolute values don't. |
| Context window | Max tokens processable at once. 128K–2M. | Working memory. Hard-walled. Nothing outside it exists — no gradual forgetting, just a cliff. |
| MoE (mixture of experts) | Many MLP "experts"; a router activates only a few per token. | Decouples stored knowledge from compute per token. A 400B MoE may activate only 30B per token — big brain, cheap thinking. |
19.3 Training
| Term | What it literally is | Mental model |
|---|---|---|
| Loss | −ln P(actual next token). | "How surprised were you?" Zero means certain and right. That's the entire objective. |
| Cross-entropy | The formal name for that loss. | Measures the gap between the model's distribution and reality. |
| Perplexity | exp(loss). | "Effectively how many tokens was it choosing between?" Perplexity 10 = as confused as a fair 10-way guess. More intuitive than nats. |
| Gradient | ∂loss/∂w — one number per parameter. | A per-knob arrow: "turn me this way to be less wrong." 70 billion arrows per step. |
| Backpropagation | The chain rule applied backward through the computation graph. | Blame assignment. Distributes responsibility for the error across every weight that contributed. Costs ~2× the forward pass. |
| SGD | Take a batch, compute gradients, step downhill. Repeat. | Blind hill-descent in 70-billion-dimensional space, one tiny step at a time. Stupid per step; extraordinary in aggregate. |
| Adam / AdamW | SGD plus per-parameter running averages of gradient and variance. | Adaptive step sizes — cautious on noisy knobs, bold on consistent ones. Costs 2 extra states per weight (~3× memory to train). |
| Learning rate | Step size multiplier, ~1e-4, decaying. | How far to move per step. Too high: divergence. Too low: never arrives. The most important hyperparameter. |
| Warmup + decay | Ramp the LR up over early steps, then cosine-decay to near zero. | Ease in while the model is fragile; fine-tune gently at the end. The final low-LR phase is when high-quality data matters most (see annealing). |
| Batch | The set of sequences processed before one update. ~4M tokens. | Averaging over many examples so the gradient points at a real trend, not one document's noise. |
| Gradient accumulation | Sum gradients over several forward passes before stepping. | Simulating a huge batch when it won't fit in memory. |
| Epoch | One full pass over the dataset. | Mostly irrelevant here — LLM pretraining does ~1 epoch. Each token is seen once. That's what forces rules over memorization. |
| Checkpoint | A saved snapshot of all weights. | A build artifact. What actually gets shipped. |
| Overfitting | Memorizing training data instead of learning the pattern. | Barely a concern at LLM scale — there's no capacity to memorize 15T tokens. The compression ratio is the regularizer. |
| Scaling laws | Loss falls as a predictable power law in params, data, and compute. | Loss is forecastable before you spend the money. Chinchilla's finding: optimal is ~20 tokens per parameter — most early models were badly undertrained. |
| Emergence | A capability appearing abruptly at some scale. | Usually a circuit forming (see induction heads), sometimes an artifact of a threshold-based metric. Real, but often overstated. |
| Grokking | Sudden jump from memorization to generalization after long training. | The general circuit finally out-competing the lookup table on loss. Watching search swap in a better program. |
| Pretraining | Next-token prediction on ~15T tokens. | Where all knowledge and reasoning comes from. 98%+ of the compute. |
| SFT | Supervised fine-tuning on curated dialogues. | Teaching the format, not the content. Selects "helpful assistant" from the range of authors the base model can simulate. |
| Reward model | A model trained on human A/B preferences to score outputs. | A learned approximation of human taste. Scaffolding for RLHF. |
| RLHF / RLAIF | RL against the reward model's scores. | Manners, tone, refusals, calibrated hedging. Adds almost no capability. Optimizing approval, not truth — hence sycophancy. |
| RLVR | RL against automatic verification — tests pass, proof checks. | The one post-training stage that adds real capability, because the reward is ground truth and has no taste to exploit. |
| Distillation | Training a small model on a large model's outputs. | Compiling with the big compiler, shipping the small binary. Most small fast models are distilled. |
| LoRA / PEFT | Train small low-rank adapter matrices; freeze the base. | A patch file instead of a rebuild. Cheap, swappable, adds style and domain shape — not new reasoning. |
| Catastrophic forgetting | Fine-tuning on narrow data degrades general ability. | Overwriting shared circuits. The reason naive fine-tuning often makes a model worse overall. |
19.4 Inference
| Term | What it literally is | Mental model |
|---|---|---|
| Forward pass | Input → 80 layers → logits. No weights change. | Running the compiled program. Same weights every time; the data flow is what your prompt reconfigures. |
| Prefill | Processing your whole prompt in one parallel pass. | The compile step before output starts. Compute-bound. This is your time-to-first-token. |
| Decode | Generating one token at a time, reusing the cache. | The execution loop. Memory-bandwidth-bound, not compute-bound — which is why batching helps throughput so much. |
| KV cache | Stored keys and values for all prior positions. | Memoization. Without it you'd recompute the whole prompt per token. Grows linearly with context — tens of GB at 128K. The real cost of long context. |
| Sampling | Drawing a token from the output distribution. | The one deliberately nondeterministic step. Why the same prompt gives different answers. |
| Temperature | Divide logits by T before softmax. | A confidence dial. T=0: always argmax, deterministic. T=1: the model's honest distribution. T>1: flattened, more surprising, more wrong. |
| Top-k / top-p | Keep only the k best tokens, or the smallest set summing to probability p. | Truncating the garbage tail. Top-p ≈ 0.9–0.95 is the usual default. |
| Greedy decoding | Always take the highest-probability token. | Deterministic, and often worse — it produces flat, repetitive text and can dead-end into loops. |
| Beam search | Track multiple candidate continuations. | Standard in translation, mostly abandoned for open generation — it collapses into bland high-probability text. |
| Stop sequence | A string that halts generation. | An explicit terminator, since the model has no intrinsic sense of "done" beyond a learned end-of-turn token. |
| Prompt | Everything in the context before generation starts. | Not a query — a program. It reconfigures which circuits fire. That's why structure matters more than politeness. |
| System prompt | Instructions placed at the front of context. | Highest-leverage position: everything else attends back to it. Not privileged by the architecture, only by position and training. |
| Zero-shot / few-shot | No examples / a handful of examples in the prompt. | Few-shot works via induction-head machinery — pattern completion, not learning. Weights never move. |
| In-context learning | Acquiring a task from the prompt alone. | The forward pass implementing a learning algorithm. Sometimes literally approximating gradient descent inside the attention layers. |
| Chain of thought | Generating intermediate reasoning tokens. | Renting serial compute. Fixed 80-layer depth × N tokens generated. The context window becomes external working memory. |
| Speculative decoding | A small model drafts several tokens; the big one verifies in parallel. | Branch prediction. Free speedup when the draft is right, no loss when it isn't. |
| Quantization | Dropping to int8/int4 at inference. | Shrinking the binary. Big memory and speed wins, small quality cost. |
19.5 Interpretability
| Term | What it literally is | Mental model |
|---|---|---|
| Feature | A direction in activation space corresponding to a human-meaningful concept. | The real unit of meaning — not the neuron. "Golden Gate Bridge," "code with a security bug," "sycophantic tone." |
| Superposition | Storing more features than dimensions using near-orthogonal directions. | The answer to "how can 8,192 numbers hold everything." ~10⁷ directions packed into 8,192 dims, only a few dozen active at once. |
| Polysemantic | One neuron firing for several unrelated things. | The symptom of superposition. Why reading individual neurons fails. |
| Monosemantic | One unit, one clean concept. | The goal of feature extraction — recovered by sparse autoencoders, not found in raw neurons. |
| Sparse autoencoder (SAE) | An overcomplete autoencoder trained to reconstruct activations sparsely. | The decoder ring. Unpacks superposed activations into millions of individually interpretable features. |
| Circuit | A set of components implementing a specific algorithm. | A discovered subroutine. Induction heads, the IOI circuit. Real algorithms nobody wrote. |
| Induction head | A two-layer circuit doing [A][B]…[A] → [B]. | Why in-context learning works. Also why the model tracks variable names it has never seen. |
| Ablation | Zeroing a component and measuring the damage. | #ifdef 0 on part of the network. How circuit claims get causally verified rather than just observed. |
| Activation patching | Copying activations from one run into another. | A/B testing internals. Isolates which component carries which information. |
| Logit lens | Applying the output head to intermediate layers. | Reading the model's mind mid-computation. Shows the answer being built, not looked up. |
| Task / function vector | A mid-layer vector encoding "the task is X," extractable and transplantable. | Your intent, as a physical object. Add it to an unrelated prompt and the model performs the task. |
19.6 Practical and operational
| Term | What it literally is | Mental model |
|---|---|---|
| FLOPs | Floating-point operations. | The currency. Inference ≈ 2 × params per token. Training ≈ 6 × params × tokens. Everything else is bookkeeping. |
| TTFT | Time to first token. | Dominated by prefill — i.e. by prompt length. Long prompts cost you latency before a single word appears. |
| Tokens/sec | Decode throughput. | Bounded by memory bandwidth, not FLOPs. Batching many requests together is nearly free on compute. |
| Throughput vs latency | Total tokens across all users vs. speed for one. | Directly in tension. Big batches maximize throughput and hurt individual latency. |
| RAG | Retrieve documents, insert into the prompt, generate. | Giving the model an open book. Fixes staleness and citation, does nothing for reasoning ability. |
| Fine-tune vs. prompt vs. RAG | Three ways to specialize. | Prompt = configuration. RAG = knowledge injection. Fine-tune = behavior and format. Most "we need to fine-tune" problems are prompt or RAG problems. |
| Context length vs. cost | Cost is superlinear-ish in context. | Attention is O(n²) in compute, KV cache is O(n) in memory. The memory usually bites first. |
| Determinism | Same input, same output? | Not by default — sampling is stochastic, and even at T=0, GPU floating-point non-associativity across varying batch sizes can flip a near-tie. Bit-exact reproducibility is genuinely hard. |
19.7 Behavior and failure
| Term | What it literally is | Mental model |
|---|---|---|
| Hallucination | Fluent, confident, false output. | Not a bug in the usual sense. There is no reject state. A system that cannot fail to parse cannot fail to answer. Weak features still produce peaked distributions. |
| Sycophancy | Caving to pushback, over-agreeing. | RLHF optimizing a proxy for approval. Approval and truth are correlated but not identical, and the gap is where this lives. |
| Goodharting | Optimizing the measure until it stops measuring the thing. | Why RLVR (ground-truth reward) is safer to push hard than RLHF (taste-based reward). |
| Lost in the middle | Degraded recall of mid-context information. | Attention mass is finite and softmax-normalized. More positions, less each. Beginnings and ends survive best. |
| Repetition loop | The model cycling the same phrase. | Low-temperature sampling entering a self-reinforcing attractor — each repeat raises the probability of the next. |
| Prompt injection | Untrusted text in context issuing instructions. | The architectural consequence of having no privilege separation. Your system prompt and a hostile web page are the same kind of tokens in the same stream. There is no const in the context window. |
| Jailbreak | Prompting past post-training guardrails. | Post-training is a learned bias over a base model that can simulate anyone. It's a strong prior, not a wall. |
| Knowledge cutoff | The date training data ends. | The build date. Everything after is invisible unless supplied in context. |
Appendix A: the compressed mental model
- Your text becomes tokens; tokens become vectors.
- Those vectors are a shared workspace, one column per position, and every layer adds to rather than replaces them.
- Attention moves information sideways — learned retrieval, recomputed 5,120 times per token, where "what to match" and "what to copy" are separate learned functions.
- MLPs add knowledge vertically — ~2.3 million learned key-value slots.
- Each vector holds a sparse combination of millions of learned features, packed via near-orthogonality. Width was never the constraint.
- Depth is composition. By mid-network, your intent exists as an extractable vector, independent of your wording.
- The final vector projects to a distribution over 128K tokens; one is sampled; the loop runs again.
- It knows what you meant because predicting text written by intentional agents requires modeling intent, and compression made that the cheapest available strategy.
- Generating tokens buys serial compute; that's what chain of thought is for.
- It fails exactly where the architecture says it should. That's the strongest evidence the model above is right.
Appendix B: the analogy that actually holds
Not a search engine. Not a database. Not a person.
A very large, very fast, learned interpreter — running a program written by gradient descent, on data supplied by your prompt. The weights are the program. Your prompt is the input and a runtime reconfiguration of which parts of the program execute. The output is what that program computes.
The reason it feels like understanding is that the program it learned is, in substantial part, a model of the people who wrote its training data — including their intentions.
Further reading (primary sources)
- A Mathematical Framework for Transformer Circuits — Elhage et al., 2021
- In-context Learning and Induction Heads — Olsson et al., 2022
- Toy Models of Superposition — Elhage et al., 2022
- Interpretability in the Wild (the IOI circuit) — Wang et al., 2022
- Transformer Feed-Forward Layers Are Key-Value Memories — Geva et al., 2021
- Scaling Monosemanticity — Anthropic, 2024
- Function Vectors in Large Language Models — Todd et al., 2023
- Training Compute-Optimal Language Models (Chinchilla) — Hoffmann et al., 2022
Source: BMAD Recon ·
bmad-recon.md· updated 2026-07-25 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
BMAD Deep Recon — Complete Guide
Covers PR #2611 (merged 23 Jul 2026) and docs/explanation/deep-recon.md. Written for someone with zero BMAD background, then applied to two concrete features: user recommendation and multilingual translation.
Reading order.
bmad-deep-recon-foundations.md— what every term means and where its name came from. Thenbmad-deep-recon-howto.md— concept-by-concept, exactly what you type and what comes back (plus how to apply the same techniques in plain Claude, without BMAD). Then this document — applied domains, epistemology, and task-driven walkthroughs.
Contents
- 0. The short, honest answer to "how useful is this?"
- 1. Background: BMAD concepts you need first
- 2. What PR #2611 actually changed
- 3. The three modes
- 4. Research types (the "packs")
- 5. How a native Run thinks
- 6. Why the reports hold up
- 7. The run folder, and research as a living asset
- 8. Where it sits in the BMAD lifecycle
- 9. Configuration
- 10. Research checkpoints across a feature's life
- 11. The full use-case catalog
- 12. Applied: a user recommendation feature
- 13. Deep dive: privacy and regulatory research
- 14. Deep dive: security and adversarial research
- 15. Deep dive: algorithm and technique-trend research
- 16. Applied: a multilingual translation feature
- 17. Deep dive: licensing, procurement and vendor risk
- 18. Best practices
- 19. Anti-patterns
- 20. Command cheat sheet
- 21. Transferable design lessons
- 22. The epistemology underneath all of this
- 23. Worked walkthroughs for software engineers
- Walkthrough A — Native Run: choosing an embedding model
- Walkthrough B — Draft → Process: the MT vendor landscape
- Walkthrough C — The compliance gate, done without fooling yourself
- Walkthrough D — Threat-modeling a ranking surface
- Walkthrough E — Refresh, three months later
- Walkthrough F — Deepen one dimension
- Walkthrough G — Headless refresh in CI
- Walkthrough H — The anti-walkthrough
- 23.9 Team integration patterns
- 23.10 Calibration — what to spend where
- 24. Sources
0. The short, honest answer to "how useful is this?"
Deep Recon is decision infrastructure, not feature-building infrastructure. It pays off only when the following are all true:
- You face a choice with real switching cost (a model, a vendor, an architecture, a metric definition, a locale strategy).
- The answer is not in your repo — it lives in vendor docs, benchmark papers, pricing pages, forum threads, standards documents.
- You will otherwise decide from the model's memory, which is stale and uncited.
It is not useful for:
- Writing the ranking service itself (that's
bmad-dev/ story workflows). - Anything answerable by reading your own codebase (that's
bmad-document-project). - Facts you already know cold.
Practical read for your two examples:
| Feature | Deep Recon value | Why |
|---|---|---|
| User recommendation (in an existing OpenSearch/HNSW stack) | High — but not where you'd guess | Index config, feature availability and latency budget are internal. The external decisions that matter are privacy and regulatory (§13), adversarial robustness (§14), offline-metric conventions, and licensing (§17). Embedding-model selection ranks below all of those, because it's the one you can cheaply A/B. |
| Multilingual translation | High | Vendor/model landscape moves fast, pricing is public and comparable, language-coverage and quality claims are published and contested, and locale/normalization rules are external standards. |
Rule of thumb: if you can write the decision as "X vs Y vs Z, and I'll be stuck with it for 12 months," Deep Recon is worth 20–40 minutes. If you can write it as "how do I implement…", it isn't.
1. Background: BMAD concepts you need first
If you already know BMAD v6/v7, skip to §2.
1.1 What BMAD is
BMAD-METHOD ("Breakthrough Method for Agile AI-Driven Development") is an open-source framework that packages agentic software development into skills — structured markdown instruction sets an AI coding agent (Claude Code, Cursor, Copilot, etc.) loads on demand. It imposes a lifecycle: analysis → planning → architecture → implementation.
1.2 The vocabulary
| Term | Meaning |
|---|---|
| Skill | A directory containing SKILL.md (the entry point) plus supporting files. The unit of installation and invocation. Invoked as /bmad-<name>. |
SKILL.md | The router/entry file. Modern BMAD keeps it lean and defers detail to references/. Every token in SKILL.md is paid on every invocation. |
references/*.md | Procedure files loaded just-in-time, only when that branch is taken. This is the token-optimization pattern. |
customize.toml | Per-skill config. Merged through a layered resolver (see §9). |
| Agent | A skill with a persona (e.g. Mary the Analyst). Agents expose a menu of options that route to workflow skills. |
| Workflow | A multi-step skill. Older ones sharded into step-01…step-NN.md; newer ones use mode-based reference files. |
| Core skill vs module skill | src/core-skills/ ships to every install. src/bmm-skills/ ships only with the BMM (software dev) module. |
| Shim | A stub skill that forwards an old ID to a new one so existing habits don't break. |
| Subagent / assistant | A spawned sub-context with its own token budget, given a narrow brief, returning a digest. |
| Planning artifacts | The output folder where analysis/planning docs land, consumed by downstream skills. |
| Memlog | An append-only run log on disk that survives context loss — the durable record. |
1.3 Why "research" is a skill at all
Two failure modes drove it:
- Hallucinated grounding. Ask a model about market size or vendor pricing and it answers from training data — confidently, uncitably, and often 18 months stale.
- Uncaptured reasoning. Even when the research is good, it evaporates when the chat ends. Downstream planning has to re-derive it.
Deep Recon addresses both: no conclusion may rest on training data alone, and every engagement writes a durable cited research.md.
2. What PR #2611 actually changed
2.1 Consolidation
Three legacy skills — bmad-market-research, bmad-domain-research, bmad-technical-research — totalling ~5,136 lines of near-duplicate step files, were replaced by one skill, bmad-deep-recon, at roughly 650 lines. The three old names survive as v6 shims that forward with the corresponding type pre-set.
2.2 Relocation to core
The skill was moved from src/bmm-skills/1-analysis/ to src/core-skills/. Rationale: research is not code-project-specific (same precedent as brainstorming), so non-software and core-only installs get it too. On core-only installs, {planning_artifacts} falls back to {output_folder}. It also ships as a standalone marketplace plugin.
2.3 The v2 rework (the important part)
The first version was field-tested and found slow, token-heavy, and locally biased, with end-of-run verification degrading quality and subagent digests stranded in contexts that then died. The rework:
- Replaced the "acquisition mode" / engine-delegate registry with three modes: Draft, Process, Run.
- Added the research firewall.
- Went files-first — digests land on disk on arrival.
- Moved verification to landing time, not an end pass.
- Rescaled presets faster (standard is now 3 subagents / 8 sources / depth 2).
- Cut
SKILL.mdfrom ~3,989 to ~2,091 tokens by carving procedures intoreferences/.
2.4 Determinism kit
scripts/recon_kit.py (stdlib-only, PEP 723, unit-tested) owns work the model shouldn't hand-derive:
- Citation marker ↔ appendix cross-check
- Memlog claim tally (
ref=/status=convention, last status wins) - Staleness re-check dates from per-class freshness windows
- Deterministic run-folder slugs across the draft→process→refresh lifecycle
- Escaped/validated source-appendix HTML
This is the transferable lesson: anything mechanical and verifiable should be a script, not a prompt. Models are bad at counting and date math and excellent at judgment.
2.5 Security findings addressed in review
Worth knowing because the same mistakes appear in your own agent tooling:
| Finding | Fix |
|---|---|
{brief} interpolated inside a double-quoted shell template — $(), backticks, and quote-breaking could reach a command line, amplified by --dangerously-skip-permissions | Contract made file-based: briefs are written to {doc_workspace}/briefs/<dimension>.md, templates substitute {brief_file}. Researched/imported text never enters a command line. |
| HTML briefing rendered source URLs unvalidated | Allowlist to http(s) only; escape source-derived text before insertion |
| Refresh/Deepen reset verification status for out-of-scope claims | Statuses preserved outside the refresh scope |
user-voice pack ranked community posts above surveys | Triangulate instead; redact usernames, handles, emails, identifying URLs from verbatim quotes |
Parallel dimension writes raced on research.md | Explicit single-writer rule: digests return to the lead, which alone writes in plan order |
3. The three modes
| Mode | What happens | You supply |
|---|---|---|
| Draft | Composes a research prompt carrying the type pack's craft — pruned dimensions, freshness bars, source policy, a hard citation demand — tuned to the tool you name | One paste into ChatGPT / Gemini / Grok / Perplexity |
| Process | Files a finished report into imports/ untouched, extracts claims into digests behind the firewall, gap-checks against the pack, distills the standard cited summary with metadata frontmatter | The report, from any source |
| Run | Native research in session: plan gate → parallel fan-out → verification at landing → cited synthesis | Approval at one plan gate |
Choosing
| Situation | Mode |
|---|---|
| You already pay for a deep-research product and don't mind one round-trip | Draft → Process |
| You already have a report (analyst PDF, colleague's doc, whatever) | Process |
| You want results now, one sitting, no app switching | Run |
| Research needs internal sources or MCP tools only your session can reach | Run |
| Broad public sweep first, targeted follow-up second | Draft + Process, then a focused Run on the gaps |
The trade, stated plainly: hosted deep-research products crawl wider per dollar because you've already paid for the subscription; Run costs your tokens and minutes but stays in context and can use every tool your harness has. On a bare "research X" ask, Deep Recon states this trade once and remembers your preference for the session.
4. Research types (the "packs")
A type selects a pack: a ~25-line card of prioritized dimensions, source craft, and freshness rules. Deep Recon infers the type from your ask, or you name it.
| Type | Use when |
|---|---|
market | Sizing an opportunity, segments, pricing, go-to-market |
domain | Learning an industry or field: structure, players, rules, vocabulary |
technical | Evaluating a technology area, integration approaches, implementation reality |
competitive | Tearing down named competitors: offers, pricing, trajectory, sentiment |
user-voice | What users actually experience and want: reviews, communities |
academic-lit | Literature review, state of the art, grounding an approach in papers |
Custom types can be added via override TOML through bmad-customize.
Decision shape — a second, independent axis
- explore (default) — build understanding
- select — layers a weighted-matrix method over any type for a structured choose-between
Any type can end in a selection matrix. technical + select is the combination you'll use most for engineering decisions.
5. How a native Run thinks
Plan gate (decision, dimensions, topology, effort, time estimate)
│ you approve
▼
Topology choice
├── breadth-first ....... assistants split independent sub-questions
├── depth-first ......... assistants take different angles on one question
└── straightforward ..... one assistant, small budget
│
▼
Digests written to disk as each assistant returns
│
▼
Verify load-bearing claims as material lands
│
▼
Write the dimension's section (report grows in front of you)
│
├── leads + budget remain? → follow the leads (next round)
└── coverage or exhaustion → next dimension
│
▼
Synthesis: cross-dimension insights, recommendations, staleness map
│
▼
Mechanical citation check → research.md (+ optional HTML briefing)
5.1 The plan gate
The single hard stop. It shows the decision, the dimensions pruned to it, the topology, the knobs in force, and a realistic time estimate. Approve it and the run proceeds with light checkpoints rather than constant interrogation.
This is where you spend your attention. A bad plan gate produces a beautiful, well-cited, useless report.
5.2 Topology
Fan-out is a deliberate choice, not a default. Independent sub-questions get parallel assistants. One deep question gets several perspectives on the same material. A simple lookup gets one assistant with a handful of calls — ten agents on an easy question just burns tokens.
5.3 Effort presets
| Preset | Assistants | Sources / round | Rounds |
|---|---|---|---|
quick | 2 | 5 | 1 |
standard (default) | 3 | 8 | 2 |
deep | 6 | 12 | 3 |
Precedence: request > pinned knob > preset. Anything you say in the request overrides.
Rounds follow leads — contradictions and unexpected connections from round one become round two's assignments. A dimension stops early when its questions are answered or a full round surfaces nothing new.
5.4 Other run craft
- Per-assistant tool-call budgets — prevents one assistant from eating the whole run.
- Short-query craft with an evaluate-before-next-call loop — read the result before firing the next search.
- Shared source-quality card — answer engines (Perplexity et al.) count as one publisher; chase their citations to the primaries.
- Stop-and-write valve — commit what you have rather than spiraling.
6. Why the reports hold up
6.1 Two standing rules, inherited verbatim by every subagent
- No conclusions from training data. Model memory proposes questions and search strategy; every claim in the report traces to a source retrieved or imported during this engagement.
- The research firewall. Your project files and briefs shape what gets asked, never what gets found. Assistants receive only their assignment.
persistent_factsdefaults to empty. A run therefore cannot come back quietly confirming whatever your local context already believed.
The firewall is the single most important idea in the whole design. Confirmation bias is the default failure mode of agentic research: give an agent your architecture doc and ask it to research alternatives, and it will find that your architecture is excellent.
6.2 Citation discipline
Every claim carries a publisher, a publication date, and an access date. Inline [n] markers resolve to a source appendix. A mechanical cross-check at finalize confirms markers and appendix agree — script, not vibes.
6.3 Verification levels
| Level | Behavior |
|---|---|
normal (default) | Spot-checks the claims the recommendation actually rests on |
high | Cross-checks the pack's critical claim classes and red-teams major conclusions |
max | Checks everything; red-team pass at full breadth |
Verification runs as material lands, never as an end-of-run rewrite pass (the v1 lesson — a late pass degraded quality). The red-team pass is the single adversarial mechanism. An outcome adjusts a claim's status; it never licenses rewriting findings.
6.4 Freshness as part of truth
Each pack sets freshness windows per claim class. A market size from three years ago is reported as history, not as fact. This matters enormously in fast-moving areas — an MT quality benchmark from 2023 describes a world that no longer exists.
6.5 Files-first
Digests, extractions, and report sections hit the run folder the moment they exist. The conversation is a control channel, not storage. A run that dies mid-flight resumes from disk with nothing lost, and the report builds in front of you instead of behind a spinner.
7. The run folder, and research as a living asset
Each engagement gets one folder under your planning artifacts:
<planning_artifacts>/<run-slug>/
├── imports/ # originals, untouched
├── digests/ # extracted claims, behind the firewall
├── briefs/ # per-dimension assignments (file-based, never shell-interpolated)
├── memlog # append-only truth
└── research.md # the canonical cited report
research.md always exists as the machine-readable report. The HTML briefing is its regenerable face — this is the v7 artifact protocol: memlog = truth, markdown = distillation under contract, HTML = face. output_format = auto | html | md | both; auto renders HTML for interactive runs and markdown only for headless or skill-invoked runs.
The report ends with a staleness map naming which claims age fastest and when to re-check. That map powers the lifecycle:
- Refresh — re-verifies only stale claims, appends a delta report (confirmed / changed / overturned), and warns you when an overturned claim feeds a downstream artifact.
- Deepen — drills into one dimension without re-running the rest.
The overturned-claim warning is the sleeper feature. If your PRD's rationale rests on a pricing claim that just changed, you find out.
8. Where it sits in the BMAD lifecycle
bmad-deep-recon → research.md (+ metadata frontmatter)
│
├──→ bmad-product-brief (reads the summary; never reprocesses the original)
├──→ bmad-prd
└──→ architecture / ADRs
Downstream skills consume the summary and metadata directly. That's the payoff of the fixed output shape — a PRD doesn't care whether the research came from Draft+Process or a native Run.
Entry points: /bmad-deep-recon, or through the Analyst agent (Mary), whose menu gained TS (select shape), CR (competitive), and UV (user-voice) alongside the existing research entry.
9. Configuration
9.1 Resolution layers
Per-skill config resolves through a layered merge:
_bmad/custom/{skill-name}.user.toml— personal, gitignored — wins_bmad/custom/{skill-name}.toml— team/org, committed- The skill's own
customize.toml— defaults
Merge rules: scalars override; tables deep-merge; arrays of tables keyed by code or id replace matching entries and append new ones; all other arrays append. There is no removal mechanism — you can't delete a base item, only override it (e.g. by code with a no-op) or fork the skill.
9.2 Deep Recon's knobs
| Key | Purpose |
|---|---|
validation | normal / high / max |
red_team | Adversarial pass; default off |
use_workflows | Whether to invoke other workflows |
subagent_models | Which model each assistant class uses |
output_format | auto / html / md / both |
external_sources | MCP research sources — shipped examples: Tavily, Perplexity Sonar, xAI X-Search |
| source policies | Preferred and banned sources |
| doc standards, handoffs | Downstream contract |
| custom types | Add your own packs |
Use /bmad-customize bmad-deep-recon rather than hand-authoring TOML.
10. Research checkpoints across a feature's life
Most teams treat research as a single event at the start of a project. It isn't. A feature passes through several gates, each with a different question, a different type pack, and a different cost of getting it wrong.
| # | Gate | The question | Type + shape | Cost of skipping |
|---|---|---|---|---|
| 0 | Validate | Do users actually want this? What already exists? | user-voice, competitive | You build something nobody asked for |
| 1 | Define success | How is this measured, and does the metric predict anything real? | academic-lit | You optimize a number that doesn't move the business |
| 2 | Build vs. buy | Is there a vendor? What's the exit cost? | competitive + select | Eighteen months rebuilding a commodity |
| 3 | Legal to ship? | Lawful basis, disclosure, residency, retention, transparency duties | domain | Redesign after legal review, or a regulator finds it |
| 4 | Attack surface | Abuse vectors, adversarial ML, supply chain | technical | Security review blocks launch; or it doesn't and you learn later |
| 5 | Licensing & procurement | Can we actually use this model/library/vendor? | domain / competitive | Chosen model turns out to be non-commercial; vendor has no DPA |
| 6 | Component selection | Which model, index, library, framework? | technical + select | Reversible, usually — the cheapest gate to get wrong |
| 7 | Failure modes | What broke for everyone else who shipped this? | technical | You rediscover known incidents in production |
| 8 | Cost at scale | What does this cost at 100× current volume? | technical | Unit economics collapse post-launch |
| 9 | Refresh | Did the ground move under a decision we already made? | refresh | Silent decision rot |
The pattern worth internalizing: almost everyone does gate 6 and skips gates 3, 4, and 5 — then discovers them during security and legal review, after the design is frozen and the sprint is committed. Gates 3–5 are cheap in week one and brutal in week ten. They're also the gates where external evidence matters most and where model memory is least reliable, which is exactly the profile Deep Recon is built for.
Gate 6 — the one everybody does — is the one where research adds the least, because you can usually just try both.
11. The full use-case catalog
The categories below are where Deep Recon earns its cost on feature work. Each maps to a type pack and a signal that tells you it's worth running.
| # | Use case | Type + shape | Run it when | Typical miss if skipped |
|---|---|---|---|---|
| 1 | Feature validation | user-voice | Before committing a quarter to a surface nobody complained about | Building a feature that solves a problem users don't have |
| 2 | Competitive teardown | competitive | A rival shipped the thing you're about to ship | Reinventing, or missing table-stakes behavior |
| 3 | Metric & eval methodology | academic-lit | Your metric will govern every future decision on the feature | Optimizing an offline metric with no online correlation |
| 4 | Component / model selection | technical + select | Switching cost is high (index format, embedding dim, framework) | Lock-in to a dying option |
| 5 | Build vs. buy | competitive + select | A credible vendor exists | Rebuilding a commodity, or buying an unexitable dependency |
| 6 | Privacy & data protection | domain | Any personal data, profiling, or automated decisioning — see §13 | Redesign after legal review; regulatory exposure |
| 7 | Security, abuse & adversarial ML | technical | The feature is user-facing, user-influenced, or model-driven — see §14 | Shipping a manipulable ranking surface |
| 8 | Licensing & IP | domain | Any third-party model, dataset, or copyleft library — see §17 | Discovering the model is non-commercial after integration |
| 9 | Procurement & vendor assurance | competitive | Vendor will process your data — see §17 | Legal blocks a vendor you already built against |
| 10 | Standards & interop | technical | Language tags, encodings, collation, protocols, schemas | Subtly wrong behavior in locales you don't test |
| 11 | Accessibility & inclusive design | technical / domain | New UI surface, especially multilingual | WCAG failures found at audit; RTL and text-expansion breakage |
| 12 | Fairness, bias & transparency duties | academic-lit + domain | Ranking or recommending to people at scale | Popularity-bias spirals; unmet transparency obligations |
| 13 | Algorithm & technique trends | academic-lit | You suspect the state of the art moved — see §15 | Building on a technique the field has quietly abandoned |
| 14 | Failure-mode / postmortem research | technical | Before designing anything with a known-hard failure mode | Rediscovering others' incidents in your own production |
| 15 | Cost modeling at scale | technical | Per-request inference, egress, or storage costs dominate | Unit economics that only fail at volume |
| 16 | Dependency health & maintenance risk | technical | Adopting a library or model you'll depend on for years | Adopting an abandoned project with a bus factor of one |
| 17 | Observability & eval tooling | technical + select | You need to measure a system you can't easily inspect | No way to tell whether the feature is working |
| 18 | Migration & deprecation risk | technical | Your current stack component has a sunset signal | Forced migration on someone else's timeline |
11.1 Underrated advantages of running research as a skill rather than ad-hoc chat
Beyond the findings themselves:
- It's a durable team artifact.
research.mdwith provenance frontmatter outlives the chat, the sprint, and the engineer. New joiners read why the decision was made instead of asking. - It's audit evidence. Publisher + publication date + access date on every claim is precisely the shape compliance functions want. In a regulated environment, "we evaluated these options against these criteria on this date, here are the sources" is materially valuable on its own.
- It settles disagreements on evidence rather than seniority. A cited weighted matrix is harder to argue with than an opinion, and easier to change when the evidence changes.
- It's negotiation leverage. A sourced pricing and feature comparison is a real input to a vendor conversation.
- It parallelizes. Draft mode fires the expensive crawl into a subscription tool while you keep working; Process folds the result back in.
- It's automatable. The headless JSON contract means a quarterly refresh can run in CI, not on someone remembering.
- It inoculates against your own agentic pipelines. If you're building AI features, "no conclusions from training data" and the firewall are the disciplines you'll want in your own retrieval systems anyway. Deep Recon is a working reference implementation.
- It forces the decision to be written down. Half the value arrives at the plan gate, before a single search runs, because you had to state what you're actually deciding and under what constraints.
12. Applied: a user recommendation feature
Much of this feature is internal work — but the external parts are not the ones most engineers expect. Privacy and security carry more research value here than model selection does.
12.1 Decisions that genuinely need external evidence
| Decision | Type + shape | Dimensions to insist on at the plan gate |
|---|---|---|
| Embedding model for item/user vectors | technical + select | Multilingual coverage, dimensionality vs. index cost, MTEB/BEIR retrieval numbers with dataset caveats, license, inference cost, self-host vs. API |
| Cross-encoder reranker vs. MMR-only diversity | technical + select | Latency at your p99 budget, quality delta on public rerank benchmarks, serving cost, staleness of published numbers |
| ANN index choice / HNSW parameterization defaults | technical | Recall-vs-latency curves published by index maintainers, memory footprint at your cardinality, filtered-search behavior |
| Offline metric conventions | academic-lit | NDCG@k vs. MRR vs. Recall@k in the recsys literature, position-bias correction, offline↔online correlation evidence |
| Cold-start strategy | academic-lit or domain | Published approaches, reported lift, honest failure reports |
| What users complain about in "recommended for you" surfaces | user-voice | Filter-bubble complaints, repetition, staleness, opacity |
| Lawful basis, retention, erasure, transparency duties | domain | See §13 — the highest-value research on this feature |
| Manipulation, poisoning and inference attacks | technical | See §14 — shilling, embedding inversion, membership inference |
| Popularity bias and fairness obligations | academic-lit + domain | Measured popularity-bias effects, mitigation cost in NDCG, disclosure duties |
| Embedding model / reranker licensing | domain | Field-of-use restrictions, commercial-use thresholds, dataset provenance |
12.2 Decisions that should not go to research
- Your index mapping, shard layout, refresh interval — read your cluster.
- Which features exist in your event stream — read your schema.
- Bucketing/assignment for A/B — that's design, not evidence.
- "How do I implement MMR" — you know this; it's twelve lines.
12.3 A worked plan-gate framing
Decision: pick the embedding model for the recommendation retrieval stage, deployable on our existing OpenSearch/HNSW infrastructure, for the next ~12 months.
Constraints: must handle our language mix; ≤200 ms p99 for the retrieval stage; self-hostable preferred; index memory budget is a hard ceiling.
Candidates to include (not to confirm): [3–5 named], plus any strong option the research surfaces.
Type: technical, select shape. Effort: deep. Validation: high.
Freshness bar: benchmark claims older than 12 months are reported as history.
Note the phrasing "to include, not to confirm." Naming candidates without that framing quietly asks the run to justify your shortlist.
12.4 What good output looks like
- A weighted matrix with your constraints as the weights, not generic ones.
- Every benchmark number carrying its dataset, date, and who ran it — vendor-run numbers flagged as such.
- An explicit "what we could not determine" section (Process mode gap-checks for exactly this).
- A staleness map that says, in effect: re-check the model landscape in 4 months; the license terms in 12.
13. Deep dive: privacy and regulatory research
Not legal advice. This produces an evidence base for a conversation with privacy counsel, not a substitute for one. The value is that you arrive at that conversation with cited primary sources and specific questions instead of vague anxiety.
13.1 Why this is the strongest case for Deep Recon
Privacy research satisfies every condition that makes the skill worth its cost, simultaneously:
- Entirely external. Statutes, regulator guidance, enforcement decisions, case law. None of it is in your repo, and none of it is inferable from your architecture.
- Jurisdiction-plural. A Toronto-built product with EU users sits under PIPEDA, Quebec Law 25, GDPR, and the DSA at once — and they don't agree on consent, notice, or automated decisioning.
- Fast-moving in exactly the layer that matters. Statute text is stable; regulator guidance and enforcement posture are not, and it's the guidance that determines whether your design passes.
- Load-bearing and expensive to reverse. You can swap an embedding model next sprint. You cannot swap a lawful basis after you've collected two years of data under it.
- The worst possible case for model memory. Legal reform moves constantly and a confidently-stated stale claim here is far more damaging than a stale benchmark. The
no conclusions from training datarule is doing real work.
That last point is not hypothetical. Canadian federal privacy and AI reform has been through multiple bill cycles; I would not assert its current status from memory, and neither should any model you're relying on. That uncertainty is the argument for running the research, not a reason to skip it.
13.2 Split it into two engagements, not one
The common mistake is one sprawling "GDPR for recommender systems" run. That's two different decisions with two different source policies, and merging them produces a report that's shallow on both.
Run A — domain type: what the rules actually require
Dimensions to insist on at the plan gate:
- Lawful basis for profiling-based recommendation. Consent vs. legitimate interest, and specifically the contexts where legitimate interest has been rejected by regulators. Include the balancing-test documentation expectations.
- GDPR Art. 22 threshold. When does ranking become "automated decision-making producing legal or similarly significant effects"? Ordinary content ranking usually doesn't; ranking that gates access, pricing, or opportunity may.
- Art. 15 access and Art. 20 portability. What must you be able to show a user about their own profile? This has direct schema consequences.
- Art. 17 erasure scope. Does the right reach a trained model and derived embeddings, or only the raw interaction log? Regulator positions differ and have moved.
- Purpose limitation and secondary use. Interaction data collected for one surface being reused to train a ranker for another is a live issue.
- DSA recommender-system obligations. Parameter transparency, and the requirement to offer a non-profiling option — plus the thresholds that determine whether they apply to you at all.
- EU AI Act. Recommenders are generally not Annex III high-risk, but transparency duties and GPAI obligations can still attach if you're using a foundation model in the pipeline. Get the classification question answered explicitly rather than assumed.
- Quebec Law 25 automated-decision notice. Stricter than PIPEDA on this point and routinely missed by teams who "did GDPR."
- PIPEDA and Canadian reform status. Current, not remembered.
- US state law patchwork. Where sensitive-inference and opt-out-of-profiling provisions bite.
- Children's and minors' data. Separate, stricter, and often triggers age-assurance obligations.
- Cross-border transfer posture. Applies to embedding inference calls, which teams frequently forget are transfers.
Run B — technical type: how to actually satisfy the above
- Right-to-erasure vs. HNSW reality. Vector indexes don't truly delete — you get a soft delete plus eventual segment merge. What's the real deletion latency, is it bounded, and does a bounded-but-slow deletion satisfy the requirement? This is a genuine engineering-meets-law question with published discussion.
- Are user embeddings personal data? Increasingly treated as yes. If so, every derived vector inherits the obligations.
- Embedding inversion attacks. Published work shows embeddings can leak the attributes they were built from — which converts a "we only store vectors" argument into a liability.
- Membership inference. Can an attacker determine whether a given user was in the training set?
- Machine unlearning. What's genuinely deployable versus what's a paper with a toy benchmark. Be ruthless here.
- Differential privacy / cohort aggregation. What does the privacy budget actually cost you in NDCG?
- Retention windows. How much interaction history does the model measurably need, versus how much you're keeping by default? Data minimization is easiest to argue when you've measured the marginal value of older data.
- Consent propagation. When a user withdraws consent, what happens to precomputed recommendation caches, materialized candidate sets, and warm segments?
- Pseudonymization that survives joins. Whether your scheme actually resists re-identification given the other datasets you hold.
13.3 Configuration that matters for regulatory research
| Knob | Setting | Why |
|---|---|---|
| Source policy — preferred | EUR-Lex, EDPB, national DPAs, ICO, OPC Canada, CAI Québec, court decisions | Primary texts and regulator guidance beat everything |
| Source policy — banned | Law-firm marketing blogs, compliance-vendor content marketing | This space is saturated with SEO'd summaries that misstate scope and are optimized to sell a product |
validation | high minimum | A wrong regulatory claim is load-bearing by definition |
red_team | on (it's off by default) | If you walked in believing you're compliant, buy the adversarial pass |
| Freshness windows | Statute ~36mo · guidance ~12mo · enforcement/case law ~6mo | These claim classes genuinely age at different rates |
| Firewall | Do not paste your retention design in | Otherwise you get a well-cited report explaining why your current design is fine |
The firewall discipline is at its most valuable and most tempting to break here. The instinct to say "here's how we currently handle deletion, is it compliant?" produces a motivated report. Ask "what does the law require for deletion in vector stores?" and compare afterward.
13.4 What to hand counsel
The output should let a lawyer work in an hour rather than a week:
- Requirements traced to primary sources, with dates, not to secondary summaries
- An explicit "could not determine" section — jurisdictional gaps are the most important finding
- Conflicts between regimes surfaced as conflicts, not averaged away
- A staleness map that says which obligations to re-check and when
- Your specific open questions, framed as questions
Then keep it refreshed. The "overturned claim feeds a downstream artifact" warning is at its most valuable here: if a lawful-basis assumption in your PRD stops holding, you want the delta report to tell you, not an auditor.
14. Deep dive: security and adversarial research
Security is the other category I'd rank above component selection, and it's the one most consistently skipped until a review board forces it.
14.1 Why it fits the skill
Threat knowledge is published, adversarial, and perishable — the three properties the verification and freshness machinery exist for. It's also a domain where the model's training data is systematically incomplete: novel attack classes appear faster than they get absorbed.
14.2 Threat dimensions for a recommendation feature
Most engineers threat-model the service (authn, injection, rate limits) and skip the ranking system as an attack surface. Both belong in the run.
Manipulation and integrity
- Shilling / profile-injection attacks — coordinated fake interaction patterns that promote or bury items. Published attack models and detection approaches exist; this is a solved-ish literature that most teams have never read.
- Data poisoning of the training or feedback loop, including slow-drip attacks below anomaly thresholds
- Popularity and trending manipulation, and feedback-loop amplification
- Catalog enumeration via recommendation responses
Confidentiality and inference
- Embedding inversion — recovering source attributes from a vector (overlaps §13, but it's a security control question here)
- Membership inference — determining whether a user or item was in training data
- Cross-user leakage — inferring another user's behavior from changes in shared segments, co-occurrence surfaces, or "people also viewed"
- Model extraction — reconstructing your ranking function through systematic querying
- Cache and shared-segment poisoning
Access control
- IDOR and horizontal privilege issues on personalization endpoints — mundane, and the most commonly exploited
14.3 If there's an LLM anywhere in the pipeline
Add these dimensions explicitly:
- Indirect prompt injection via user-generated content, item metadata, or retrieved documents — the dominant risk class for retrieval-backed systems
- Tool-use boundary design and least-privilege for agentic components
- Output handling — treating model output as untrusted before it reaches a shell, a template, a query, or a browser
PR #2611 is itself the canonical worked example: a research brief was being interpolated into a double-quoted shell template, so $(), backticks, or a quote break inside researched or imported content could shape a command — amplified by a permission-skipping flag. The fix was structural, not a filter: briefs are written to a file and the template substitutes a skill-generated path, so untrusted text never touches a command line. Take that pattern wholesale into your own agent tooling. Filters on untrusted text fail; changing the channel so the text never reaches an interpreter doesn't.
14.4 Supply chain
- Model provenance — where the weights came from, what's actually verifiable
- Serialization risk in model artifacts (pickle-family deserialization remains a live RCE vector)
- Dataset provenance and whether training data carries obligations
- Dependency health — release cadence, maintainer count, open-issue and security-advisory trends
- CVE landscape for your serving stack, index, and inference runtime
14.5 Configuration for security research
| Knob | Setting |
|---|---|
| Preferred sources | NIST, OWASP, MITRE ATT&CK/ATLAS, CVE/NVD, vendor security advisories, peer-reviewed adversarial-ML venues, credible incident writeups |
| Banned sources | Security-vendor content marketing, "top 10 threats" listicles |
validation | high |
red_team | on — this is the category it was built for |
| Freshness | Attack classes ~24mo · advisories ~3mo · tooling ~6mo |
14.6 Turning the report into work
The output shouldn't stop at a threat list. Convert it: threat → likelihood/impact for your deployment → control → story. Threats you consciously accept get written down as accepted, with the citation attached. That record is what makes the next security review fast, and it's the thing that's always missing when a reviewer asks "did you consider X?"
15. Deep dive: algorithm and technique-trend research
This is the weakest of the categories you raised — but it's rescuable if you reframe it.
15.1 First, disambiguate "EMA"
Two unrelated research problems share the acronym:
- Exponential decay of user interest profiles — recency-weighted aggregation of a user's interaction vectors, so last week outweighs last year. A ranking-features question.
- EMA teacher weights in self-supervised training — momentum encoders in the BYOL/DINO/MoCo family, where a slowly-updated copy of the network supervises the fast one. A representation-learning question.
They share nothing except the smoothing formula. A run that doesn't disambiguate produces a report that's half-relevant twice over.
15.2 Why "trends in algorithms like X" is the anti-pattern
No decision means no dimension pruning, which means unbounded scope, maximum fan-out, and an expensive encyclopedia entry you'll skim once. Convert it into a decision:
| Vague ask | Decision framing | Type + shape |
|---|---|---|
| "Trends in EMA" (sense 1) | "Replace fixed-window last-N profile aggregation with EMA decay — and what half-life per surface?" | technical + select |
| "Trends in EMA" (sense 2) | "Has EMA-teacher SSL held up against contrastive training for retrieval embeddings at our scale and data volume?" | academic-lit |
| "What's new in reranking" | "Is a cross-encoder rerank worth 40ms at our p99, versus MMR-only?" | technical + select |
15.3 The dimension you must add by hand
Negative results, failed reproductions, and industry postmortems. Name it explicitly at the plan gate.
The literature is systematically positive-biased: papers report wins, and a survey that only aggregates claimed improvements is worse than nothing — it's confidently wrong in a specific, actionable direction. Recommender systems have a well-documented reproducibility problem in particular, where reported gains over properly-tuned simple baselines often fail to replicate.
This is also where "no conclusions from training data" pays off most, because model memory of an ML technique is weighted heavily toward the abstract of the original paper — the most optimistic sentence anyone ever wrote about it.
15.4 Read benchmark claims like an adversary
Require every reported number to carry:
- The dataset and its known idiosyncrasies
- The date — a 2023 retrieval benchmark describes a world that no longer exists
- Who ran it — self-reported vendor numbers flagged as such
- Tuning-budget parity — was the baseline tuned as hard as the proposed method? Usually not.
- Whether the gain survives at your scale and data regime, which is rarely the paper's regime
15.5 Know where research stops and your eval starts
For a parameter like an EMA half-life, research gives you the prior and the conventions — plausible ranges, how others parameterize per surface, what failure looks like when decay is too aggressive. It cannot give you the answer. Your offline replay against your own interaction logs decides it, and your A/B confirms it.
Running a deep engagement to pick a number your own data can answer in an afternoon is the clearest form of research-as-procrastination. Use quick for the prior, then go measure.
16. Applied: a multilingual translation feature
This is the higher-value case, because almost every decision is external.
16.1 Decision map
| Decision | Type + shape | Why external |
|---|---|---|
| MT provider / model selection | competitive + select | Pricing, language pairs, quality claims, and rate limits are all public and all move |
| Quality evaluation methodology | academic-lit | BLEU vs. chrF vs. COMET vs. LLM-as-judge is a live methodological debate; picking wrong invalidates your whole eval |
| Locale, normalization, and collation handling | technical or domain | These are external standards (Unicode/CLDR/ICU), not opinions |
| Human-in-the-loop / post-editing workflow | domain + user-voice | Localization industry has established practice you shouldn't reinvent |
| Data residency and PII in translation payloads | domain | Regulatory, jurisdiction-specific, and changes |
| Terminology/glossary and do-not-translate handling | technical | Provider feature matrices differ sharply here and are poorly summarized |
16.2 Traps this pack is built to catch
- Vendor-reported quality numbers. The source-quality card treats an aggregator as one publisher; verification at
highcross-checks the claim classes the recommendation rests on. Insist that self-reported quality claims are marked as such. - Language-coverage asymmetry. "Supports 100+ languages" is a marketing claim; per-pair quality varies by an order of magnitude. Make coverage-quality a named dimension, not a checkbox.
- Stale benchmarks. Set the freshness bar aggressively — 12 months, not 36.
- Pricing shape, not just price. Per-character vs. per-token vs. per-request vs. committed-use changes the ranking entirely at your volume. State your actual volume at the plan gate.
16.3 Suggested mode
Draft → Process → focused Run on the gaps:
- Draft a prompt for whichever deep-research product you subscribe to. Vendor landscapes are exactly what hosted crawlers are good at.
- Run it there; bring the report back.
- Process it — original preserved in
imports/, claims extracted, gaps flagged against thecompetitivepack. - Run natively on the two or three gaps (usually: your specific language pairs, and anything requiring internal or MCP-only sources).
16.4 Then refresh
Set a calendar reminder from the staleness map. MT pricing and model versions turn over on a roughly quarterly cadence. refresh the translation vendor research gives you a delta report — confirmed / changed / overturned — with a warning if an overturned claim underpins something you already shipped a decision on.
17. Deep dive: licensing, procurement and vendor risk
The most-skipped category, and the one that kills projects latest in the cycle — after integration, when unwinding is most expensive.
17.1 Model and dataset licensing
- Open weights ≠ open source. Many popular model licenses carry field-of-use restrictions, acceptable-use policies, monthly-active-user thresholds that flip commercial terms, or naming and attribution requirements. Several forbid using outputs to train competing models — which matters if you plan to distill.
- Dataset provenance. A permissively-licensed model trained on a non-commercial dataset is a real and common trap, particularly for multilingual and speech models.
- OSS license compatibility with how you actually distribute — SaaS, on-prem, embedded, and client-side each trigger different obligations.
- Output IP and indemnification. Who owns generated translations? Does the vendor indemnify you against third-party claims, and under what conditions do they void it?
Type: domain (or technical when it's mostly library compatibility). Preferred sources: the license texts themselves, model cards, official FAQs. Banned: summaries of licenses. Read the primary text; this is a category where paraphrase loses the operative clause.
17.2 Procurement and vendor assurance
If a vendor will process your data — an MT API, a hosted embedding endpoint — these determine whether you're allowed to use them at all, and they should be researched before you build against the API, not after:
- SOC 2 Type II / ISO 27001 status and scope
- DPA availability, SCCs, and transfer mechanism
- Sub-processor list and whether it's stable and notified
- Data residency and processing-location commitments
- Training-on-customer-data policy — the default matters, and so does whether opting out changes pricing or features
- Retention and deletion commitments, with actual timelines
- Breach notification SLA
- Exit terms and data portability — the exit cost is the real measure of lock-in
- Uptime SLA, rate limits, and what happens at the ceiling
Type: competitive. This is the research whose absence produces the sentence "legal won't approve the vendor we've already integrated."
17.3 Dependency and deprecation risk
Cheap to run, disproportionately useful:
- Release cadence and whether it's slowing
- Maintainer count and bus factor
- Open-issue and security-advisory trend lines
- Corporate backing, and whether the backer has a history of sunsetting
- Migration paths that already exist for people leaving
A quick preset run answers most of this and has saved more projects than any model comparison.
18. Best practices
18.1 Framing
- Lead with the decision, not the topic. "Research embeddings" gets you an encyclopedia entry. "Pick the embedding model for our recommendation retrieval stage under these constraints" gets you a recommendation.
- State your constraints as constraints. Latency budget, memory ceiling, language mix, volume, license posture, deploy target. These become matrix weights.
- Name candidates as inclusions, never as the frame. Otherwise you get a confirmation exercise.
- Declare your freshness bar when the field moves fast. Don't accept the pack default silently.
- Pick the decision shape deliberately.
selectfor choose-between,explorewhen you don't yet know the option space. Runningselecttoo early narrows prematurely.
18.2 At the plan gate
- Read every dimension. This is your only hard stop. Delete dimensions that don't bear on the decision — each one costs a full fan-out.
- Sanity-check the topology. Independent sub-questions → breadth. One contested question → depth. A lookup → straightforward. If it proposes six assistants for something simple, say so.
- Match effort to reversibility.
quickfor a decision you can undo next sprint;deep+validation = highfor something that becomes load-bearing. - Check the time estimate against your patience. Approving a 40-minute run you'll abandon at minute 12 wastes everything except what's already on disk.
18.3 During and after
- Trust the firewall; don't defeat it. Resist the urge to paste your architecture doc "for context." Project context is legitimately framing; it is inadmissible as evidence. Feeding it in is how you get a report that agrees with you.
- Chase answer-engine citations to primaries. If the report leans on an aggregator, that's one publisher, not three.
- Read the "could not determine" section first. Gaps are more informative than confirmations.
- Check the staleness map and act on it. Research that's never refreshed is research that silently becomes wrong.
- Keep
research.mdin version control alongside the PRD it grounds. The provenance frontmatter is what makes the decision auditable in six months.
18.4 Cost and token discipline
- Draft → Process is the cheap path. If you already pay for a deep-research subscription, the round-trip is one paste and it crawls wider per dollar.
- Default to
standard. The presets were rescaled downward because v1 was too slow and too token-heavy. Reach fordeepdeliberately. - Use
deepeninstead of re-running. Drilling one dimension is far cheaper than a fresh engagement. - Use
refreshinstead of redoing. It re-verifies only what's stale.
18.5 Hygiene
- Turn on
red_teamfor decisions with a strong prior. It's off by default. If you walked in already believing the answer, buy the adversarial pass. - Redact before quoting people. The
user-voicepack now requires stripping usernames, handles, emails, and identifying URLs from verbatim quotes — carry that rule into anything you paste into a PRD. - Never shell-interpolate researched text. The PR's critical finding. Researched and imported content is untrusted input; pass it by file path, not on a command line — especially near any
--dangerously-skip-permissionsflag.
18.6 Domain-specific craft
- Run the compliance and security gates in week one, not week ten. Their findings are design constraints. Discovered late, they're rewrites.
- Tune source policy per domain, not once. Regulatory research wants regulators and bans law-firm blogs; security research wants NIST/OWASP/CVE and bans vendor marketing; academic research wants venues and bans press releases. The default policy is a compromise; override it.
- Set freshness per claim class, not per run. Statute text, regulator guidance, and enforcement posture age at wildly different rates — and so do attack classes, advisories, and tooling.
- Always add a "negative results and failures" dimension to any technique or trend research. Published literature is positive-biased and won't volunteer it.
- Split regulatory research into requirements and implementation. One run answers "what must be true"; a second answers "how do we make it true in a vector index." Merging them makes both shallow.
- Demand a "could not determine" section on compliance work. In regulatory research the gaps are the finding — an unresolved jurisdictional question is more actionable than five confirmed ones.
- Convert security output into accepted-or-mitigated decisions, with citations attached. A threat list is not a deliverable; a threat register with dispositions is.
- Know where research stops. Priors and conventions come from the literature; the actual parameter comes from your offline replay. Research that could be settled by an afternoon of measurement is procrastination.
19. Anti-patterns
| Anti-pattern | Why it fails | Do instead |
|---|---|---|
| "Research recommendation systems" | No decision → unbounded dimensions → expensive encyclopedia | Name the decision and constraints |
| Running research on your own codebase | Firewall means it looks outward; your repo isn't a source | bmad-document-project |
| Pasting architecture docs "for context" | Defeats the firewall; produces agreeable findings | Let context shape questions only |
deep + max on every run | Burns tokens and minutes for marginal gain | Scale to reversibility |
| Approving the plan gate without reading | The one place your judgment is irreplaceable | Prune dimensions, check topology |
Treating research.md as a one-time artifact | Claims rot; downstream artifacts inherit the rot | Refresh from the staleness map |
| Using research to decide how to implement | Wrong tool; you need your codebase, not the web | Story workflows |
| Accepting vendor-published quality numbers | Systematically flattering | Flag self-reported; verify at high |
| Naming your preferred option in the framing | Converts research into justification | "Include X, Y, Z — and anything better" |
| "Is our current design GDPR-compliant?" | Motivated framing; the firewall exists to prevent exactly this | "What does the law require here?" — then compare yourself |
| One giant "GDPR + security + vendors" run | Three source policies in conflict; shallow on all three | Three engagements, three policies |
| Sourcing compliance claims from law-firm blogs | SEO'd, scope-distorting, sales-motivated | Ban them; require regulators and primary texts |
| Threat list with no dispositions | Reads like diligence, changes nothing | Threat → likelihood/impact → control or accepted, cited |
| Trend research with no negative-results dimension | Aggregates only claimed wins; confidently wrong | Name failed reproductions as a required dimension |
| Researching a parameter your own data can settle | Expensive procrastination | quick for the prior, then measure |
| Vendor research after integration | Discovers blockers when unwinding costs most | Procurement gate before the first API call |
| Assuming open weights means usable | Field-of-use and MAU clauses bite late | Read the license text, not a summary |
20. Command cheat sheet
| Goal | Type this |
|---|---|
| Start research | /bmad-deep-recon, then describe the decision |
| Force a type | "competitive research on DeepL and Google Translate" |
| Draft for your own tool | "draft a deep research prompt about X for Gemini" |
| Process a report | "there's a research report at ~/Downloads/report.pdf, process it" |
| Choose between options | "help me choose between Postgres and MySQL for this" |
| Refresh | "refresh the market research" |
| Deepen one dimension | "deepen the pricing dimension" |
| Customize defaults | /bmad-customize bmad-deep-recon |
Legacy IDs (bmad-market-research, bmad-domain-research, bmad-technical-research) still work and forward with the type pre-set.
21. Transferable design lessons
Independent of whether you adopt Deep Recon, these generalize to any agentic system you build:
- Consolidate near-duplicate prompt trees into one skill with data-driven variation. 5,136 lines → ~650 by turning three workflows into six ~25-line policy cards.
- Keep the entry point lean; defer detail. ~4k → ~2.1k tokens by carving procedures into just-in-time reference files. Entry-point tokens are paid on every invocation.
- Files-first, conversation-as-control-channel. Anything valuable goes to disk on arrival. Contexts die; disks don't.
- Verify at landing, not at the end. A late pass degraded quality in field testing.
- Mechanical work belongs in a tested script. Counting, cross-referencing, date math, slug generation.
- Build an explicit bias firewall. Separate "what shapes the question" from "what counts as evidence," and enforce it at the subagent boundary.
- Choose fan-out topology deliberately. Parallelism is a cost, not a virtue.
- Treat tool output as untrusted input. File-based handoff over shell interpolation, protocol allowlists, escaping at the boundary.
- Give artifacts a lifecycle. Staleness maps, delta refreshes, and downstream-impact warnings turn a document into an asset.
- Single-writer rule for parallel agents. Fan out reads; serialize writes through the lead.
22. The epistemology underneath all of this
This section assumes no philosophy background. Every term is defined in plain language before it's used.
The PR has a section literally headed "Epistemics and reliability." That's not decoration — it's the honest label for what the whole design is. Read it this way and the mechanisms stop looking like a feature list and start looking like a single coherent argument.
22.1 What "epistemology" means
Epistemology is the branch of philosophy that asks: what is knowledge, and what makes a belief justified?
The classic starting point: knowing something requires three things.
- It's true
- You believe it
- You have good reason — justification — for believing it
Miss any one and you don't have knowledge. If it's true but you have no grounds, you got lucky. If you have grounds but it's false, you're mistaken.
Why this matters here: an LLM produces sentences that satisfy #1 and #2 by accident and skip #3 entirely. It asserts things without holding beliefs, and its confidence is uncorrelated with whether it has grounds. It can tell you something perfectly true while having no justification whatsoever — the sentence just came out fluent.
There's a name for the case where you have a true, believed claim that's only accidentally right: a Gettier case (after a famous three-page paper from 1963). Picture a stopped clock that happens to show the correct time when you glance at it. You now believe something true, for a reason that gives you no actual grounds. Every uncited model output is a potential stopped clock.
Deep Recon's whole purpose is to bolt a justification layer onto a system that natively has none.
22.2 The core concepts, defined
| Concept | Plain meaning | Everyday example |
|---|---|---|
| Justification | The grounds you have for a belief — the why behind it | "The bridge is closed" vs. "The bridge is closed because I just drove past the barrier" |
| Testimony | Believing something because someone told you. Most of what anyone knows works this way — you've never personally verified that Antarctica exists | Trusting a doctor, a map, a news report |
| Foundationalism | The view that beliefs form a structure resting on a base — some things are believed because of other things, and the chain has to stop somewhere solid | A wall of bricks needs a foundation, not infinitely more bricks below |
| Defeasible | A belief held provisionally, which new evidence can overturn. It's "reasonable until defeated," not "proven forever" | "The train runs at 8" — until they change the timetable |
| Defeater | The new evidence that overturns it | The posted schedule change |
| Fallibilism | Accepting that any of your beliefs might be wrong, without concluding that nothing is worth believing. Confidence without certainty | Science's default posture |
| Falsification | Testing an idea by trying hard to break it rather than piling up examples that fit. Associated with Karl Popper | Don't count white swans; go looking for a black one |
| Calibration | Your confidence matching your actual accuracy. Being 70% sure of things that turn out true about 70% of the time | A weather forecaster who says "30% rain" and is right about that often |
| Provenance | Where a claim came from, and through whose hands | A museum tracing an artifact's ownership chain |
| Independence | Two sources counting as genuinely separate evidence, rather than both repeating one original | Two newspapers citing the same wire story are one source, not two |
22.3 Every mechanism is an epistemological commitment
Now the mapping. Each row is a design choice in the skill and the philosophical position it encodes.
| Mechanism | What it commits to |
|---|---|
| "No conclusions from training data" | Truth alone isn't enough — a claim needs traceable justification. This is the rule that bars the stopped clock. |
Inline [n] → source appendix | Foundationalism. Every claim has to bottom out in a retrieved source, not in another claim. No infinite regress, no circles. |
| "Answer engines count as one publisher — chase their citations" | Guards against false independence. Three sites echoing one primary report are one witness wearing three hats. Counting them as three is how confident nonsense propagates. |
| Publisher + publication date + access date on every claim | The epistemology of testimony. Since you're believing things you didn't verify yourself, you need to know who said it, when, and when you looked — that's exactly what lets a reader evaluate it. |
| Preferred / banned source policy | A reductionist stance on testimony: trust is earned, not presumed. Publishers are ranked. "Someone published it" is not a credential. |
| Claim statuses (unverified → verified → disputed → overturned) | Defeasible reasoning made explicit. Beliefs are held provisionally with their status visible, rather than silently promoted to fact. |
| "An outcome adjusts a claim's status — it never licenses rewriting findings" | Defeat must stay visible. You don't quietly delete the belief you used to hold; you mark it overturned. Erasing the old belief destroys the audit trail that made the new one trustworthy. |
| Staleness map + freshness windows per claim class | Fallibilism with a clock. Different kinds of claims decay at different rates — statute text ages slowly, pricing fast. Justification has a shelf life. |
| "A three-year-old market size is history, not fact" | A subtle one: the proposition itself changes character over time. It stops being a claim about the present and becomes a claim about the past. |
| Red-team pass | Falsification. Attack the conclusion instead of accumulating agreeable evidence. Off by default, which is a cost decision, not an epistemic one — turn it on when you had a prior. |
| "Could not determine" section | Calibration. Reporting the shape of your ignorance. Most systems — and most people — never do this, which is why overconfidence is the default failure. |
| Negative-results dimension for literature | Corrects publication bias: the evidence base itself is distorted, because journals print wins and quietly drop failures. Surveying only what got published systematically overestimates. |
| Verification at landing, not at the end | Justification is checked while the context that produced it still exists. A late pass evaluates claims stripped of their situation — and field testing showed it degraded quality. |
| Files-first / memlog as truth | The record of how you came to believe something must outlive the conversation. Reasoning that evaporates can't be audited. |
22.4 The elegant one: the research firewall
This is the deepest idea in the design, and it's a direct implementation of a distinction from philosophy of science.
Context of discovery vs. context of justification (Hans Reichenbach, 1930s). These are two separate questions about any belief:
- Discovery: how did you come up with this idea? A hunch, a dream, a colleague's offhand remark, your existing assumptions — anything goes. There's no wrong way to generate a hypothesis.
- Justification: what makes it true? Here almost nothing goes. The grounds have to be independent of how you happened to think of it.
The classic illustration: the chemist Kekulé reportedly hit on the ring structure of benzene after daydreaming about a snake biting its tail. Perfectly fine as discovery. Utterly irrelevant as justification — the structure is right because of the evidence, not because of the dream.
The firewall implements exactly this split at a process boundary:
- Your project files, architecture docs, and briefs are allowed to shape which questions get asked — that's discovery, and your priors are a legitimate source of good questions.
- They are inadmissible as evidence — that's justification, and it has to come from outside.
- Subagents receive only their assignment.
persistent_factsdefaults to empty.
Why this is unusually good engineering: most bias mitigation is an instruction — "be objective," "consider alternatives." Instructions fail, because a system that wants to agree with you will find a way. This is architectural. The subagent cannot be biased by context it was never given.
The failure it prevents is the single most common one in agentic research: hand an agent your design doc and ask it to research alternatives, and it comes back explaining that your design is excellent. Not through deceit — through ordinary confirmation bias, which is what you get when the thing generating hypotheses also gets to grade them.
22.5 Where the epistemology is genuinely weak
Being honest about this matters more than admiring the design.
-
The independence assumption is shaky. Fan-out to multiple assistants borrows its logic from a result about crowds: if many judges each do slightly better than chance and their errors are independent, the majority is very reliable. But these subagents share a base model, a training corpus, and query craft. They are correlated witnesses, not independent ones. Six of them do not buy six times the reliability — they buy the same blind spot, six times, expressed differently.
-
Verification isn't independent either. The system checking a claim is the same system that produced it. That's a coherence check — does this hang together? — not corroboration, which requires a genuinely separate source. Useful, but weaker than it looks.
-
The web is not a neutral evidence base. Search ranking is itself an epistemic filter, shaped by commercial incentives. What is findable is not the same as what is true, and the gap is systematic rather than random. SEO-saturated domains — compliance, security vendors, "best X for Y" — are exactly where this bites hardest, which is why the banned-source policy is doing far more work than it appears to.
-
Citation prevents fabrication, not laundering. A well-cited report built on weak sources is more dangerous than an uncited one, because it wears the costume of justification. Rigor in the form can disguise rot in the content.
-
The plan gate is the least-examined step and the most consequential. You choose which dimensions count before any evidence exists. Every downstream mechanism assumes the question was well-posed. That's where the deepest bias enters — and nothing in the system checks it. Which is precisely why it's the one place your own judgment is irreplaceable, and why "read every dimension at the plan gate" is the most important practice in §18.
The honest summary: Deep Recon doesn't give you knowledge. It gives you auditable justification — a chain you can inspect and challenge. That's a real and unusual improvement over uncited fluency, and it's less than it can look like when the output is well-formatted and thoroughly cited.
22.6 Why this matters doubly for search and recommendation work
Here's the reflexive turn: search and recommendation systems are themselves epistemic technologies.
A ranking function is an implicit theory of relevance. A theory of relevance is an implicit theory of what is worth knowing. When you build a recommender, you are building a machine that shapes what people can come to know — you're operating on the justification side of other people's beliefs, at scale.
Which means the same concepts run in both directions:
| Epistemology concept | Its form in your systems |
|---|---|
| Publication bias | Popularity bias — rich-get-richer feedback loops distorting what's available to be seen |
| Epistemic closure / narrowed inquiry | Filter bubbles — the system narrowing the space of what a user can encounter |
| Transparency of grounds | DSA recommender-transparency duties — a legal demand that an epistemic intermediary disclose its criteria |
| Construct validity (does the measure capture the thing?) | Offline/online metric divergence — NDCG moves, engagement doesn't, because the proxy measures something adjacent |
| Testimony and provenance | Source attribution in retrieval and RAG — whether users can trace what they're being shown |
| Independence of sources | Deduplication and diversity in candidate sets — ten versions of one document are one document |
| Calibration | Confidence display and abstention — showing "we're not sure" instead of always returning ten results |
| The firewall | Personalization vs. evidence — a user's history should shape what you show them, not what you claim is true |
That last row is the sharpest one, and it's worth sitting with. Deep Recon's firewall says: your context shapes the questions, never the answers. A recommendation system that lets a user's history shape what it presents as true — rather than merely what it surfaces — is doing the thing the firewall exists to prevent, to someone else, without their knowledge.
The discipline Deep Recon applies to your own knowing is the same discipline you owe your users about theirs. Provenance, defeasibility, calibrated uncertainty, source independence, visible criteria. In your research process they're hygiene. In your ranking systems they're ethics.
23. Worked walkthroughs for software engineers
Fidelity note. The session excerpts below are illustrative reconstructions. They show the shape of each interaction — the decision points, what you type, where you push back, what lands on disk. Exact prompt wording and gate formatting live in the skill's reference files and will differ in detail. The workflow, artifacts, and decision points reflect the documented design; the TOML key paths should be confirmed against your installed
customize.toml.
23.0 Setup, once
npx bmad-method install # select your IDE; deep-recon ships in core
Confirm it's there, then look at what you can change before your first real run:
/bmad-customize bmad-deep-recon
Two things worth setting on day one, before you've formed habits:
# _bmad/custom/bmad-deep-recon.user.toml (personal, gitignored)
[workflow]
output_format = "both" # md for the repo, html for sharing with non-engineers
validation = "normal" # raise per-run when the decision is load-bearing
Leave red_team off globally. Turn it on per-run, deliberately, when you notice you already believe the answer.
Walkthrough A — Native Run: choosing an embedding model
Scenario. You own multilingual search and recommendation. Retrieval runs on OpenSearch with HNSW. You need to commit to an embedding model for the recommendation retrieval stage and live with it for roughly a year, because re-embedding the corpus and rebuilding the index is a multi-week project.
This is the gate everyone runs. It's genuinely the least valuable of the gates — but it's the best one to learn the mechanics on, because the feedback loop is short.
A.1 The invocation, and the fork in the road
> /bmad-deep-recon
Deep Recon opens the floor and, on a bare research ask, states the Draft-vs-Run trade once and remembers your answer for the session. For this one, Run is right: you'll want follow-up rounds on contradictions, and some sources are technical enough that you'll want to chase citations interactively.
Now the part that determines everything downstream.
❌ What most engineers type:
research the best embedding models for multilingual search
What comes back: a competent encyclopedia entry. Every dimension is in scope because nothing was pruned. Maximum fan-out, maximum tokens, and a report that ranks models against generic criteria you don't share. You'll skim it once and decide on vibes anyway.
✅ What to type instead:
I need to pick an embedding model for the retrieval stage of our
recommendation system. Decision horizon ~12 months — re-embedding the
corpus is a multi-week project, so this is expensive to reverse.
Constraints:
- Content is multilingual; the mix is roughly [your actual mix]
- Retrieval stage budget is 200ms p99, end to end
- Index memory has a hard ceiling; dimensionality is a real cost, not a
preference
- Self-hostable strongly preferred; API-only is a significant negative
- Must be usable commercially without a revenue-threshold clause
Include these candidates — but do not treat the list as the frame, and
surface anything stronger: [3-5 named models].
Type: technical, select shape. Effort: deep. Validation: high.
Freshness: benchmark claims older than 12 months are reported as history,
not as current fact.
Four things are doing work there:
| Element | Why it matters |
|---|---|
| The decision and its horizon | Prunes dimensions. "12 months, expensive to reverse" tells the run to weight license stability and maintenance health, not just today's benchmark |
| Constraints as constraints | These become the weights in the selection matrix. Without them you get generic weights |
| "Do not treat the list as the frame" | Without this, naming candidates quietly converts research into justification for your shortlist |
| Explicit freshness bar | Embedding benchmarks from 2024 describe a different world. The pack default may be more generous than you want |
A.2 The plan gate — the only place your attention is irreplaceable
An illustrative gate for the above:
DECISION
Select an embedding model for recommendation retrieval; 12-month horizon,
high reversal cost.
DIMENSIONS (6)
1. Multilingual coverage and per-language retrieval quality
2. Dimensionality vs. index memory and HNSW build/query cost
3. Published retrieval benchmarks (MTEB/BEIR family) with dataset caveats
4. Inference latency and throughput; self-host vs. hosted
5. License terms, commercial-use restrictions, model provenance
6. Maintenance signals: release cadence, backing, deprecation risk
TOPOLOGY breadth-first (dimensions are largely independent)
EFFORT deep — 6 assistants / 12 sources per round / 3 rounds
VALIDATION high; red_team off
FRESHNESS benchmarks 12mo · license 24mo · maintenance 6mo
ESTIMATE ~35-45 minutes
Approve, or tell me what to change.
What to actually do here — this is the whole skill:
- Delete dimensions that don't bear on the decision. Each one costs a full fan-out. If you'll self-host regardless, dimension 4 collapses to "self-host latency only" — say so. Cutting one dimension at
deepsaves roughly 15% of the run. - Add what's missing. Two things are absent above and belong: fine-tuning and adaptation cost (if you'll adapt to your domain, a model that resists adaptation is disqualified regardless of zero-shot numbers), and how each model behaves under the filtered-search patterns your recommender actually uses. Benchmarks measure unfiltered top-k; your production path is filtered.
- Sanity-check the topology. Breadth-first is right here — these are independent questions. If it had proposed depth-first, that would be a signal the decision was framed as one contested question rather than six separable ones.
- Check the estimate against your patience. 40 minutes is real. If you'll wander off at minute 12, drop to
standardnow. Files-first means you keep what landed, but you'll have a partial report you don't trust.
A realistic reply:
Approve with changes:
- Drop dimension 4's hosted-API sub-question; we self-host, full stop.
- Add: fine-tuning/adaptation cost and whether the license permits it
- Add: behavior under filtered ANN search, not just unfiltered top-k
- Keep effort at deep
A.3 During the run
You'll see digests landing and report sections committing per dimension, because everything hits disk on arrival. Two things to watch:
- Aggregator pileup. If several digests trace back to the same summary site, that's one publisher wearing several hats. Say so: "dimension 3 is leaning on aggregators — chase the primary benchmark repos."
- Round-two assignments. Contradictions from round one become round two's work. If two sources disagree on a latency figure and round two isn't chasing it, flag it. Disagreement is the highest-value lead in the run.
A.4 What lands on disk
_bmad/planning/embedding-model-selection-2026-07/
├── briefs/
│ ├── multilingual-coverage.md
│ ├── dimensionality-index-cost.md
│ └── ...
├── digests/
│ ├── d01-mteb-multilingual-subsets.md
│ ├── d02-hnsw-memory-scaling.md
│ └── ...
├── memlog # append-only; claim tally lives here
└── research.md # the canonical cited report
research.md is the artifact. Everything else is the audit trail — and the reason a run that dies at minute 30 resumes instead of restarting.
A.5 Reading the report — where to look first, in order
- "Could not determine." Read this before the recommendation. If per-language quality for your two most important languages is in here, the selection matrix is weaker than it looks and you should say so out loud in the ADR.
- The selection matrix. Check that the weights are your constraints. If it weighted "ease of getting started" heavily, it defaulted to generic criteria and you under-specified at the plan gate.
- Self-reported vs. independent numbers. Every vendor-run benchmark should be flagged. If they aren't, ask.
- The staleness map. Turn it into calendar entries the same day. This is the step everyone skips and it's what makes the artifact a living asset rather than a snapshot.
- The recommendation, last. By now you know how much weight it can bear.
A.6 When the matrix disagrees with you
This happens, and it's the most useful moment in the process. Two legitimate responses:
- Your constraint was wrong or unstated. Fix the weights and say why in the ADR. "We overrode the matrix because index memory is a harder ceiling than the weighting reflected" is a fine sentence.
- Your prior was wrong. Rarer, more valuable.
What's not legitimate is re-running with a different framing until you get the answer you wanted. If you're tempted, that's the moment to turn red_team on instead.
A.7 Handoff
research.md carries metadata frontmatter that downstream skills consume directly:
/bmad-prd
The PRD reads the summary without reprocessing the digests. In your repo, put the report next to the ADR it grounds:
docs/decisions/0014-embedding-model.md # the decision
docs/decisions/0014-research.md # the evidence
Realistic cost: ~40 minutes wall clock, most of it unattended. Compare against the multi-week cost of re-embedding after a wrong pick.
Walkthrough B — Draft → Process: the MT vendor landscape
Scenario. You need translation for the multilingual feature. Six plausible vendors, opaque and structurally different pricing, quality claims that are all self-reported.
This is the textbook Draft case: a broad public sweep over commercial sources, which is exactly what hosted deep-research products are good at, and which you're probably already paying for.
B.1 Draft
> draft a deep research prompt for Gemini: choosing a machine translation
vendor for [your language pairs], ~[N] million characters/month, EU +
Canada data residency requirements, glossary/do-not-translate support
required. Competitive type, select shape.
You get back a prompt carrying the competitive pack's craft — pruned dimensions, freshness bars, source policy, and a hard citation demand, tuned to the tool you named.
Check it before you paste. Two things:
- Does it demand citations with publication dates? If the citation demand is soft, the report will be soft.
- Does it name volume and language pairs? Pricing rankings invert with volume, and "supports 100+ languages" is a marketing claim that says nothing about your specific pairs. If those aren't in the prompt, add them by hand.
B.2 Round-trip, then Process
Paste into your subscription tool, let it crawl, save the output.
> there's a research report at ~/Downloads/mt-vendors.pdf, process it
What Process does, and why each part matters:
| Step | Why |
|---|---|
Original preserved untouched in imports/ | Provenance. You can always go back to what the tool actually said |
Claims extracted to digests/ behind the firewall | Your project context can't leak into the extraction |
Gap-check against the competitive pack | The pack knows what a vendor teardown should cover. This is the highest-value step — it tells you what your external tool missed |
Distilled into the standard cited research.md with metadata | Downstream skills consume it identically to a native run |
B.3 The gap report is the point
Typical output from the gap check on a vendor sweep:
COVERED
pricing, language coverage, published quality claims, API surface
GAPS (not covered by the imported material)
- sub-processor lists and data residency commitments
- training-on-customer-data defaults and opt-out terms
- glossary / do-not-translate feature parity across vendors
- exit terms and data portability
- per-pair quality for [your two lowest-resource pairs]
Note what the gaps are: every single one is a procurement or per-pair specific question. Hosted research tools crawl broad commercial surfaces well and are poor at the narrow, contractual, and specific. That's the division of labour to internalize.
B.4 Targeted Run on the gaps only
> run a focused research pass on the gaps only — sub-processors,
training-on-customer-data terms, glossary parity, exit terms.
Type competitive, effort standard, validation high.
Small, cheap, and it lands in the same run folder — the slug is deterministic across the draft→process→refresh lifecycle, so the report is one artifact, not three.
Net cost: one paste, one file drop, one 12-minute run. Your subscription paid for the expensive crawling.
Walkthrough C — The compliance gate, done without fooling yourself
Scenario. Design freeze is in two weeks. You need to know whether the deletion path survives a right-to-erasure request against a vector index.
C.1 The framing trap, shown concretely
❌ Here's our retention and deletion design [paste]. Is it GDPR compliant?
This defeats the firewall by hand. You've supplied the conclusion as context, and you'll get a well-cited report explaining why your design is fine. It will be persuasive. It will be worthless.
✅ What does GDPR Art. 17 require when personal data has been embedded into
a vector index that does not support hard delete? Include regulator
guidance on derived data and on bounded-but-delayed deletion.
Type: domain. Validation: high. red_team on.
Then compare your design against the requirements yourself, afterward. That ordering is the entire discipline.
C.2 Source policy override — do this before running
Regulatory research is the clearest case for overriding the default source policy, because the SEO layer in this space is thick and sales-motivated:
# _bmad/custom/bmad-deep-recon.user.toml
[[workflow.source_policies]]
code = "regulatory"
preferred = [
"eur-lex.europa.eu", "edpb.europa.eu", "ico.org.uk",
"priv.gc.ca", "cai.gouv.qc.ca",
]
banned_kinds = ["law-firm marketing", "compliance-vendor content marketing"]
[workflow.freshness.regulatory]
statute = "36mo"
guidance = "12mo"
enforcement = "6mo"
Confirm the exact key paths against the shipped customize.toml; the shape and the intent are what matter.
C.3 Two runs, not one
Run 1 (domain) — what the law requires. Erasure scope for derived data, whether embeddings are personal data, Art. 22 threshold for your ranking surface, Quebec Law 25 automated-decision notice, transfer posture for inference calls.
Run 2 (technical) — how to satisfy it in your stack. Real deletion latency under HNSW soft-delete plus segment merge; whether bounded-but-delayed deletion is defensible; consent propagation to precomputed candidate sets and warm caches; embedding inversion as a residual risk.
Merging these produces a report that's shallow on both, because they want different sources and different freshness windows.
C.4 What you hand counsel
Not the whole report. A one-pager built from it:
- Requirements, each traced to a primary source with a date
- Our design's position on each — your comparison, done after the fact
- "Could not determine", verbatim. In compliance work the gaps are the finding
- Three specific questions you need a lawyer to answer
An hour of counsel time instead of a week. And when the auditor asks "how did you conclude this?", the answer is a file with publishers and access dates, not a recollection.
Walkthrough D — Threat-modeling a ranking surface
Scenario. The recommendation feature ships to authenticated users next quarter. Security review is a launch gate.
D.1 The run
> technical research: attack surface of a personalized recommendation
ranking system serving authenticated users. Cover manipulation of
rankings, poisoning of the interaction feedback loop, inference attacks
against user embeddings, cross-user leakage through shared segments,
and model extraction. Include documented incidents, not just papers.
Validation: high. red_team on.
red_team on is deliberate. You are walking in believing the system is basically safe — buy the adversarial pass.
"Include documented incidents, not just papers" is the other lever. Academic attack papers describe what's possible; incident writeups describe what's happened, which is a much better guide to what to fix first.
D.2 The transformation that makes it useful
A threat list is not a deliverable. Convert it, in the same session:
| Threat (from report) | Likelihood here | Impact | Disposition | Artifact |
|---|---|---|---|---|
| Shilling / coordinated profile injection | Medium — accounts are cheap | High — ranking integrity | Mitigate | Story: interaction-rate anomaly detection per item |
| Embedding inversion recovering attributes | Low — vectors not exposed at API | High if exposed | Control | ADR: user vectors never leave the service boundary |
| Cross-user leakage via shared segments | Medium | Medium | Mitigate | Story: minimum cohort size before a segment is surfaced |
| Model extraction via systematic querying | Low — rate limits exist | Medium | Accept, documented | Note in threat register, with citation |
| Feedback-loop poisoning (slow-drip) | Medium | High — hard to detect | Investigate | Spike: can our anomaly detection see sub-threshold drift? |
Accepted risks get written down with the citation attached. That record is what makes the next security review fast, and it's exactly what's missing when a reviewer asks "did you consider X?" and the honest answer is "probably, in a meeting."
D.3 If there's an LLM in the pipeline
Add indirect prompt injection via item metadata and user-generated content as a first-class dimension, and take the structural lesson from PR #2611 itself: they didn't filter untrusted text out of a shell command, they changed the channel so it never reached one. Briefs went to a file; the template substitutes a skill-generated path. Filters on untrusted text fail eventually. Removing the interpreter from the path doesn't.
Walkthrough E — Refresh, three months later
> refresh the embedding model research
Only stale claims get re-verified, per the staleness map. You get a delta:
CONFIRMED (11) license terms, dimensionality/memory figures, ...
CHANGED (3)
[4] Model B latency benchmark — improved ~20% after a runtime release
[7] Model C pricing — restructured to committed-use tiers
[9] Model A release cadence — slowed; last release 5 months ago
OVERTURNED (1)
[12] Model D commercial-use terms changed; the revenue threshold that
made it viable for us no longer applies as documented
⚠ Claim [12] is referenced by:
docs/decisions/0014-embedding-model.md
That warning is the whole reason the lifecycle exists. A licensing assumption underpinning a shipped decision moved, and you found out from a 6-minute refresh rather than from procurement, or a lawyer, or a blog post someone forwards you.
What you do: re-open the ADR, note the change, decide whether it changes anything. Often it doesn't. The value is that the question got asked at all.
Walkthrough F — Deepen one dimension
You committed to a model. Now you need the fine-tuning story properly, and only that.
> deepen the fine-tuning and adaptation dimension
Drills one dimension without re-running the other five. Minutes, not tens of minutes. This is the command that makes research iterative — you stop trying to front-load every question into one enormous engagement and instead pull threads as they become load-bearing.
Rule of thumb: deepen when the question was in scope but under-answered. A fresh run when the question is genuinely new.
Walkthrough G — Headless refresh in CI
The headless JSON contract means the staleness map can run itself. A quarterly job, roughly:
# .github/workflows/research-refresh.yml
on:
schedule: [{ cron: "0 6 1 */3 *" }] # first of the month, quarterly
jobs:
refresh:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Refresh decision research
run: |
bmad-deep-recon refresh \
--headless --output-format md \
--workspace docs/decisions/research/
- name: Open an issue if anything was overturned
run: ./scripts/alert-on-overturned.sh
Headless runs present canonical markdown only — output_format = auto handles that without configuration. Alert on overturned and on changed that touches a referenced claim; ignore confirmed. Otherwise you've built a quarterly notification everyone mutes by the second quarter.
This is the cheapest high-value automation in the whole system: decision rot, detected without anyone remembering to look.
Walkthrough H — The anti-walkthrough
Worth seeing the failure mode concretely, because it's what a first run usually looks like.
> research recommendation system best practices
What the plan gate proposes: eleven dimensions, breadth-first, deep, ~55 minutes. Nothing was pruned, because no decision was stated and therefore nothing could be pruned.
What you get: a well-cited, genuinely accurate survey of recommender systems. Collaborative filtering, content-based, hybrids, cold start, evaluation, MLOps. All true. All findable in any textbook. None of it weighted to a choice you face.
Cost: ~55 minutes and a large token spend, for something you skim once.
What went wrong, precisely: the plan gate is a pruning device. It prunes dimensions against the decision. No decision means no pruning function, so the run is unbounded by construction. The mechanism didn't fail — it was handed nothing to work with.
The fix is one sentence, always the same shape: "I am choosing between X, Y and Z, under constraints A, B and C, and I'll live with it for N months."
If you cannot write that sentence, you're not ready to research. You're ready to brainstorm — which is a different skill, and the right one for that moment.
23.9 Team integration patterns
Keep the research in the repo, next to the decision.
docs/decisions/
├── 0014-embedding-model.md # ADR: what we chose and why
├── 0014-research.md # the evidence, with provenance
├── 0015-mt-vendor.md
└── 0015-research.md
The ADR states the decision and the reasoning. The research file carries the citations, dates, and staleness map. Six months later the ADR tells you what, and the research file tells you whether it still holds.
Review research in PRs. A research report in a pull request gets the same treatment as code: someone checks whether the plan-gate dimensions were right, whether the weights match the team's actual constraints, and whether the "could not determine" section was read rather than skipped. This catches motivated framing better than anything else, because the person reviewing didn't want the answer.
Onboarding. A new engineer reading 0014-research.md learns why the stack looks the way it does, from sources, in twenty minutes. Compare with the usual mechanism: asking whoever's been there longest and getting a partly-reconstructed memory.
Don't let it become theatre. Research that's produced, filed, and never refreshed or contradicted is a compliance ritual. The signal that it's real: someone, at some point, overrode a recommendation and wrote down why.
23.10 Calibration — what to spend where
| Engagement | Preset | Validation | Red team | Time | Worth it when |
|---|---|---|---|---|---|
| Library or framework pick | quick | normal | off | ~8 min | Multi-year dependency |
| Dependency health check | quick | normal | off | ~6 min | Always. Cheapest useful run there is |
| Metric / eval methodology | standard | normal | off | ~20 min | The metric will govern future decisions |
| Component selection (model, index) | standard–deep | high | off | 25–45 min | Reversal cost is weeks |
| Vendor / build-vs-buy | deep | high | off | 35–50 min | Data leaves your boundary |
| Privacy / regulatory | deep | high | on | 40–60 min | Any personal data or profiling |
| Security / threat model | deep | high | on | 40–60 min | User-facing or model-driven surface |
| Algorithm / technique trend | quick–standard | normal | off | 10–20 min | Only after converting it to a decision |
Two patterns in that table worth naming:
red_teamis on exactly where you have a prior. Nobody walks into a privacy or security review neutral — you walk in believing you're fine. That's the condition the adversarial pass exists for.- The cheapest runs are the most consistently underused. A six-minute dependency-health check has probably prevented more grief than any model comparison ever run.
24. Sources
- BMAD-METHOD PR #2611 — "feat(core): consolidate research trio into bmad-deep-recon", merged 23 Jul 2026 — https://github.com/bmad-code-org/BMAD-METHOD/pull/2611
docs/explanation/deep-recon.md— https://github.com/bmad-code-org/BMAD-METHOD/blob/main/docs/explanation/deep-recon.mddocs/how-to/customize-bmad.md(config layering and merge rules) — https://github.com/bmad-code-org/BMAD-METHOD/blob/main/docs/how-to/customize-bmad.md
Applied guidance in §12–§17, the epistemological analysis in §22, and the walkthroughs in §23 are my analysis, not sourced from the BMAD docs. The named philosophical concepts (Gettier, Popper, Reichenbach, Condorcet) are standard terms of art in epistemology and philosophy of science.
Source: bmad-rcon-foundation ·
bmad-rcon-foundation.md· updated 2026-07-25 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Part 0 — Foundations
Read this before bmad-deep-recon-guide.md. It assumes no background in agentic tooling. Every term is defined, traced to where its name came from, and shown in use.
Why the etymologies? Almost every word in this system is borrowed from somewhere else — the military, graph theory, newspaper typesetting, caching, law. That's not decoration. When you know that "topology" comes from network diagrams and "breadth-first" is a graph-traversal algorithm, you stop memorizing vocabulary and start predicting what the system will do, because you already understand the source concept. Naming is compressed explanation.
Contents
- 1. The five-minute model, before any vocabulary
- 2. Layer 1 — the AI substrate
- 3. Layer 2 — how BMAD is built
- 4. Layer 3 — Deep Recon's own vocabulary
- 5. Six mental models that carry the most weight
- 6. Where the metaphors break
- 7. Learning path: your first two weeks
- 8. Resources and references
- 9. What I'm confident about, and what I'm not
1. The five-minute model, before any vocabulary
Strip away every term and this is the whole system:
You have a decision to make. You don't trust the AI's memory, because it's stale and can't show its work. So you make it go look things up, in parallel, from sources it has to name — while preventing your own assumptions from leaking into what it finds. Everything it learns gets written to a file with dates attached, so that in six months you can tell whether it's still true.
Four moving parts:
- A decision — stated up front, with constraints. Everything else is shaped by it.
- A plan you approve — what will be investigated, how, and for how long.
- Parallel lookup with a barrier — several sub-processes search, none of them sees your assumptions.
- A dated, cited file — the durable output, with an expiry warning built in.
If you hold only that, the rest of the vocabulary is just precise names for these four things.
2. Layer 1 — the AI substrate
You need these before BMAD makes sense. Skip any you already know.
2.1 The basics
| Term | What it is | Where the name comes from | How it shows up here |
|---|---|---|---|
| Model (LLM) | The trained neural network that produces text. "Large Language Model" | A model in the scientific sense: a compressed representation of something, used to make predictions | Claude, GPT, Gemini. Different subagents can run different models |
| Token | The unit of text a model reads and writes — roughly ¾ of a word, or a word fragment | The philosopher C. S. Peirce's type/token distinction: a "type" is the abstract pattern, a "token" is one concrete instance of it. NLP borrowed it for "one unit of text" | Every instruction file costs tokens. This is why BMAD obsesses over file size |
| Context window | The total amount of text the model can hold at once — the instructions, your messages, the tool results, everything. Finite | Window from signal processing: a bounded view sliding over a longer stream. You see a portion, not the whole | The hard constraint behind almost every BMAD design choice. Run out and the model starts forgetting the beginning |
| Prompt | The text you send in | Theatre — the prompter feeds an actor their line from offstage | Draft mode's entire output is a prompt |
| Inference | One act of the model producing output | Logic/statistics: deriving a conclusion from inputs. Contrasted with training | "Inference cost" = what you pay per call |
| Training data / knowledge cutoff | What the model learned from, and the date it stops | Self-explanatory | The reason "no conclusions from training data" exists as a rule |
| Hallucination | The model stating something false with full confidence | Borrowed from psychiatry — perceiving what isn't there. A contested term: critics argue it implies a perceptual glitch when the real cause is that the model was never tracking truth in the first place. "Confabulation" is the more accurate word | The failure mode the citation discipline exists to prevent |
| Grounding | Tying output to a verifiable external source | Electrical grounding — connecting a circuit to earth so it has a stable reference | The entire point of Deep Recon |
2.2 Agents and tools
| Term | What it is | Where the name comes from | How it shows up here |
|---|---|---|---|
| Agent | A model that can take actions in a loop — call tools, observe results, decide what to do next — rather than just answering once | Latin agere, "to do." In AI theory, a rational agent perceives an environment and acts on it (the framing in Russell & Norvig's standard textbook) | ⚠️ BMAD uses this word differently — see §3 |
| Tool / function calling | Giving the model a set of callable functions (search the web, read a file, run a command) | Literal | Search, file writes, and MCP servers are all tools |
| Harness | The program that runs the loop around the model, handles tool calls, and manages files | From draft animals and, later, test harnesses in software: the rigging that connects a component to the thing driving it | Claude Code, Cursor, etc. BMAD skills run inside a harness |
| Subagent | A separate model instance spawned with its own fresh context and a narrow assignment, which reports back | Sub- = below. It's an agent working for an agent | The parallel researchers. Also called assistants in the docs |
| MCP (Model Context Protocol) | An open standard for connecting models to external data sources and tools | Literal description | How Deep Recon reaches Tavily, Perplexity, or your internal systems |
Why subagents exist at all — this is worth understanding, because it explains the whole architecture. The context window is finite. If one model does twelve searches, it accumulates twelve pages of raw text and chokes. Instead: spawn six subagents, each with its own fresh window, each doing two searches and returning a half-page summary. The parent receives six half-pages instead of twelve full pages.
The cost: each subagent only knows what you told it. Coordination becomes the hard problem. Nearly every complexity in Deep Recon — briefs, digests, the single-writer rule — is machinery for managing that trade.
3. Layer 2 — how BMAD is built
3.1 The naming collision, flagged early
| Word | AI meaning | BMAD meaning |
|---|---|---|
| Agent | A model acting in a loop with tools | A persona — "Mary the Analyst" — that presents a menu and routes you to workflows |
| Skill | (no standard AI meaning) | A folder of markdown instructions the model loads on demand |
When BMAD docs say "the Analyst agent," they mean a persona file, not an autonomous process. Both senses appear in the same repo. Read from context.
3.2 Structural vocabulary
| Term | What it is | Where the name comes from | How you use it |
|---|---|---|---|
| Skill | A directory with SKILL.md plus supporting files. The unit of installation and invocation | A packaged competence the agent acquires — the way a person learns a skill and can then perform it on demand | You invoke it: /bmad-deep-recon |
SKILL.md | The entry file. A router: reads your request, decides which branch, loads the right procedure | Convention | You never edit it directly; you override via TOML |
references/ | Procedure files loaded only if that branch is taken | Literal | Why the skill shrank from ~4,000 to ~2,100 tokens: detail moved here |
| Just-in-time loading | Loading instructions only when needed | Borrowed from just-in-time manufacturing (Toyota, 1970s): don't stock parts until the moment of use, because inventory is waste | The core token-optimization pattern. Context is the inventory |
| Slash command | Typing /name to invoke something | IRC and chat clients, 1990s: /join, /me | How you start every skill |
| Workflow | A multi-step skill with a defined sequence | Business process management | Deep Recon is a workflow |
| Module | A bundle of skills shipped together. core = everything; bmm = software development | Software modularity | Deep Recon moved from bmm to core in PR #2611, so non-software installs get it |
| Shim | A stub that forwards an old name to a new one | Mechanical: a thin wedge inserted to fill a gap or level a surface. Software borrowed it for thin compatibility layers | bmad-market-research still works; it forwards to Deep Recon with type=market |
| TOML | The config file format (customize.toml) | "Tom's Obvious Minimal Language," named for its creator, Tom Preston-Werner | Where you set validation level, source policies, output format |
| Frontmatter | A metadata block at the top of a markdown file | Book publishing: the front matter is everything before the main text — title page, copyright, contents. Static site generators (Jekyll, ~2008) borrowed it for the YAML block | Carries research.md's provenance so downstream skills can consume it |
| Artifact | A file produced by a process, kept as evidence of it | Archaeology: an object made by a human, evidence of activity. Software took it for build outputs | research.md is the artifact; the memlog is its excavation record |
| Planning artifacts | The output folder where analysis and planning documents land | Compound of the above | Where your run folders go |
| Memlog | An append-only file recording what happened, in order | Memory + log. Append-only is the load-bearing part: you add, never edit | Survives context death. The claim tally lives here |
Why "append-only" matters — if you can rewrite history, the record can't be trusted, because you can't distinguish "this was always true" from "someone changed it." Append-only logs are the backbone of databases, version control, and blockchains for exactly this reason. Here it means: a claim's status can be added to (unverified → verified → overturned), and the earlier states remain visible.
4. Layer 3 — Deep Recon's own vocabulary
4.1 "Recon"
Military reconnaissance: scouting ahead of the main force to learn the terrain and the enemy's position before committing troops. From French reconnaître, "to recognize" — literally to know a place again, by having been there.
Why it's the right metaphor:
- It precedes commitment. You scout before you move, not during.
- It's cheap relative to what it protects. A patrol costs far less than a failed assault.
- It's information-gathering, not action. Recon doesn't take the hill. It tells you whether the hill is takeable.
- "Deep" recon is a real military term: patrolling far beyond the forward line, at higher risk, for strategic rather than tactical information.
Where the metaphor is useful to you: it correctly implies that research is preparatory and bounded. Research that never terminates in a decision has failed at being recon.
4.2 "Run" — the term you asked about
What it is: one complete, bounded execution of a research engagement. It has a beginning, an end, a configuration, an identifier, a folder on disk, and outputs. When you say "the run," you mean that whole bounded episode and everything it produced.
Where the name comes from — three overlapping ancestors:
- Manufacturing. A production run: one batch produced under one set of settings. Change the settings, it's a different run. This gives you the sense of bounded, configured, and reproducible-ish.
- Early computing. In batch-processing days, you submitted a job and the operator ran it. One run = one pass of one job through the machine, producing one printout. This gives you one execution, one output.
- Machine learning experiment tracking — almost certainly the immediate ancestor. In tools like MLflow and Weights & Biases, a run is one execution of an experiment: it gets a run ID, a config, logged metrics, and an artifact directory. That's exactly Deep Recon's structure — a slug, a config, a memlog, an artifact folder.
Why the word does real work here. Compare the alternatives:
- "Session" would imply it's tied to your conversation. It isn't — it survives on disk.
- "Query" would imply one question, one answer. It's dozens.
- "Report" names only the output, not the process.
- "Run" names the bounded episode plus its record. That's the concept you need, because you refresh a run, deepen a run, resume a run.
How you use it — the three senses, which trip up beginners:
| Sense | Example | Meaning |
|---|---|---|
| The mode | "use Run mode, not Draft" | Do the research natively in this session, rather than drafting a prompt for another tool |
| The episode | "the run took 40 minutes" | This particular bounded execution |
| The verb | "run the research" | Execute it |
Practical consequences of a run being a bounded, identified thing:
- It has an identity. The folder slug is deterministic, so a draft, its later processing, and a refresh three months on all land in the same run folder. One decision, one run, one artifact — not three loose files.
- It's resumable. Because everything hits disk as it arrives, a run that dies at minute 30 picks up from disk rather than starting over.
- It's refreshable.
refreshre-executes only the stale parts of an existing run. You can only do that to something with an identity. - It's configurable as a unit. Effort, validation, and freshness are properties of the run, set once at the plan gate.
4.3 "Mode" — Draft, Process, Run
Mode = a way of operating; from Latin modus, "measure, manner." Same sense as a camera's portrait/night mode: the same device, different operating logic.
The three are named for what you do, and they differ in where the searching happens:
| Mode | Where the crawling happens | You supply | Pick it when |
|---|---|---|---|
| Draft | Elsewhere — ChatGPT, Gemini, Perplexity | One paste, one round-trip | You already pay for a deep-research subscription |
| Process | Already happened, somewhere | A finished report | Someone handed you a document |
| Run | Here, in this session | Approval at one gate | You want it now, or you need tools only this session has |
4.4 "Type" and "pack"
Type = the category of research: market, domain, technical, competitive, user-voice, academic-lit.
Pack = the ~25-line configuration card that a type selects. From the gaming/software sense of a pack: a bundle of assets shipped as a unit (texture pack, expansion pack).
A pack contains: prioritized dimensions, source craft, freshness rules per claim class. This is the "data-driven variation" that let three near-identical workflows collapse into one skill — the procedure is shared, only the card differs.
Decision shape is a second, independent axis: explore (build understanding) or select (choose between options, using a weighted matrix). Shape because it describes the form the conclusion takes. Any type can combine with either.
4.5 "Dimension" — the second term you asked about
What it is: one independent axis of inquiry within your research question. Your question gets decomposed into dimensions; each one is investigated separately, often in parallel; the results are recombined.
Where the name comes from: geometry and measurement. A dimension is an independent direction of variation — you can move along one without moving along the others. Length, width, height. Data analysis borrowed it (a "dimension" in a data cube: time, region, product), and the sense carried is the same: a separable axis.
Why the word is well chosen — it encodes three properties at once:
- Independence. Dimensions should be as orthogonal as possible, because that's what makes parallel investigation valid. If two dimensions overlap heavily, you're paying twice for one answer.
- Coverage. Together they should span the decision. In geometry, dimensions that don't span the space leave regions you can't reach; a dimension set that doesn't span the decision leaves blind spots. That's what the "could not determine" section reports.
- Separability. Each can be handed to a different subagent with a self-contained brief.
How you use it — this is the highest-leverage skill in the whole system. At the plan gate you're shown a proposed dimension list. Your job:
| Action | When | Why it matters |
|---|---|---|
| Delete a dimension | It doesn't bear on the decision | Each costs a full fan-out. At deep, cutting one saves ~15% of the run |
| Add a dimension | Something decision-critical is missing | The pack is generic; only you know your constraints |
| Split a dimension | It's actually two questions with different sources | Merged dimensions produce shallow answers on both |
| Merge two | They'd hit the same sources | Removes duplicated effort |
Concrete example from the guide: for an embedding-model choice, the proposed dimensions covered benchmarks, cost, license and maintenance — but omitted behavior under filtered ANN search. Benchmarks measure unfiltered top-k retrieval; your recommender queries with filters. That missing dimension is the difference between a report that answers your question and one that answers a similar-looking question.
4.6 "Topology" — and where breadth-first/depth-first come from
What it is: the shape of how subagents are arranged against the dimensions — how work is split and connected.
Where the name comes from: mathematics. Topology studies properties of shape that survive stretching and bending — what's connected to what, regardless of distance. Network topology (star, ring, bus, mesh) is the borrowing you've probably met: the diagram of what connects to what, ignoring cable length.
Here it means the same: the connection pattern of the work graph. Not how long each part takes — the shape.
The three topologies:
| Topology | Shape | Use when |
|---|---|---|
| Breadth-first | One plan → N assistants → N independent sub-questions, in parallel | The dimensions are genuinely independent (the usual case) |
| Depth-first | One question, several assistants attacking it from different angles, iterating | One contested question where the disagreement is the problem |
| Straightforward | One assistant, small budget | A lookup. Ten agents on an easy question just burns tokens |
Where "breadth-first" and "depth-first" actually come from — and this is the good part. They are graph-traversal algorithms from computer science.
Imagine exploring a maze:
- Depth-first search (DFS): pick a corridor, follow it as far as it goes, hit a dead end, back up, try the next. You go deep before you go wide. Traces back to Trémaux's 19th-century maze algorithm; formalized in mid-20th-century CS.
- Breadth-first search (BFS): step one pace down every corridor, then two paces down every corridor, and so on. You go wide before you go deep. Developed in the 1950s (Moore, for maze routing; Lee, for circuit routing).
The classic property: BFS finds the shortest path and explores evenly; DFS goes further faster on one path but can get lost down a long wrong corridor.
The metaphor transfers exactly:
- Breadth-first research: cover all six dimensions to a moderate depth, evenly.
- Depth-first research: drive hard down one question, following where it leads, accepting you'll learn less about everything else.
Knowing the algorithms means you can predict the failure modes. BFS's weakness is memory — it holds the whole frontier at once, which is exactly why breadth-first research burns more tokens per round. DFS's weakness is going deep down a corridor that doesn't matter — which is exactly what happens when you depth-first a question that turns out to be a side issue.
Fan-out — a related term. From digital electronics: the number of gate inputs a single output can drive before the signal degrades. Distributed systems borrowed it for one request spawning many downstream calls. Here: one plan spawning N assistants. The electronics sense carries a useful warning — fan-out has a limit past which quality degrades. More parallelism is not free, and it is not automatically better.
4.7 "Plan gate"
What it is: the single mandatory stop where the system shows you what it intends to do, and waits.
Where the name comes from: gate in the sense of a controlled passage — nothing proceeds until it opens. Two direct ancestors:
- Stage-Gate in product development (formalized by Robert G. Cooper in the 1980s): projects move through stages, and between them sit gates where a decision-maker says go, kill, hold, or recycle. The point of a gate is that it's the only place a decision is made, so the decision gets real attention.
- Quality gates in CI/CD: the build doesn't advance unless it passes.
Why there is exactly one. Constant interrogation trains you to click through. One well-placed gate that actually matters gets read. After it, the run proceeds with light checkpoints. This is a deliberate attention-budget decision, and it means your attention at the gate is the single highest-value input you provide to the entire system.
What it shows: the decision, the dimensions, the topology, the knobs, and a time estimate. What you do: prune, add, adjust, check that the estimate matches your actual patience.
4.8 Rounds, leads, budgets, presets
| Term | What it is | Where the name comes from |
|---|---|---|
| Round | One full cycle of parallel searching, after which results are assessed and the next cycle assigned | Boxing, tournaments, negotiations: a bounded phase followed by reassessment |
| Lead | A finding that points toward something worth chasing next — a contradiction, an unexpected connection | Journalism: a lead is a tip pointing at a story. Round 2's assignments come from round 1's leads |
| Tool-call budget | A cap on how many searches one assistant may make | Old French bougette, "little pouch" — a bounded allocation. Prevents one assistant eating the whole run |
| Effort preset | A named bundle of settings: quick / standard / deep | Preset from audio and camera equipment: stored settings recalled by name |
| Stop-and-write valve | The rule that says commit what you have rather than spiralling | Valve: a pressure-release mechanism |
Precedence — worth memorizing: your request beats a pinned setting beats the preset. Whatever you say in the moment wins.
4.9 Briefs, digests, claims
| Term | What it is | Where the name comes from |
|---|---|---|
| Brief | The narrow assignment handed to one subagent — written to a file, never a command line | Legal brief (a condensed statement of a case) and military briefing. Both mean: the minimum someone needs to act, deliberately short |
| Digest | Extracted, arranged claims from a source | Latin digerere, "to carry apart, arrange." Justinian's Digest (6th c.) organized scattered Roman law into a usable body. A digest is not a summary — it's rearranged for use |
| Claim | A single assertion, carrying a source, dates, and a status | Legal and philosophical: an assertion put forward as true, awaiting support |
| Status | unverified → verified → disputed → overturned | Latin status, "state." The point is that a claim's condition is tracked and visible, not silently promoted to fact |
| Import | A finished external report, preserved untouched | Trade: something brought in from outside, whose origin is recorded |
Why briefs are written to files and not passed as text on a command line — the security fix in PR #2611, and worth understanding as a beginner because it generalizes. If untrusted text is pasted into a shell command, characters like $(...) or backticks are interpreted as commands rather than read as text. Researched content is untrusted by definition — you fetched it from the internet. The fix wasn't to filter dangerous characters (filters always leak); it was to change the channel so the text never reaches an interpreter. It goes to a file; the command references the file's path.
That pattern — remove the interpreter from the path rather than sanitizing the input — is one of the most transferable ideas in the whole PR.
4.10 "Firewall"
What it is: the rule that your project context may shape which questions get asked, but never what counts as evidence. Subagents receive only their brief.
Where the name comes from: building construction. A firewall is a physical wall built to a fire-resistance standard, dividing a structure into compartments so a fire in one can't spread to the next. Cars borrowed it (the barrier between engine and cabin), then networking borrowed it in the late 1980s.
The property that carries through every sense: a barrier at a boundary that stops one specific thing from propagating, by design and by construction rather than by vigilance.
Why it matters more than it sounds. The failure it prevents is subtle: hand an AI your architecture document and ask it to research alternatives, and it comes back explaining that your architecture is excellent. Not through deceit — through ordinary confirmation bias. The thing generating hypotheses is also grading them.
Most attempts to fix this are instructions — "be objective," "consider alternatives." Instructions fail, because a system inclined to agree with you will find a way. The firewall is architectural: the subagent cannot be biased by context it was never given. That's the difference between a policy and a wall.
The closest analogy from research methodology is the double-blind trial: the experimenter doesn't know which subject got the drug, so their expectations cannot contaminate the measurement. Not "must not" — cannot. Same idea, same reason it works.
4.11 Staleness, freshness, refresh, deepen
| Term | What it is | Where the name comes from |
|---|---|---|
| Staleness | A claim that was true when gathered and may not be now | Literal — stale bread. Borrowed into caching, where data past its TTL is "stale": still present, still readable, no longer trustworthy. That's precisely the condition |
| Freshness window | How long a class of claim stays trustworthy. Statute ~36 months; enforcement guidance ~6 | Same caching lineage — essentially a TTL per claim class |
| Staleness map | The section of the report listing what ages fastest and when to re-check | Cartographic metaphor: a map of where the ground is likely to move |
| Refresh | Re-verify only the stale claims; produce a delta report | Caching again: refreshing a cache entry re-fetches it |
| Delta | The difference: confirmed / changed / overturned | Greek letter Δ, standard notation for change |
| Deepen | Drill further into one dimension without re-running the others | Literal |
The distinction that matters: refresh when time has passed. deepen when a dimension was under-answered. A brand-new question is neither — that's a fresh run.
4.12 Provenance, citations, verification
| Term | What it is | Where the name comes from |
|---|---|---|
| Provenance | The documented origin and chain of custody of a claim | French provenir, "to come from." Art history and archaeology: the ownership record that establishes an artifact is what it claims to be. A painting without provenance may still be genuine — but you can't show that it is |
| Citation marker | The inline [n] pointing to a source | Latin citare, "to summon" — literally calling a witness |
| Source appendix | The numbered list the markers resolve to | Latin appendere, "to hang upon" — attached matter |
| Cross-check | The mechanical script verifying every marker has an entry and vice versa | Bookkeeping: reconciling two records against each other |
| Verification level | normal / high / max — how much gets checked | Latin verus, "true" — making true, or showing to be so |
| Red team | An adversarial pass that attacks the conclusion | Cold War military war-gaming: Red played Soviet forces, Blue played US. The Red team's job was to win against the plan. Security adopted it in the 1990s |
Why provenance is the right word, not "sources." A source list says where things came from. Provenance adds when it was published, when you accessed it, and who published it — which is what lets someone else evaluate a claim they didn't verify themselves. That's the difference between a bibliography and an audit trail.
4.13 The remaining machinery
| Term | What it is | Where the name comes from |
|---|---|---|
| Slug | The short deterministic identifier for a run folder | Newspaper typesetting. A "slug" was a line of metal type cast by a Linotype machine; the slugline was a short label identifying a story as it moved through production. Web development borrowed it for URL identifiers |
| Deterministic | Same input always produces the same output | Latin determinare, "to limit." Why it matters: the run slug must be identical across draft → process → refresh, or your artifacts scatter |
| Single-writer rule | Subagents return digests to the lead; only the lead writes research.md | Concurrency control. Parallel writes to one file race and corrupt. Fan out reads, serialize writes |
| Headless | Running without an interactive user or display | Headless server / headless browser: no "head" = no monitor. Here it means CI, so output defaults to plain markdown |
| Idempotent | Running it twice has the same effect as running it once | Latin idem ("same") + potens ("power"). Why refresh is safe to schedule |
| Determinism kit | recon_kit.py — the script owning counting, date math, and slug generation | Because models are unreliable at mechanical tasks and excellent at judgment. Give each what it's good at |
5. Six mental models that carry the most weight
If you remember nothing else:
1. Recon is scouting before you commit troops. Cheap relative to what it protects, and it must terminate in a decision or it wasn't recon.
2. The run folder is a lab notebook. Dated, append-only, showing failed attempts alongside successes. The value isn't the conclusion; it's that someone else can check your work — including future you.
3. The firewall is a double-blind trial. The researcher can't be influenced by expectations they were never given. Architectural, not aspirational.
4. Dimensions are axes that must span the space. Independent enough to parallelize, complete enough to cover the decision. Gaps in the span are exactly what "could not determine" reports.
5. Staleness is cache TTL for beliefs. Still readable, no longer trustworthy. Different claim classes have different TTLs, and that's a feature.
6. The plan gate is a stage gate. There is exactly one, deliberately, so that it gets read. Your attention there is the highest-value input you give the system.
6. Where the metaphors break
Understanding a borrowed term includes knowing where the borrowing stops. Four that mislead beginners:
| Metaphor | What it correctly suggests | Where it misleads |
|---|---|---|
| "Run" | Bounded, configured, identified, resumable | Implies determinism. Two runs with identical settings will not produce identical reports — the model is stochastic and the web changes underneath you. It's a run in the experiment-tracking sense, not the build sense |
| "Dimension" | Separable axes of inquiry | Implies true orthogonality. Real research dimensions overlap constantly — license terms bear on maintenance risk, latency bears on cost. Treat them as mostly separable and expect some duplicated effort |
| "Firewall" | A barrier that blocks propagation by construction | Implies a static perimeter. It's a data-flow rule about what enters a subagent's context — and you can defeat it by hand, simply by pasting your assumptions into the request. It protects against accidental leakage, not against you |
| "Verification" | Checking whether a claim holds | Implies independent confirmation. The system checking a claim is the same system that produced it — that's a coherence check, not corroboration. Genuinely weaker than the word suggests |
That last one is the most important caveat in this entire document. Deep Recon does not give you knowledge. It gives you auditable justification — a chain you can inspect and challenge. That's a real improvement over uncited fluency, and it is less than a well-formatted, thoroughly-cited report can look like.
7. Learning path: your first two weeks
Week 1 — mechanics
Day 1 — Install and read the shape.
npx bmad-method install
Then /bmad-customize bmad-deep-recon and just read the options. Don't change anything. You're building a map of what's adjustable.
Day 2 — The calibration run. This is the single most useful beginner exercise, and almost nobody does it:
Run a
quickengagement on a question you already know the answer to cold — something in your own stack you could write a memo about from memory.
Because you know the content, you're free to evaluate the machinery. Where did it go for sources? Did it find the thing you know is the definitive reference? Where was it confidently thin? Where did it flag uncertainty you'd have glossed over? Twenty minutes here teaches more about calibration than five real runs.
Day 3 — Deliberately break it. Run the anti-pattern: research recommendation system best practices. Watch the plan gate propose eleven dimensions and fifty-five minutes. Kill it at the gate. You'll have learned viscerally why a decision statement is load-bearing, for the price of two minutes.
Day 4 — A real cheap run. Pick a genuine but low-stakes decision — a library choice, a dependency health check. quick, standard validation. Take the output to a colleague and defend it.
Day 5 — Read the artifacts. Open the run folder. Read a brief, a digest, the memlog, then research.md. Trace one claim from the report back through its digest to its source. Once you've done this once, you'll never over-trust a report again.
Week 2 — judgment
Day 6–7 — Draft → Process. Draft a prompt, run it in whatever deep-research tool you have, process it back. The gap report is the lesson: see for yourself what hosted tools systematically miss.
Day 8 — Configure. Write your first user.toml. Set a source policy for one domain you know well — you can tell good sources from bad in your own field, which is exactly what you need to write a policy.
Day 9 — A load-bearing run, with red_team on. Notice how the adversarial pass changes the tone.
Day 10 — Refresh something. Even a two-week-old run. Watch the delta mechanism work.
The habit that makes it stick
Every time you're about to make a decision you'll live with for months, write this sentence first:
"I am choosing between ___, ___ and ___, under constraints ___, ___ and ___, and I'll live with it for ___ months."
If you can write it, you're ready to research. If you can't, you're not ready to research — you're ready to brainstorm, which is a different skill and the right one for that moment.
8. Resources and references
Verify before citing. These are from my own knowledge, not fetched and checked in this session. Titles and authors I'm confident about; URLs and current versions you should confirm.
8.1 BMAD itself — start here
| Resource | Why |
|---|---|
github.com/bmad-code-org/BMAD-METHOD | The repo. Read docs/explanation/ before docs/how-to/ — explanation gives you the model, how-to gives you the steps |
docs/explanation/deep-recon.md | The primary source for everything in Part 0 and the main guide |
| PR #2611 | The rationale, the v1 post-mortem, and the security review. Reading a merged PR discussion is one of the fastest ways to understand why a system is shaped the way it is |
docs/how-to/customize-bmad.md | Config layering and merge rules |
The skill's own customize.toml | The authoritative list of knobs, always ahead of any documentation |
8.2 Agentic systems generally
| Resource | Why |
|---|---|
| Anthropic, "Building Effective Agents" (engineering blog, 2024) | The clearest short treatment of when to use workflows vs. agents, and why simple beats clever. Deep Recon's topology choices make more sense after this |
Model Context Protocol docs — modelcontextprotocol.io | The standard behind external_sources |
| Anthropic's Agent Skills documentation | The skill-as-loadable-competence pattern BMAD builds on |
| Simon Willison's blog, prompt-injection tag | The best sustained writing on why untrusted text in agent pipelines is dangerous. Directly relevant to the §4.9 file-vs-shell fix |
8.3 The computer science behind the terms
| Topic | Where to learn it |
|---|---|
| BFS / DFS | Cormen, Leiserson, Rivest & Stein, Introduction to Algorithms (CLRS), graph algorithms chapters. Or any decent visualization — 20 minutes watching BFS and DFS explore a maze is worth more than the chapter |
| Fan-out | Any digital logic text for the electronics origin; distributed-systems literature for the modern sense |
| Append-only logs | Kleppmann, Designing Data-Intensive Applications, ch. 3 — the best explanation of why immutable logs are the backbone of trustworthy systems |
| Single-writer / concurrency | Same book, ch. 7 |
| Cache TTL and staleness | Any HTTP caching primer; the concept transfers directly |
8.4 Epistemology — for §22 of the main guide
All free online:
| Resource | Why |
|---|---|
Stanford Encyclopedia of Philosophy (plato.stanford.edu) — entries on Epistemology, The Analysis of Knowledge, Epistemological Problems of Testimony, Scientific Objectivity | Rigorous, free, written for non-specialists. The single best starting point |
| Edmund Gettier, "Is Justified True Belief Knowledge?" (1963) | Three pages. Genuinely readable in ten minutes, and it reframes what "justification" has to do |
| Karl Popper, The Logic of Scientific Discovery (1934/1959) | Falsification — the intellectual ancestor of the red-team pass |
| Hans Reichenbach, Experience and Prediction (1938) | Where the discovery/justification distinction is articulated — the firewall's ancestor |
| Condorcet's jury theorem (SEP or Wikipedia) | Why independent errors make crowds reliable — and therefore why correlated subagents don't deliver what fan-out promises |
8.5 Research methodology — best practices worth stealing
| Resource | Why |
|---|---|
| PRISMA guidelines for systematic reviews | The gold standard for transparent evidence synthesis: pre-registered questions, documented search strategy, explicit inclusion criteria, reported exclusions. Deep Recon is a lightweight cousin; PRISMA shows you what rigorous looks like |
| GRADE framework | How medicine rates the certainty of a body of evidence, not just individual studies. Directly applicable to reading a research report |
| Literature on publication bias and the replication crisis | Why "surveying published results" systematically overestimates. Justifies the negative-results dimension |
| Ferrari Dacrema, Cremonesi & Jannach, "Are We Really Making Much Progress?" (RecSys 2019) | Specifically for your field: shows that many published recommender improvements fail to beat properly-tuned simple baselines. If you read one paper from this list, read this one |
8.6 Domain references for the applied sections
Retrieval and recommendation
- MTEB (Muennighoff et al.) and BEIR (Thakur et al.) — the benchmark suites, and more importantly their stated limitations
- Malkov & Yashunin, HNSW (2016/2018) — the original paper for the index you're running
- Shilling / profile-injection attack literature in the recsys venues (RecSys, SIGIR)
Security and adversarial ML
- OWASP Top 10 for LLM Applications — the standard reference for prompt injection and agentic risk
- MITRE ATLAS — adversarial threat landscape for AI systems, structured like ATT&CK
- NIST AI Risk Management Framework
- Shokri et al. (2017) on membership inference; Morris et al. on text-embedding inversion
Privacy and regulation — always primary sources, never summaries
- GDPR text on EUR-Lex; EDPB guidelines and opinions
- ICO (UK) — unusually clear practical guidance, useful even outside UK scope
- OPC Canada (
priv.gc.ca) and CAI Québec (cai.gouv.qc.ca) for PIPEDA and Law 25 - The EU AI Act and DSA texts on EUR-Lex
Decision records
- Michael Nygard, "Documenting Architecture Decisions" (2011) — the origin of the ADR format, and the natural home for research output. Short and practical
9. What I'm confident about, and what I'm not
Worth stating plainly, since this document makes a lot of etymological claims.
Well-documented and standard:
- BFS/DFS as graph-traversal algorithms; the maze intuition
- Fan-out from digital electronics
- Firewall from building construction → automotive → networking
- Red team from Cold War military war-gaming
- Slug from newspaper typesetting
- Frontmatter from book publishing
- Provenance from art history and archaeology
- Stage-Gate from Robert G. Cooper's product-development work
- Shim from mechanical engineering
- Digest from Latin digerere and Justinian's Digest
- Reconnaissance from French reconnaître
- TOML named for Tom Preston-Werner
- The philosophical works, authors and dates in §8.4
My inference, reasonable but not documented as BMAD's stated rationale:
- That "run" specifically inherits from ML experiment tracking (MLflow, W&B) rather than the general computing sense. The structural match — run ID, config, artifact directory — is strong enough that I'd bet on it, but the docs don't say so.
- That "dimension" carries the spanning/orthogonality intuition deliberately rather than just meaning "aspect."
- The specific mapping of each mechanism to a named epistemological position in §22 of the main guide. The PR does head a section "Epistemics and reliability," so the intent is explicit; the particular philosophical labels are mine.
Things I have not verified in this session:
- Every URL in §8. I'm confident about titles and authors; check the links.
- Exact TOML key paths — confirm against your installed
customize.toml. - Exact prompt and plan-gate wording, which lives in the skill's reference files.
Where this document and the actual skill disagree, the skill wins. Metaphors are for building intuition, not for settling facts.
Next: bmad-deep-recon-howto.md — every concept here shown as an executable session. Then bmad-deep-recon-guide.md — the full applied guide.
Source: bmad_Research ·
bmad_research.md· updated 2026-07-23 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
BMAD Research Capabilities — The Complete Reference
Scope & recency. BMAD-METHOD V6 (BMM module), current as of July 2026. The V6 line shipped steadily through spring 2026 (v6.3 → v6.8.x by late May). BMAD moves fast; command names and folder layout shift between point releases, so treat exact command strings as version-dependent and confirm against your install with
bmad-help. Where a detail could not be verified against current published docs, it is flagged inline rather than guessed.
Table of Contents
- At a Glance
- Where Research Sits in the Lifecycle
- Choosing the Right Research Type
- The Three Research Workflows in Detail
- The Sub-Agent Architecture
- Research Depth and Scale-Adaptive Intelligence
- End-to-End Walkthrough
- Scenario Library
- Multiple and Parallel Agents (Party Mode)
- Where the Facts Come From (Provenance and Retrieval)
- Cost, Context, and Running Off-IDE
- Brownfield Research and Established Projects
- Worked Example: A User Recommendation System
- Markov Decision Processes and Stochastic Dependency Graphs
- Observations and Findings
- Practical Tips and Best Practices
- Quick Reference
- Glossary
- Appendix A: Copy-Paste Research Prompts
- Appendix B: Custom Party Definitions
- Sources and Recency
1. At a Glance
Research in BMAD is Phase 1 (Analysis) work. It is optional — the required backbone is Planning → Solutioning → Implementation — but it exists to stop you building architecture on unvalidated assumptions. There are three distinct research workflows, all driven by the Analyst agent (persona "Mary"):
| Workflow | Command (short form) | Menu code | Answers |
|---|---|---|---|
| Market Research | bmad-market-research | MR | Who else is in this space? TAM/SAM/SOM, competitors, trends, sentiment |
| Domain Research | bmad-domain-research | DR | What must I know about this domain? Terminology, regulation, SME depth |
| Technical Research | bmad-technical-research | TR | Is it feasible? Architecture options, library/pattern comparison |
Each stands alone — run one, two, or all three. Output is a structured findings document that feeds the PRD, product brief, or architecture workflows.
Command-naming caveat. Current official docs use the short forms above. Some builds and the internal registry use a module-qualified form (
bmad-bmm-market-research, etc.). Both refer to the same workflow underbmad/bmm/workflows/1-analysis/research/. If one form isn't recognized, try the other or askbmad-help.
2. Where Research Sits in the Lifecycle
Phase 1: ANALYSIS (optional) Phase 2: PLANNING (required)
───────────────────────── ───────────────────────────
• brainstorming • PRD (bmad-create-prd)
• RESEARCH ← you are here • UX design (optional)
- market
- domain ─── findings feed ──▶ the PRD workflow ingests
- technical briefs, PRFAQ, research
• product-brief findings, and brainstorm
• prfaq reports as input
The key design idea is that artifacts flow forward. Research findings are not a throwaway chat — they land as documents in your output folder, and the PRD workflow explicitly accepts them as input, synthesizing whatever Phase 1 produced into structured requirements. Running research before planning compounds in value rather than being busywork.
BMM follows a waterfall-with-gates model: each phase produces documents that become context for the next, and mandatory checkpoints (gates) sit before implementation to keep requirements, architecture, and work breakdown aligned.
Default output locations (from config.yaml, set at install time):
output_folder→_bmad-output(base for everything)planning_artifacts→{output_folder}/planning-artifactsproject_knowledge→docs(long-lived research / reference docs live here)
So durable research can be steered into docs/, while cycle-specific research
lands under planning-artifacts/.
3. Choosing the Right Research Type
The three types answer genuinely different questions. Conflating them is the most common mistake — "research my recommender" is really three separate investigations.
What are you actually unsure about?
│
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
"Does anyone want "I don't understand "Can we build it,
this / who else this problem space and how?"
is doing it?" / its rules?"
│ │ │
▼ ▼ ▼
bmad-market-research bmad-domain-research bmad-technical-research
(MR) — external, (DR) — vocabulary, (TR) — feasibility,
competitive, sizing constraints, SME depth architecture, libraries
Rules of thumb:
- Internal feature for a known audience? Skip market research. Run technical, maybe a light domain pass.
- Entering an unfamiliar regulated domain? Domain research first — it changes how you'll judge everything downstream.
- Choosing between concrete technical approaches? Technical research, one focused pass per decision (see §13).
- Brownfield / existing codebase? Your real entry point is often
bmad-document-project, not greenfield research (see §12). - Not sure? Run
bmad-helpand describe the situation; it recommends a starting point based on what you've already produced.
4. The Three Research Workflows in Detail
Market Research (bmad-market-research, MR). Examines competitors, trends,
and user sentiment; produces TAM/SAM/SOM sizing, competitive positioning, and
persona/journey notes. This is the most mature of the three because it fans out to
five specialized sub-agents (§5). Use it to validate that a concept is worth
resources before committing.
Domain Research (bmad-domain-research, DR). Builds subject-matter
expertise: the terminology practitioners use, industry-specific constraints,
regulatory/ethical boundaries, and how "good" is judged in this space. Use it when
entering an unfamiliar domain — getting this wrong produces a technically clean
system that solves the wrong problem.
Technical Research (bmad-technical-research, TR). Evaluates feasibility,
architecture options, and implementation approaches. This is the workhorse for
engineering decisions. Note it is currently single-agent (§5) — its output
quality depends heavily on how sharply you scope the prompt and what constraints
you feed it.
All three are optional Phase-1 workflows run by the Analyst, and all three write findings documents that the PRD workflow can ingest. Run any subset; each stands alone.
Adjacent capability worth knowing —
bmad-investigate. V6.8 added a forensic-investigation workflow in the implementation loop that produces evidence-graded findings for debugging and complex system analysis. It isn't one of the three Phase-1 research workflows, but for "why is production behaving like this" questions it's the research-shaped tool you actually want, and it's more grounding-disciplined than a generic research pass.
5. The Sub-Agent Architecture
The research workflow isn't a single monolithic prompt. Market research dispatches to five specialized sub-agents that can work in parallel:
| Sub-agent | Responsibility |
|---|---|
bmm-market-researcher | Market intelligence, TAM/SAM/SOM sizing |
bmm-data-analyst | Quantitative analysis, business metrics |
bmm-trend-spotter | Trend detection and disruption forecasting |
bmm-competitor-analyzer | Competitive positioning |
bmm-user-researcher | Personas and journey mapping |
The known gap
As of the V6 line, technical research has NO dedicated sub-agents (tracked upstream as a feature request). When you run technical/architecture research, the main Analyst agent executes queries sequentially by itself. Consequences:
- Slower — no parallel fan-out the way market research gets.
- Shallower specialization — no dedicated "architecture evaluator" or "library comparator."
- You carry more of the load — quality depends on how sharply you scope the prompt and how much source material / constraints you supply up front.
The single most useful thing to internalize before relying on
bmad-technical-research for anything load-bearing: it is a well-structured
single-agent research pass, not a multi-agent swarm. Treat it accordingly, and
use Party Mode (§9) when you need genuine multi-perspective technical research.
6. Research Depth and Scale-Adaptive Intelligence
Step 5 of the workflow asks you to set research depth — a knob controlling how much source-gathering and cross-checking happens versus a fast synthesis pass. Deeper settings mean more web search, more sources, longer runs, and higher token cost.
Honest, still-open gap. The exact depth option values (e.g. quick/standard/deep vs. numeric levels) are defined in the workflow's step files, not the public docs, and could not be verified at time of writing. Don't hard-code a value from memory; when the workflow prompts, read the options it offers, or ask
bmad-helpwhat your installed version exposes.
Scale-Adaptive Intelligence is the related, documented system: bmad-help
analyzes project-complexity indicators (team size, architectural complexity,
compliance requirements) and recommends appropriate phase entry points and
planning depth. It's how BMAD scales from a five-minute bug fix (Quick Flow) to
enterprise-platform planning without forcing heavy process on small work. The
three planning tracks — Quick Flow, BMad Method, Enterprise — are the concrete
expression of this and each defines which workflows are required, optional, or
conditional.
7. End-to-End Walkthrough
The workflow follows five interactive steps. Here's a full run for a technical decision.
Step 0 — Fresh chat. Always start research in a new chat. Stale context from an unrelated workflow degrades quality and costs more tokens.
Step 1 — Load the Analyst.
bmad-agent-analyst
(You can also invoke the workflow skill directly and let the tool route to the
Analyst; loading the agent first gives you the conversational menu with the
MR/DR/TR triggers.)
Step 2 — Run the workflow.
bmad-technical-research
Step 3 — Choose research type. Since you invoked the technical variant
directly, this is already TR.
Step 4 — Provide context. This is where the run is won or lost (especially given no technical sub-agents). Give a tight problem statement, the constraints, and what a good answer looks like. See Appendix A for ready-to-use prompt shapes.
Step 5 — Set research depth. Pick depth by stakes. A "which library" spike can be shallow; an architecture bet that shapes service topology warrants the deepest setting.
What comes out. A structured findings document in your output folder —
typically: options considered, comparison across your constraints, tradeoffs, a
recommendation, and (for technical research) a sketch of a validation/PoC path.
Because it's a file, it becomes an input you hand straight to
bmad-create-architecture later.
What happens next automatically. The workflow ends by invoking bmad-help,
which inspects project state and suggests the next step (usually product-brief or
PRD). If you treat BMAD as a personal tool, ignore the suggestion and keep the
findings doc.
8. Scenario Library
Scenario A — Technical research (feasibility spike)
Situation: Deciding whether pre-execution validation of Athena queries should
be a hand-rolled parser, a sqlglot-based validator, or Athena's own dry-run /
EXPLAIN path.
Expected findings: a tradeoff matrix (safety coverage vs. cost vs. language
fit), a recommended defense-in-depth layering, and which failure modes each layer
catches. Observation: with no technical sub-agent, expect to iterate — feed
back the option you're leaning toward and ask it to pressure-test that specifically.
Scenario B — Domain research (unfamiliar space)
Situation: Scoping a recommendation feature inside a legal-tech product; you want the domain landscape before writing a PRD. Expected findings: a subject-matter primer — vocabulary, the constraints that make the domain different (authority, jurisdiction, recency of law), and the evaluation lens you'd otherwise miss. Why domain, not technical: you're building framing, not choosing a library.
Scenario C — Market research (validation before build)
Situation: Deciding whether an internal demo is worth productizing. Expected findings: this is where the five sub-agents earn their keep — a competitor map, sizing, trend forecast, and persona notes assembled in parallel, then synthesized.
Full prompt text for each is in Appendix A.
9. Multiple and Parallel Agents (Party Mode)
There are two distinct forms of "multi-agent" in BMAD, and conflating them is the most common mistake:
- Built-in sub-agent fan-out — automatic, inside a single workflow. Market research already does this (§5). You don't control it directly.
- Party Mode — you convene multiple agent personas in one room to attack a question from different angles. This is the deliberate, steerable tool.
Reality check on "parallel." BMAD agents are registered as Skills, which run synchronously in the main chat thread. Even Party Mode is a simulated round-table, not OS-level concurrency — there's an open request to wrap agents as native IDE subagents for true async execution, but that isn't the default. Read "parallel" as "independent reasoning per persona," not "N agents at the same wall-clock instant." The exception is
agent-teammode (Claude Code only), which stands personas up as a persistent team.
9.1 The four Party Mode modes (who does the thinking)
Invoke with bmad-party-mode; pick a mode with --mode:
| Mode | What it does | Reach for it when | Cost |
|---|---|---|---|
session | Default. One model voices every persona inline. | Banter, brainstorming, quick back-and-forth. | Cheapest |
auto | Voices inline for light rounds; spawns independent agents only when independence changes the answer. | Speed most of the time, independence on hard rounds. | Medium |
subagent | Spawns a separate agent per persona every substantive round. | Honest reviews and focus groups — voices must not bleed together. | High |
agent-team | Personas stand up as a persistent team addressing each other directly. Claude Code only. | A live, hands-off round-table. | Highest |
Fallback chain: agent-team → subagent → session when a harness can't do
the rest. On a non-Claude-Code tool, asking for agent-team quietly degrades.
The core principle: one model voicing five personas will quietly converge —
they share a mind, so they tend to agree. Spawning real agents
(subagent/agent-team) keeps reasoning genuinely separate, which is the entire
point of a review panel or focus group. If you use multi-agent for diverse
input and run it in session, you paid for theater.
9.2 ⚠️ Critical best practice: grounding vs. fabrication
A known V6.3 regression worth internalizing before trusting subagent mode on
anything codebase-grounded: the default subagent spawn prompt ended with an
instruction telling each persona not to use tools — "just respond with your
perspective." On a codebase-grounded question, spawned agents returned zero tool
calls and fabricated a meaningful fraction of their claims (roughly a third
in one reported run on a ~1000-line file), because they reasoned from the
orchestrator's lossy summary instead of reading the source. In the same tests,
--solo/session and tool-enabled variants stayed grounded.
Rules that follow:
- For grounded work (real code, real data), verify the spawned personas can actually read the sources. If the spawn prompt forbids tools, the panel is opining on a summary, not the artifact.
- Party Mode shines for judgment questions, not fact-retrieval. "Which reranking approach fits our constraints?" is perfect. "What does line 340 do?" is exactly where the summarization gap bites.
- When in doubt, feed the source into the room yourself (paste the diff, metrics, findings docs) rather than trusting each subagent to fetch it.
- Confirm on your version — this specific prompt has been iterated upstream, so newer builds may already ground subagents.
9.3 The shipped review panels
BMAD ships two custom parties as working templates (defined but inactive until summoned — they cost nothing sitting in the pool):
Code Review Crew — five lenses that attack a change and argue about what
matters: Vex (security/exploit path), Grumbal (adversary, assumes it's broken),
Boundary (edge cases, nulls, races, timezones), Yui (craft, simplicity, naming),
Dana (pragmatist, ranks real vs. nit). Run with subagent so each reviews before
they clash.
Anti-Consensus Club — for decisions and fuzzy questions where one assistant
agrees too fast or debates past the useful point: Wildcard (alternative framings),
Level (claim/confidence checker), Killjoy (stops repetition and fake
disagreement), Splinter (challenges easy consensus). Run with
--party anti-consensus-club --mode subagent.
9.4 Working around the technical-research sub-agent gap
Because bmad-technical-research is single-agent (§5), Party Mode is the sanctioned
way to get multi-perspective technical research. Two patterns:
Pattern A — Fan out, then convene. Run several separate technical-research
chats, one per candidate approach (fresh chat each), producing one findings doc
apiece. Then open a Party Mode room in subagent mode, feed in all the findings
docs, and let a purpose-built panel reconcile them. Depth (focused single-agent
research per option) and independent cross-examination, without the missing
built-in sub-agents.
Pattern B — Build a custom research panel via bmad-party-mode, create a new party (writes to your overrides through bmad-customize). Ready-to-use
definitions are in Appendix B.
9.5 Steering, memory, and keepsakes
- Steer actively: bring a voice in, tell one persona to "take that apart," switch rooms mid-session, or summon a named custom member.
- 2–3 active voices per round, not all at once — more turns to noise.
- Non-interactive:
--non-interactive "…"runs one pass to a close and releases spawned agents (good for scripted use). - Keepsake: on wrap-up the orchestrator offers a self-contained HTML document laid out by persona — a genuinely useful artifact to attach to a ticket.
- Memory: saved parties keep lightweight memory of past sessions (dynamics,
open threads) unless you turn it off via
bmad-customize. Good for a standing panel; off for one-offs you don't want coloring later runs.
10. Where the Facts Come From (Provenance and Retrieval)
The question a technical reader asks first and most docs skip: when a research workflow states a "fact," where did it come from?
BMAD ships no crawler, search index, or retrieval engine. The research workflows are orchestration — structured prompts, step files, personas, output templates. The actual source-gathering rides on the host coding agent's tool access. In Claude Code that means the model's web search, any MCP connectors you've enabled, and file/repo reads. In a locked-down harness with no web tool, the same workflow falls back to the model's parametric knowledge — cutoff-bound, potentially stale or confabulated.
Consequences:
- Research currency is bounded by your host's tools. Market/domain research is only as fresh as the web access behind it.
- Technical research in a brownfield repo sources mostly from your own code — so the host needs file-read access, and (per §9.2) spawned subagents need tool access or they hallucinate.
- Provenance hygiene is on you. Ask the workflow to cite what it actually
fetched; treat uncited claims as model recall, not verified fact. Matters most
for
bmad-technical-research, where a confidently-wrong claim about a library costs you a bad architecture bet. - Working memory: a
memlogprimitive (_bmad/scripts/memlog.py) carries state across steps within a run, so a multi-step pass doesn't drag the whole context forward.
The honest framing: BMAD makes research structured and repeatable; it does not make it grounded — grounding is a property of the tools you point it at.
11. Cost, Context, and Running Off-IDE
Token cost is real. Independent testing has put a full planning cycle in the rough ballpark of ~$200 in tokens (an order-of-magnitude signal, not an official figure). Two knobs multiply it:
- Depth (§6) — deeper = more sources, more cross-checking, more tokens.
- Party Mode spawning (§9.1) —
subagent/agent-teamspin up a separate agent per persona per round, so an N-persona panel is roughly N× the per-round cost ofsession. Great for independence, expensive by design.
Context management keeps this affordable. BMAD's step-file architecture loads
workflow steps just-in-time rather than holding the whole workflow in context, and
each SKILL.md uses YAML frontmatter to persist runtime state across turns. The
fresh-chat-per-workflow discipline (§7, §9.5) isn't just a quality rule — it's a
cost rule; stale accumulated context is both worse and pricier. memlog carries
the few things worth remembering so a fresh chat isn't a cold start.
Web Bundles (v1.0, May 2026) are the token-conscious escape hatch. BMAD can package planning/research skills — market research, brainstorming, product briefs, PRFAQ, PRD, UX specs — as Google Gemini Gems and ChatGPT Custom GPTs. Run the research-and-planning-heavy front of the process on a flat-rate web subscription instead of metered IDE tokens, then bring artifacts back into the IDE for solutioning and implementation. For token-conscious use this is the single biggest lever: offload the exploratory, high-token research phase to a fixed-cost surface.
Customization. Research workflows are customizable like every BMad skill via
bmad-customize (writing to your overrides). Pin a default depth, set house rules
the workflow holds for the whole run, add persistent facts so you don't re-explain
your stack every time. The clean, version-safe way to shape behavior without
forking the workflow.
12. Brownfield Research and Established Projects
Most scenarios frame research as pre-build validation, but in an existing codebase your real research entry points differ.
bmad-document-project (DP, Analyst) reverse-documents an existing codebase
into reference docs. Critically, its output can substitute for Phase 1–2
artifacts, letting a brownfield project enter at Phase 3 (Solutioning) with
generated context. For a live system, this is often the actual first move — not
greenfield market/domain research.
bmad-generate-project-context (GPC, Analyst) produces project-context.md,
which acts as the "constitution" for your project: technical preferences and
implementation rules every agent must follow. You can hand-write it at the project
root or in _bmad-output/, or auto-generate it by scanning the codebase. For
research specifically, a good project-context.md means technical-research passes
start already knowing your stack, conventions, and constraints — less re-explaining,
sharper findings.
Practical brownfield stance:
- Market/domain research is frequently skippable for internal brownfield work — don't run the full Phase-1 suite out of completeness reflex.
- The useful pair is usually
document-project(understand the system) +technical-research(decide one thing), optionally withbmad-investigate(§4) when the question is "why is it behaving like this." - Feed
project-context.mdinto research runs so findings respect your actual constraints from the first token.
13. Worked Example: A User Recommendation System
This section shows how BMAD research actually works on a real build. The feature: a user recommendation system that suggests content based on a user's view history. It's the ideal case study because it spans all three things research handles — algorithm choice, failure modes, and infrastructure — and because it exposes the hard boundary between what BMAD researches and what your data has to decide.
Framing first (the whole point). A recommender is ~90% technical research, ~10% domain, 0% market (internal feature, not a product to position). And per §10, BMAD will structure and survey the problem — it will not benchmark EMA-vs-two-tower on your click logs. Everything below is decision-support; the empirical answers come from eval, not the research workflow (see §13.7).
13.1 Decompose the feature into research passes
Don't run one giant "research a recommender" prompt — it produces a shallow essay. Split into focused technical-research passes, each a real decision:
| Pass | Decision under research | Workflow |
|---|---|---|
| A — Scoring & representation | How do we model taste and score candidates? (EMA profiles, vectorization, hybrid) | bmad-technical-research |
| B — Failure modes | What goes wrong and how do we defend? (duplicates, cold start, popularity bias, staleness, feedback loops) | bmad-technical-research |
| C — Architecture & deployment | How does this run on AWS within latency/cost/DR constraints? | bmad-technical-research |
| D (light) — Domain | How is "relevant" judged in this content domain? | bmad-domain-research |
Then reconcile the technical passes in a Party Mode panel (§13.5) — the fan-out-then-convene pattern from §9.4, and the way to work around the missing built-in technical sub-agents.
13.2 Pass A — scoring & representation
Real question: how do you turn "what a user viewed" into "what to show next," and keep it fresh without recomputing everything?
Options to compare and the substance you're validating:
- EMA user-preference vector — maintain
u ← α·e_item + (1−α)·uper interaction (or time-decayedweight = exp(−λ·Δt)). α (or a half-life) is the recency knob. Cheap, updates online in O(1), naturally forgets stale interests. Known weakness: interest blending — a user who reads both tax law and IP law collapses into a muddy centroid that recommends neither well. That single fact is usually what pushes toward the next option. - Recent-k / multi-vector — keep the last k interaction embeddings and retrieve by max-similarity (late-interaction style) instead of a single centroid; preserves distinct interests at higher retrieval cost. (Where ColBERT / MUVERA / late-interaction thinking connects.)
- Two-tower learned model — user tower + item tower trained on engagement; best relevance ceiling but adds training infrastructure and a cold-start dependency on engagement data.
- Hybrid — content-based ANN candidate generation (works cold) + collaborative signal in rerank once engagement exists. The usual pragmatic landing spot.
Retrieval mechanics: content/item embeddings in OpenSearch k-NN (HNSW); the user vector (EMA aggregate or multi-vector) drives an ANN query for the candidate set. Dimensionality and quantization (PQ / IVF-PQ) are the recall-vs-cost knobs.
Observation: research lays out this tradeoff space cleanly. It cannot tell you the right α or whether EMA is "good enough" for your users — that's §13.7.
13.3 Pass B — the failure modes (where recommenders actually break)
Duplicates are the headline, but they're one of a family. A good findings doc gives a layered defense and says where each layer sits and what it misses:
| Failure mode | Mitigation | Pipeline stage |
|---|---|---|
| Exact duplicates (same doc resurfaced) | Seen-set exclusion against view history (e.g. your Athena history) | Filter |
| Near-duplicates (same story, diff source; re-embeds) | Cosine-similarity dedup or collapse by an OpenSearch cluster field with top_hits | Filter / candidate-gen |
| No diversity (five near-identical items) | MMR rerank: score = λ·rel − (1−λ)·max_sim_to_selected | Rerank |
| Cross-session repetition (already-seen items) | Impression discounting — decay recently-shown-not-clicked, don't hard-exclude | Rerank / post |
| Popularity bias (head drowns the tail) | Inverse-propensity weighting / popularity normalization | Scoring |
| Cold start (new user / new content) | Content-based ANN fallback + popularity/editorial prior | Candidate-gen |
| Staleness / filter bubble | Exploration — ε-greedy or bandit slot injecting off-profile candidates | Post |
The layering insight research should surface: these are ordered stages (candidate-gen → seen-filter → dedup/cluster-collapse → MMR/diversity → impression-discount), and each layer only catches its own class — MMR won't catch exact dupes, seen-set won't catch near-dupes. That ordering is the finding. (The exploration slot in the last row is a one-step special case of a fuller sequential formulation — see §14 for modeling the recommender as a Markov Decision Process.)
13.4 Pass C — AWS architecture & deployment
Real question: how does all of the above run in production within latency, cost, and DR constraints? What good findings look like:
- Two paths. A batch/offline path (embed the corpus, backfill the vector index, precompute where possible) and an online path (per-request retrieve → filter → rerank). Keeping heavy work off the request path is the p95 story.
- State placement. EMA user vectors in DynamoDB (fast point read on the request, O(1) write on interaction, TTL to forget dormant users); content vectors in OpenSearch k-NN; a recommendation Lambda orchestrates retrieve → seen-filter → dedup → MMR rerank → Content API hydrate.
- Embedding generation. Batch backfill (SageMaker Batch Transform / Bedrock) for the corpus; near-real-time for newly ingested content via a Lambda on the ingest event.
- Latency budget is spent mostly in ANN retrieval + rerank; MMR over a bounded candidate set is cheap, a cross-encoder rerank is where you'd blow the budget (ties to Scenario A / reranking decisions).
- Edge & DR. CloudFront + ALB in front; origin-layer DR determines what happens to the rec call when a region degrades — fail to a popularity/editorial fallback rather than erroring.
- Top cost levers. Vector quantization (PQ) vs. recall, embedding-recompute frequency, and DynamoDB read/write capacity mode.
13.5 Reconcile the passes in a panel
Use §9. Open a Party Mode room in subagent mode and feed in the three findings
docs — because the passes interact (a multi-vector user model changes ANN cost,
which changes the AWS budget, which changes whether a cross-encoder rerank is
affordable):
bmad-party-mode --mode subagent
# panel: relevance-engineer, latency-hawk, ops-SRE, cost-owner, eval-skeptic
# input: pass-A-scoring.md, pass-B-failure-modes.md, pass-C-aws.md
The eval-skeptic persona matters most here — its job is to keep asking "how will we know this is better?", forcing the design toward §13.7. (Heed §9.2: make sure the panel can actually read the three docs.)
13.6 What chains downstream
The three findings docs + the panel keepsake feed:
bmad-create-prd— synthesizes them into requirements (it ingests research findings as input, §2).bmad-create-architecture— Pass C becomes the architecture backbone; Passes A/B become the algorithm and quality sections.- The eval plan — where the real work starts.
13.7 The honest boundary — what the research cannot tell you
This is the "how it actually works here" punchline. BMAD technical research produces a decision-support package: options, tradeoffs, a layered design, cited sources (only if the host has web access — §10), and a proposed evaluation plan.
It does not, and cannot, tell you:
- the right α / half-life for your EMA,
- the right cosine threshold for near-dup collapse or λ for MMR,
- whether EMA beats two-tower on your users,
- your actual p95 under load.
Those are empirical and belong to eval-driven development, not research — adversarial multi-evaluator loops, offline replay against view history, and online A/B. The division of labor:
Research designs the experiment and narrows the option space. Your data runs the experiment and picks the winner.
A recommender is precisely where teams mistake a confident research doc for an answer and skip the eval. The research is real and valuable — it stops you building the wrong thing — but the tuning constants that decide whether the feature is good come from measurement, and the research's job ends at handing you a sound experiment to run.
14. Markov Decision Processes and Stochastic Dependency Graphs
14.1 Does BMAD support this natively? No — and it's worth being precise about why
BMAD's dependency model is a deterministic directed graph with conditional
branches and mandatory gates — the graph TD planning flow (idea → optional
research → brief → PRD → architecture → gates → implementation), soft
preceded-by/followed-by sequencing in module-help.csv, and scale-adaptive
heuristic routing to a track (Quick Flow / BMad Method / Enterprise). There is:
- no transition probability between milestones (a gate is pass/fail by review,
not
P(pass | state, action)), - no reward/value function or policy being optimized,
- no MDP/POMDP solver, no stochastic scheduler, no Bayesian dependency graph.
"Scale-adaptive intelligence" and bmad-investigate's "evidence-graded findings"
sound probabilistic but are heuristic/qualitative grading, not a Markov model. So
if the question is "can I hand BMAD a stochastic milestone graph and have it solve
for an optimal policy" — no, that's outside its design.
That said, MDPs and stochastic dependency graphs are relevant to your work in two distinct, genuinely useful ways. Neither is a built-in feature; one is a research subject BMAD is well-suited to investigate, the other is an overlay you build on top of BMAD's deterministic graph.
14.2 The concept — what a Markov Decision Process actually is
An MDP is the standard mathematical model for sequential decision-making under uncertainty: an agent repeatedly observes a state, picks an action, receives a reward, and lands (probabilistically) in a new state — and it wants to choose actions that maximize reward over the long run, not just right now.
The Markov property. The defining assumption: the future depends only on the
current state, not the whole history — P(s_{t+1} | s_t, a_t, s_{t-1}, …) = P(s_{t+1} | s_t, a_t). In practice you engineer the state to make this true. If
recent history matters (it does for a recommender), fold it into the state — and
note an EMA vector already summarizes history into one carry-forward number, which
is exactly a Markov-friendly representation.
The five pieces ⟨S, A, P, R, γ⟩.
- S — the set of states the world can be in.
- A — the actions available.
- P(s′ | s, a) — the transition model: the probability of the next state given the current state and action. The stochastic heart of the thing.
- R(s, a) (or
R(s,a,s′)) — the reward for taking an action. - γ ∈ [0,1) — the discount factor: a reward t steps away is worth
γ^tof face value. γ near 0 = myopic (only now matters); γ near 1 = far-sighted.
What "solving" an MDP means: finding a policy. A policy π(a | s) maps states to actions. The quantity you care about is the expected discounted return from following π:
V^π(s) = E[ r_0 + γ·r_1 + γ²·r_2 + … | start in s, follow π ]
V^π(s) is the state-value function; Q^π(s,a) is the action-value
function (value of taking a in s, then following π). Solving the MDP = finding
π* that maximizes value in every state.
The Bellman equation is the recursion that makes this tractable — value is immediate reward plus discounted value of where you land:
V*(s) = max_a [ R(s,a) + γ · Σ_{s′} P(s′|s,a) · V*(s′) ]
Q*(s,a) = R(s,a) + γ · Σ_{s′} P(s′|s,a) · max_{a′} Q*(s′,a′)
How you actually solve it — two worlds:
- Model known (you have P and R). Use dynamic programming: value iteration (sweep the Bellman update to convergence) or policy iteration (alternate evaluate-policy / improve-policy). This is the milestone graph of §14.4 — few states, estimable transitions, solvable in a few lines.
- Model unknown (you only get to act and observe). This is reinforcement
learning. Q-learning updates value estimates from sampled transitions:
Policy-gradient methods instead push π directly toward higher return. This is the recommender of §14.3 — you can't write down how a user will respond, so you learn a policy from interaction logs.Q(s,a) ← Q(s,a) + α·[ r + γ·max_{a′} Q(s′,a′) − Q(s,a) ]
Exploration vs. exploitation. With an unknown model, the agent must choose between exploiting the action that looks best now and exploring an uncertain one that might be better and teaches you something. ε-greedy, UCB, and Thompson sampling are the standard strategies — and this tension is exactly the diversity/exploration knob from §13.3, now with a name and a theory.
POMDP — when you can't see the true state. If the agent only gets a noisy observation rather than the state itself, it's a Partially Observable MDP. Almost every real system is one: you never observe a user's true intent, only their clicks. The fix is a belief state (a summary of history — again, the EMA vector is a crude one) used in place of S.
Two intuition anchors before we apply it:
- The bandit is the smallest MDP. A multi-armed bandit is an MDP with one state and γ = 0 — pure "which action pays best right now," no sequential effect. A contextual bandit adds a state (context) but still γ = 0. Set γ > 0 and let actions change the next state and you have a full MDP. That ladder (bandit → contextual bandit → MDP/RL) is the natural adoption path.
- Discounting is a modeling choice, not a detail. γ encodes how much you care about the future — it's where product intent enters the math.
14.3 Applying it #1 — the recommender as an MDP (a research subject, not a BMAD feature)
The strong fit, extending §13 directly. A recommender is a textbook (PO)MDP: the user is the environment, and each recommendation nudges the user into a new state whose response you can't fully predict.
The tuple ⟨S, A, P, R, γ⟩ instantiated:
| Element | Recommender meaning |
|---|---|
| S — state | User context: the EMA/user vector + session features + recently-shown impressions + time-of-day, etc. |
| A — action | Which item or slate to recommend now (slate MDPs are the multi-item case). |
| P(s′|s,a) — transition | The stochastic part: how the user's next state depends on what you showed — click, skip, dwell, or churn. The "stochastic dependency" between interaction states. |
| R(s,a) — reward | Engagement signal: click, dwell time, or a longer-horizon retention/return signal. |
| γ — discount | How much you weight future satisfaction vs. the immediate click. γ > 0 is the whole reason to bother — it discourages clickbait and filter-bubble myopia. |
Myopic vs. sequential — the concrete difference. A greedy ranker (your EMA+MMR design) maximizes immediate reward: show the highest-predicted-click item now. That can quietly degrade the state — hammer someone with the same high-CTR category and they narrow, get bored, and eventually churn. An MDP with γ > 0 will sometimes show a slightly lower-CTR item because it improves the future state — it discovers a new interest, keeps the session alive, protects long-term retention. Same catalog, same infra, different objective: sequence-value instead of click-value. That is the entire reason to reach for an MDP here.
It's really a POMDP, and that's good news for reuse: you never see true intent, but the belief state you can build is the EMA/user vector from §13. So MDP/RL layers on top of §13's representation and serving rather than replacing it — the user vector becomes (part of) S, the ANN candidate set constrains A, and the ranker becomes a learned policy.
The adoption ladder (crawl → walk → run):
- Contextual bandit (γ = 0) over the rerank slot — the smallest useful step, and a principled version of the ε-greedy exploration you already flagged.
- Slate bandit / slate MDP — handle that you recommend a set, where items interact (diversity, position bias); SlateQ-style decomposition tames the combinatorial action space.
- Full RL with γ > 0 optimizing a long-horizon reward (retention, return visits) — highest ceiling, highest cost.
The hard part is evaluation, not the algorithm — off-policy evaluation (OPE). You can't A/B-test every candidate policy on live users. OPE estimates a new policy's value from logged data collected under the current policy, via inverse-propensity scoring (IPS) and doubly-robust estimators. This is the practical gate on RL recommenders and the thing a research pass should design before anyone trains a policy.
Pitfalls research should surface (all real, all expensive to learn late):
- Reward shaping / gaming — optimize raw dwell and you breed clickbait; optimize clicks and you breed rage-bait. The reward is the product's values.
- Credit assignment / delayed reward — retention arrives days later; attributing it to a recommendation three sessions ago is genuinely hard.
- Feedback loops — the policy shapes the data that trains the next policy; unchecked, it collapses diversity (the filter bubble, now self-reinforcing).
- Serving & stability — a learned policy in the request path adds latency, a model-serving dependency, and the risk of a bad policy reaching users.
How BMAD fits: research it with a technical-research pass (prompt A8 in Appendix A) — "MDP/RL vs. heuristic, and if RL, what's the OPE plan and the crawl-walk-run path?" The §13.7 boundary applies with extra force: whether RL beats EMA+MMR on your data is empirical, and the cost of being wrong is higher. Research defines the experiment (state/action/reward design, the OPE methodology, rollout guardrails); your logs decide whether to climb the ladder.
14.4 Applying it #2 — modeling BMAD's own milestones as a stochastic graph (an overlay you build)
The literal reading of the question — a stochastic dependency graph between
milestones — and where the model-known branch of §14.2 applies. BMAD gives you the
graph structure for free (phases, gates, and the preceded-by/followed-by
edges in module-help.csv); you supply the probabilities and solve it outside
BMAD.
Two versions, increasing power:
- Markov chain (no decisions) — model gate outcomes as random transitions to estimate schedule risk; this is essentially stochastic PERT.
- MDP (with decisions) — add actions at each gate — {proceed, iterate/rework, cut scope, run more research} — and solve for the optimal policy: given where the plan is and how risky the next gate looks, what's the cost-minimizing move?
A worked example. Model each gate as a transition over {pass, rework}:
p_pass = 0.7
[Arch gate] ───────────────▶ [Implementation]
│ ▲
p_rework│ │ 0.3 (loop back, pay rework cost c_r)
▼ │
[Re-architect]
- Expected attempts to clear a gate with pass-probability p is geometric: 1/p.
At p = 0.7 that's ≈ 1.43 attempts; at p = 0.5 it's 2.0. Each extra attempt costs
c_r(rework time). - Chain three independent gates at p = 0.7: P(clean run) = 0.7³ ≈ 0.34 — only a third of projects clear all three without rework. That number alone reframes how you plan.
- Expected total rework ≈
Σ_gates (1/p_g − 1)·c_r— a concrete, defensible schedule-risk figure.
The elegant payoff — value of information. Treat "run a technical-research
pass" as an action that changes a transition probability: it raises the
architecture gate's p_pass (fewer late re-architecture loops). Then:
value of the research ≈ (expected rework cost WITHOUT research)
− (expected rework cost WITH research)
research is worth it ⇔ that difference > the research's own cost
If a research pass lifts the architecture gate from p = 0.5 to p = 0.7, expected attempts drop from 2.0 to ≈ 1.43 — saving ≈ 0.57 rework cycles at that gate. If one rework cycle costs more than the research pass, research pays for itself. This turns §13.7's qualitative "research stops you building the wrong thing" into a decision-theoretic ROI — the value-of-information calculation at the heart of MDP planning.
How to solve it: the graph is tiny (a handful of states), so value iteration by hand or ~20 lines of NumPy yields the optimal gate policy; pure schedule risk is a PERT / Monte-Carlo run.
What BMAD does / doesn't do: it emits the milestone graph and the artifacts; it
does not estimate the probabilities or solve anything. You pull the edges (from
module-help.csv or the planning flow), attach p values from your own historical
delivery data (the weak link — garbage in, garbage out), and solve it in a notebook.
A tidy loop: use bmad-technical-research to design the risk model, then own it
yourself. Keep the honest caveat: the map is not the territory — a clean expected-value
number built on three guessed probabilities is worse than no number.
14.5 Honest summary
- Native BMAD support for MDP / stochastic dependency graphs: none. Its model is a deterministic gated DAG with heuristic routing.
- The concept (§14.2) is sequential decision-making under uncertainty — ⟨S,A,P,R,γ⟩, solved for a policy via dynamic programming (model known) or RL (model unknown), with exploration-vs-exploitation and partial observability as the two complications that always show up in practice.
- MDP as a recommender technique (§14.3): fully applicable — the sequential,
long-horizon generalization of the greedy ranker in §13, a natural
bmad-technical-researchsubject, with off-policy evaluation as the real gate. - Stochastic graph over BMAD's own milestones (§14.4): a valid overlay you build on the graph BMAD gives you — useful for quantifying plan risk and computing the value-of-information ROI of research itself, but solved outside BMAD.
15. Observations and Findings
Strengths
- Artifact-first design is the real value. Research isn't a chat you lose — it's a document the PRD/architecture workflows consume. The compounding across phases is the point.
- Market research is genuinely multi-agent and produces broad, structured coverage with little hand-holding.
- Clean separation of the three types maps to actually-different questions and stops you conflating "is it buildable" with "does anyone want it."
- Scale-adaptive: depth adjusts to project complexity, so bug-fix-sized work isn't pushed through enterprise-weight research.
Weaknesses / watch-outs
- Technical research is single-agent — weakest of the three for depth and speed. For high-stakes bets, plan to iterate and supply strong constraints. It amplifies a sharp prompt; it won't out-think a vague one.
- Depth options are opaque across versions — confirm at runtime.
- Command drift between short (
bmad-*) and module-qualified (bmad-bmm-*) forms;bmad-helpis the source of truth. subagentgrounding regression (§9.2) — verify tool access before trusting a panel on codebase-grounded questions.- Fresh-chat discipline is load-bearing — the most common cause of degraded, costlier output.
- Grounding is not guaranteed (§10) — research is only as current as the host's tools.
Complementary modules
- Creative Intelligence Suite (
cis) — facilitated ideation frameworks (SCAMPER, reverse brainstorming, reframing) for before you have a crisp question to research. - BMad Builder (
bmb) — author your own.agent.yamlsub-agents / workflows; the sanctioned path to a personal "architecture-evaluator" sub-agent that closes the technical-research gap. - Test Architect (
tea) — risk-based test strategy and NFR assessment; research-adjacent when the question is "how will we validate this."
16. Practical Tips and Best Practices
- For technical research, over-specify constraints. Latency budgets, runtime fit, "no new platform," existing infra you must reuse. The single agent leans on your framing.
- State the decision, not just the topic. "Which of A/B/C and why" produces a usable doc; "tell me about reranking" produces an essay.
- One decision per pass. Fan out across passes, then convene a panel (§9.4).
- Match Party Mode mode to intent —
sessionfor ideation,subagentfor honest critique/focus groups,agent-teamfor hands-off debate. - Verify subagent grounding before trusting a panel on real code/data (§9.2).
- Route durable research to
docs/, cycle-specific toplanning-artifacts/. - Feed
project-context.mdinto research so findings respect your stack from the first token (§12). - Offload to Web Bundles when tokens matter — run the heavy research phase on a flat-rate GPT, bring artifacts back (§11).
- Take the keepsake HTML and attach it to the ticket; it's a real artifact.
- Remember the boundary — research designs the experiment; your data picks the winner (§13.7).
- Use
bmbto close the sub-agent gap if you do technical research often on the same stack. - Always fresh chat per workflow — quality and cost both depend on it.
17. Quick Reference
Research commands
# Fresh chat, then:
bmad-agent-analyst # load Analyst (Mary); exposes MR / DR / TR
bmad-market-research # MR — competitors, sizing, trends (5 sub-agents)
bmad-domain-research # DR — terminology, regulation, SME depth
bmad-technical-research # TR — feasibility, architecture, libs (single-agent)
bmad-help # source of truth for what's installed / what's next
Research-adjacent
bmad-document-project # DP — reverse-document an existing codebase (brownfield)
bmad-generate-project-context # GPC — write project-context.md ("constitution")
bmad-investigate # forensic, evidence-graded system analysis (v6.8)
bmad-brainstorming # guided ideation (before research)
bmad-party-mode # multi-persona room (--mode session|auto|subagent|agent-team)
bmad-customize <skill> # pin defaults, house rules, persistent facts
BMM agents (persona · key menu codes)
| Agent | Persona | Codes |
|---|---|---|
| Analyst | Mary | BP, CB, MR, DR, TR |
| Product Manager | John | CP, VP, EP, CE, IR, CC |
| Architect | Winston | CA, IR |
| Scrum Master | Bob | SP, CS, ER, CC |
| Developer | Amelia | DS, CR |
| QA Engineer | Quinn | QA |
| UX Designer | Sally | CU |
| Technical Writer | Paige | WD, MG, VD, EC |
| Quick Flow Solo Dev | Barry | QS, QD, CR |
Output → _bmad-output/planning-artifacts/ (cycle) or docs/ (durable).
Feeds → bmad-create-prd, bmad-product-brief, bmad-create-architecture.
18. Glossary
BMAD terms
- BMM — BMad Method module; the flagship four-phase agile-AI module.
- Analyst (Mary) — the agent that runs all three research workflows.
- Skill — a workflow packaged as a
SKILL.md+ sharded step files; runs synchronously in the host IDE. - Step file —
step-XX-*.md, loaded just-in-time to keep context lean. - module-help.csv — catalog mapping skills to names, menu codes, phases, output
locations; powers
bmad-helpnext-step routing. - Party Mode — multi-persona conversation; four modes govern independence.
- Sub-agent — a specialized agent spawned within a workflow (market research) or
a party (
subagentmode). - Scale-Adaptive Intelligence — automatic planning-depth adjustment by project complexity; expressed as Quick Flow / BMad Method / Enterprise tracks.
- project-context.md — the project "constitution": rules all agents follow.
- memlog — working-memory primitive carrying state across steps in a run.
- Web Bundles — planning skills packaged as Gemini Gems / ChatGPT GPTs for flat-rate, off-IDE planning.
- Gate — a mandatory checkpoint before advancing phases.
Recommender terms (used in §13)
- EMA — exponential moving average;
u ← α·e + (1−α)·ufor recency-weighted user profiles. - Half-life / α / λ — the recency-decay knobs (interaction-count or time-based).
- Vectorization / embedding — mapping content/users to vectors for similarity search.
- ANN / HNSW / IVF-PQ / PQ — approximate nearest-neighbor search and its index/quantization variants (recall-vs-cost).
- Two-tower — separate learned encoders for users and items.
- MMR — Maximal Marginal Relevance; diversity rerank trading relevance against redundancy.
- Impression discounting — down-weighting recently-shown-not-clicked items.
- Inverse-propensity weighting — correcting popularity bias.
- Cold start — no history for a new user/item.
- Eval-driven development — deciding tuning constants by measurement (offline replay, adversarial evaluators, online A/B), not by research.
MDP and stochastic-planning terms (used in §14)
- MDP — Markov Decision Process; ⟨states, actions, transition probabilities, reward, discount γ⟩ for sequential decisions under uncertainty.
- Markov property — the future depends only on the current state, not the full history; you engineer the state to make this hold.
- Policy (π) — the state → action mapping an MDP solution produces (which slate to show in a given context).
- Value function (V, Q) — expected discounted return from a state (V) or a state-action pair (Q) under a policy.
- Bellman equation — the recursion
V*(s) = max_a[R + γ·Σ P·V*]that defines optimal value and underlies every solution method. - Value iteration / policy iteration — dynamic-programming solvers used when the model (P, R) is known.
- Reinforcement learning (Q-learning, policy gradient) — learning a policy from experience when the model is unknown.
- Exploration vs. exploitation — the trade between the best-known action and an uncertain one that might be better (ε-greedy, UCB, Thompson sampling).
- POMDP / belief state — partially observable MDP; the true state is inferred from observations via a history summary (a recommender rarely sees real intent, so it's really a POMDP; the EMA vector is a crude belief state).
- Contextual bandit — a one-step MDP (γ = 0); the frame behind the exploration / ε-greedy slot in §13.3.
- Discount factor (γ) — weights future vs. immediate reward; γ > 0 is what makes a recommender optimize long-term engagement over next-click.
- Off-policy evaluation (OPE) — estimating a new policy's value from logged data without deploying it live (IPS, doubly-robust); the practical gate on RL recommenders.
- SlateQ — a slate-decomposition value-learning approach for recommending sets of items under an RL/MDP formulation.
- Value of information — the expected cost/benefit of acquiring information (e.g. running research) before deciding; the ROI frame in §14.4.
- Stochastic dependency graph / PERT — a milestone graph whose edges carry probabilities (gate pass/rework), used to reason about plan time/cost risk (§14.4).
19. Appendix A: Copy-Paste Research Prompts
Ready-to-run prompt bodies. Start a fresh chat, load the Analyst, invoke the workflow, then paste. Replace bracketed parts.
A1 — Technical research (generic template)
bmad-technical-research
Decision: [the one thing to decide].
Options to compare:
1. [option] — [one line]
2. [option] — [one line]
3. [option] — [one line]
Constraints: [latency budget], [runtime/language fit], [existing infra to reuse],
[no-go's].
Output: comparison across [criteria], a recommended default, and a minimal
validation/PoC path.
Depth: [read the options the workflow offers].
A2 — Recommender Pass A (scoring & representation)
bmad-technical-research
Decision: how to represent a user's taste from view history and score candidates.
Options: (1) EMA user vector u←α·e+(1-α)·u + HNSW ANN; (2) recent-k multi-vector /
late-interaction; (3) two-tower learned; (4) hybrid (content ANN + collaborative
rerank). Constraints: O(1)-ish online update (no nightly full recompute),
Java/Spring service, OpenSearch vector store in place, tight p95.
Output: compare on freshness, multi-interest handling, cold-start, infra cost,
online-update cost; recommend a default + migration path.
A3 — Recommender Pass B (failure modes)
bmad-technical-research
Decision: mitigation strategy for recommender failure modes, view-history based.
Cover with a concrete mitigation + pipeline stage: exact duplicates, near-duplicates,
no-diversity, cross-session repetition, popularity bias, cold start, staleness.
Constraints: OpenSearch retrieval, view history queryable (Athena), content has a
cluster/grouping field. Output: a layered defense table, the order to apply layers,
and what each layer misses.
A4 — Recommender Pass C (AWS architecture)
bmad-technical-research
Decision: AWS reference architecture, batch + online paths. Cover: embedding
generation (batch backfill vs near-real-time on ingest), OpenSearch k-NN index +
quantization tradeoff, EMA user-state store (DynamoDB + TTL), serving path (rec
Lambda → ANN retrieve → seen-filter → dedup → MMR rerank → Content API hydrate),
CloudFront+ALB with origin-layer DR, warm/cold path, caching, cost drivers.
Constraints: tight p95, no GPU in request path, existing HNSW index.
Output: component/data-flow description, DR posture, top 3 cost levers.
A5 — Domain research (unfamiliar space)
bmad-domain-research
Domain: [the space]. I need: the terminology practitioners use, how "relevant"/
"good" is judged here vs. elsewhere, regulatory/ethical constraints, and the
evaluation lens I'd otherwise miss.
A6 — Market research (validation)
bmad-market-research
Space: [the category]. Who are the incumbents and adjacent players, the current
trend line, and where are the gaps a fast-follower could exploit?
A7 — Reconcile passes in a panel
bmad-party-mode --mode subagent
Convene: relevance-engineer, latency-hawk, ops-SRE, cost-owner, eval-skeptic.
Inputs (read these files): pass-A-scoring.md, pass-B-failure-modes.md, pass-C-aws.md.
**A8 — Recommender as MDP / RL (should we?)**
bmad-technical-research Decision: should the ranking stage be formulated as an MDP/RL problem (optimizing long-term engagement) or stay a greedy EMA+MMR heuristic (optimizing the next slate)? Frame: S = user vector + session + recent impressions; A = slate; P = stochastic user response; R = engagement/retention; γ = long-term weight. Compare: contextual bandit (one-step), slate-decomposition value learning (SlateQ-style), and the heuristic baseline. Constraints: no live experimentation on every policy (need off-policy evaluation), tight p95, existing OpenSearch/DynamoDB serving. Output: when RL is worth its cost, the off-policy eval plan, serving/complexity implications, and a recommended crawl→walk→run path from the heuristic baseline.
---
## 20. Appendix B: Custom Party Definitions
Author these with `bmad-party-mode, create a new party` (it writes to your overrides
via `bmad-customize`). The exact override-file format is version-specific and the
skill writes it for you — provide the personas and scene below when prompted.
Personas need *teeth*: specific values, first-move behavior, and a blind spot.
**B1 — Reranking / Scoring Decision Panel** (run `--mode subagent`)
- **Rel (Relevance Engineer).** Fights for measured relevance lift (nDCG, MRR,
recall@k). Opens by asking for the offline metric and the candidate set size.
Blind spot: discounts operational cost.
- **Pax (Latency Hawk).** Rejects anything that risks the p95 budget or puts a GPU
in the request path. First question is always "what does this add to tail
latency?" Blind spot: would ship a fast but mediocre ranker.
- **Sol (Ops/SRE).** Guards index rebuild cost, operational surface, and failure
modes under region degradation. Blind spot: conservative to a fault.
- **Cass (Cost Owner).** Ranks options by cost-per-query and recompute frequency;
challenges anything without a cost line. Blind spot: penny-wise on relevance.
- **Vera (Eval Skeptic).** Won't accept any recommendation without a concrete
experiment that could disprove it. Says "how will we *know*?" in the first thirty
seconds. Blind spot: can stall a decision that's good enough.
*Scene:* a design review for the recommender's ranking stage; the team must pick one
approach and a validation plan by end of session, and the eval skeptic has veto over
"just ship it."
**B2 — View-History Focus Group (from data)** (run `--mode subagent`)
Hand the room **anonymized user view-history clusters**; it builds representative
personas that react to a proposed recommendation strategy *from their own
behavior*, instead of one model rubber-stamping. Example seeded members:
- **The Specialist.** Reads deep in one narrow area; hates diversity injection that
pulls them off-topic. Reacts badly to exploration slots.
- **The Generalist.** Grazes across many areas; punished by a single-centroid EMA
that averages their interests into mush.
- **The Returner.** Comes back weekly; furious when shown items they already read
(tests your seen-set/impression-discounting).
- **The New Arrival.** Little history; the cold-start canary.
*Scene:* each persona is shown a sample recommendation slate produced by the
candidate strategy and reacts from their own goals; the room surfaces which user
type each design choice helps or hurts.
> **Grounding note (§9.2):** for B2 to be worth anything, the spawned personas must
> actually consume the cluster data you provide — verify tool/file access, or paste
> the cluster summaries directly into the room.
---
## 21. Sources and Recency
Synthesized from the current BMAD-METHOD docs and repository
(docs.bmad-method.org, the `bmad-code-org/BMAD-METHOD` repo, its releases/issues,
and the DeepWiki module reference), plus third-party testing writeups, reflecting
the V6 / BMM state through mid-2026.
Version-sensitive or unverified items, flagged inline where they appear:
- **Research depth option values** — defined in workflow step files, not public
docs; not verified. Read the options at runtime or ask `bmad-help`.
- **Short vs. `bmm`-qualified command form** — varies by build; `bmad-help` is
authoritative.
- **The `subagent` grounding regression (§9.2)** and the open request for true
native-subagent parallelism — both tracked upstream and actively changing;
re-verify grounding on your build before trusting `subagent` mode for factual,
code-grounded work.
- **The ~$200 full-cycle token figure (§11)** — one independent measurement, not an
official number; treat as order-of-magnitude.
- **Custom party override-file format (Appendix B)** — the skill writes it; the
exact schema is version-specific.
The recommender engineering content in §13 (EMA, MMR, ANN/quantization, the AWS
shape) is stable engineering knowledge, independent of BMAD version.
*Confirm anything load-bearing against your installed version with `bmad-help`.*
Source: Similarity-Calculations ·
Similarity-Calculations.md· updated 2026-07-23 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Is binary_clip Using Cosine Similarity? A Ground-Up Explanation
This document answers one question about the mew2026 OpenSearch index:
The
binary_clipfield is configured withspace_type: innerproduct. Is that effectively cosine similarity, or plain dot product? And does using faiss with 16x compression mean the vectors are already normalized?
Short answer, proven with your own data in Section 8: the vectors are not normalized (their lengths are ~9–11, not 1), so this field computes raw dot product, which is not cosine similarity. faiss and the compression level have nothing to do with normalization.
This version assumes no prior background. Every term is defined, the origin of the name is explained (why "L2", where "Euclidean" comes from, what "inner product" means), and every concept has a worked numeric example. If you already know the basics, skip Section 0 and start at Section 1.
Table of Contents
- Background: the words and ideas you'll need
- The building block: what a vector is
- What a "norm" is, and where "L2" comes from
- Three ways to measure similarity (with a hand-worked example)
- The key insight: normalization makes all three agree
- Why
innerproductspecifically requires you to normalize - Why faiss and 16x compression do NOT mean "normalized"
- How to verify it yourself (the exact steps)
- The proof, using your five sampled vectors
- Conclusion
- How to fix it
- Glossary
- References
0. Background: the words and ideas you'll need
Before any math, here is the cast of characters. Each is expanded later; this is the map.
Embedding. A machine-learning model reads something complex (an image, a sentence) and outputs a fixed-length list of numbers that captures its "meaning" in a form computers can compare. That list is called an embedding or embedding vector. Similar inputs get similar lists. Your binary_clip field stores one 512-number embedding per news photo.
CLIP. The model that produced these embeddings. CLIP stands for Contrastive Language–Image Pre-training (OpenAI, 2021). It was trained on image–caption pairs so that a picture and its description land at nearby points. A useful consequence: you can search images by text, because both live in the same 512-dimensional space.
Vector / dimension. "Vector" is just the math word for an ordered list of numbers. Each number is one dimension or component. We live in 3 dimensions (left-right, up-down, forward-back), but math places no limit — 512 dimensions simply means "a list of 512 numbers." You can't picture 512-D, but every formula that works in 2-D works identically in 512-D; the examples below use 2-D so you can check them by hand.
Norm. A norm is any rule that measures the "size" or "length" of a vector, written with double bars: ‖v‖. There are several norms; the one everyone means by default is the L2 / Euclidean norm (Section 2).
Similarity metric. The rule for scoring how alike two vectors are. In OpenSearch this is the field's space_type. The three that matter — dot product, cosine, Euclidean — are Section 3.
Normalization. Rescaling a vector so its length becomes exactly 1, keeping its direction. This is the single operation the entire question turns on (Section 4).
faiss. The software library OpenSearch uses under the hood to store vectors and find nearest neighbors quickly. The name stands for Facebook AI Similarity Search. It is a search engine for vectors, not a data-cleaning tool — it does not modify your numbers (Section 6).
HNSW. The specific index structure faiss builds: Hierarchical Navigable Small World graph. It's a shortcut that avoids comparing your query against every stored vector. It affects speed, not the numbers (Section 6).
Quantization / "16x compression". Storing each number in fewer bits to save memory, by rounding it to a coarse set of allowed values. "16x" means 16× smaller than the original. It approximates your numbers; it does not rescale them (Section 6).
With that map in hand, we start from the smallest piece.
1. The building block: what a vector is
CLIP turns an image into an embedding vector: a list of 512 numbers. Picture each vector as an arrow from the origin (the zero point) into space. Two properties describe any arrow:
- Direction — which way it points. For CLIP, direction carries the meaning: similar images point similar ways.
- Magnitude / length — how long the arrow is, written
‖v‖. For CLIP this is mostly a model artifact and carries little meaning.
What your actual data looks like (first 8 of the 512 numbers from document MT1IMGOST00034M2TL):
[-0.0992932, 0.3014491, -0.123002, -0.3986786, 0.4534476, -0.470669, 0.1424519, 0.0870281, ...]
Most components are small (±0.1–0.5). But a few, further along, are large (one is −3.33). That mixture is the tell we'll return to.
Why "dimension" here isn't the everyday meaning. In daily life "dimension" means a physical axis. In linear algebra it just means "one slot in the list." 512 dimensions = 512 slots. Nothing is spatial about it; it's bookkeeping. The reason we still use spatial words ("length", "direction", "angle") is that the formulas are the same ones you learned for 2-D and 3-D — they extend to any number of slots unchanged.
2. What a "norm" is, and where "L2" comes from
A norm answers "how big is this vector?" It's a function that takes a vector and returns a single non-negative number: its size. There is more than one sensible way to measure size, so there is a family of norms.
The Lᵖ family (this is where the letters and numbers come from)
The general formula, with a chosen exponent p:
‖v‖ₚ = ( |v₁|ᵖ + |v₂|ᵖ + ... + |vₙ|ᵖ ) ^ (1/p)
Different values of p give different norms:
- p = 1 → the L1 norm (a.k.a. Manhattan or taxicab distance): just add up the absolute values. It's called "taxicab" because it's how far a taxi drives on a grid of streets — you can't cut diagonally through buildings.
- p = 2 → the L2 norm (a.k.a. Euclidean norm): square each, sum, take the square root. This is ordinary straight-line length.
- p = ∞ → the L∞ norm: just the single largest absolute component.
So "L2" literally means "the Lᵖ norm with p = 2." The 2 is the exponent in the formula. And the "L" honors Henri Lebesgue, the French mathematician after whom the Lᵖ spaces of functions are named; the vector norms inherit the letter. (Pedantic note: for finite lists like ours the strictly correct symbol is a lowercase ℓ — "little-ell-2" or ℓ² — while capital L is for continuous functions, but in engineering "L2" is used for both and everyone understands it.)
Why L2 is also called "Euclidean"
Euclid of Alexandria (~300 BCE) wrote the Elements, the foundation of geometry. Straight-line distance in flat space is "Euclidean" in his honor. The L2 formula is nothing more than the Pythagorean theorem (a² + b² = c²) extended to many dimensions:
length of arrow = sqrt( sum of squares of its components )
That's why squaring (exponent 2) and then square-rooting appears: it's Pythagoras. The squaring is what makes it "straight-line" distance rather than grid distance.
One worked example, all three norms
Take v = [3, 4]:
L1 (p=1): |3| + |4| = 7 (add absolute values)
L2 (p=2): sqrt(3² + 4²) = sqrt(25) = 5 (Pythagoras — the default "length")
L∞ (max): max(|3|, |4|) = 4 (largest component)
import numpy as np
v = np.array([3.0, 4.0])
np.sum(np.abs(v)) # L1 -> 7.0
np.linalg.norm(v) # L2 -> 5.0 (np.linalg.norm defaults to p=2)
np.max(np.abs(v)) # Linf-> 4.0
The same three on the real 8-number slice above: L1 = 2.076, L2 = 0.8543, L∞ = 0.4707 — different measurements of the same vector.
For the rest of this document, "norm" means L2 (the default everywhere in vector search), and the L2 norm is the single most important calculation here: the test in Section 8 is literally "compute the L2 norm of your real vectors and check whether it equals 1."
3. Three ways to measure similarity (with a hand-worked example)
A similarity metric (OpenSearch's space_type) turns two vectors A and B into one number saying how alike they are. We compute all three on the same pair so the differences are visible.
Example pair: A = [3, 4], B = [4, 0]. (‖A‖ = 5, ‖B‖ = 4.)
3a. Dot product (a.k.a. inner product)
Multiply matching components, sum them:
dot(A, B) = A₁·B₁ + A₂·B₂ = 3·4 + 4·0 = 12
Why two names — "dot product" and "inner product"? "Dot product" comes from the notation
A · B(a literal dot). "Inner product" is the more general, abstract term from linear algebra for this whole category of operation; the dot product is the standard inner product on ordinary (Euclidean) space. OpenSearch'sinnerproductis exactly this:A · B. The two names mean the same thing here.
The crucial identity — dot product blends direction and length together:
dot(A, B) = ‖A‖ · ‖B‖ · cos(θ) (θ = the angle between the two arrows)
So a longer vector inflates all its dot products. This is the metric your field uses.
3b. Cosine similarity
Divide the dot product by both lengths — this cancels magnitude and leaves only the angle:
dot(A, B) 12
cos(A, B) = ───────────── = ─────── = 0.6
‖A‖ · ‖B‖ 5 · 4
Trig refresher — what "cosine" and "angle" mean here. Cosine is the trigonometric function that, for an angle θ, ranges from +1 at 0° (arrows point the same way), through 0 at 90° (arrows perpendicular — called orthogonal, meaning "unrelated"), to −1 at 180° (arrows point opposite ways). "Angle between two 512-D arrows" sounds exotic, but any two arrows — no matter how many dimensions — always lie in a single flat plane together, and the angle in that plane is an ordinary 0°–180° angle. Cosine similarity is just the cosine of that angle. That's why it's the natural measure of direction alone: it ignores how long the arrows are and reports only how aligned they are.
3c. Euclidean distance (L2 distance)
The straight-line distance between the two arrow tips (smaller = more similar). It reuses the Pythagorean/L2 idea from Section 2, applied to the difference A − B:
d(A, B) = ‖A − B‖ = sqrt((3−4)² + (4−0)²) = sqrt(1 + 16) = sqrt(17) ≈ 4.123
Your field is not using this (space_type is innerproduct, not l2); it's shown for contrast, and to make clear the Euclidean-vs-cosine question is already closed by the config string alone.
All three in code:
import numpy as np
A = np.array([3.0, 4.0]); B = np.array([4.0, 0.0])
A @ B # dot -> 12.0 ('@' is dot product in numpy)
A @ B / (np.linalg.norm(A) * np.linalg.norm(B)) # cosine -> 0.6
np.linalg.norm(A - B) # euclidean -> 4.1231
Why length "leaking in" actually matters
Make A twice as long (same direction): A2 = [6, 8].
dot(A2, B) = 6·4 + 8·0 = 24 # DOUBLED — purely because A got longer
cos(A2, B) = 24 / (10 · 4) = 0.6 # UNCHANGED — direction didn't change
Under dot product, a document can outrank another just for being "longer," even with identical direction. Under cosine it can't. Hold onto this — it is exactly what's happening in your index.
4. The key insight: normalization makes all three agree
Normalizing rescales a vector to length 1 while preserving direction:
v_normalized = v / ‖v‖
Divide every component by the vector's own L2 length. The result is a unit vector (length 1). Geometrically, all unit vectors sit on the surface of a sphere of radius 1 centered at the origin — the unit sphere — so normalizing is "slide the arrow tip in/out until it touches the unit sphere, without turning it."
Worked example. Normalize A = [3, 4] (‖A‖ = 5):
A_normalized = [3/5, 4/5] = [0.6, 0.8]
check: ‖[0.6, 0.8]‖ = sqrt(0.36 + 0.64) = sqrt(1.0) = 1 ✓
A = np.array([3.0, 4.0])
An = A / np.linalg.norm(A) # -> [0.6, 0.8]
np.linalg.norm(An) # -> 1.0
Now the payoff. Once ‖A‖ = 1 and ‖B‖ = 1:
(i) Dot product becomes cosine — the cosine denominator is 1·1 = 1:
cos(A, B) = dot(A, B) / (‖A‖·‖B‖) = dot(A, B) / 1 = dot(A, B)
Verify: B_normalized = [4/4, 0/4] = [1, 0], so dot(An, Bn) = 0.6·1 + 0.8·0 = 0.6, which equals the cosine we computed earlier. ✓
(ii) Euclidean ranking becomes cosine ranking, via this identity for unit vectors:
d(A,B)² = ‖A‖² + ‖B‖² − 2·dot(A,B) = 1 + 1 − 2·cos(A,B) = 2 − 2·cos(A,B)
Verify: d(An,Bn)² = (0.6−1)² + (0.8−0)² = 0.16 + 0.64 = 0.8, and 2 − 2(0.6) = 0.8. ✓ A bigger cosine always means a smaller distance — identical neighbor order.
The consequence: if your vectors are normalized, it doesn't matter whether the engine runs dot product, cosine, or Euclidean — they all rank identically. That's why teams normalize once at write time, then pick the cheapest metric (dot product) and get cosine behavior for free.
The flip side (your situation): if the vectors are NOT normalized, dot product ≠ cosine, and length distorts the ranking exactly as the doubling demo showed.
5. Why innerproduct specifically requires you to normalize
OpenSearch treats cosinesimil, innerproduct, and l2 as separate space_type values with different internal formulas:
space_type | What the engine computes | Divides by ‖A‖·‖B‖? | _score reported |
|---|---|---|---|
cosinesimil | cosine of the angle | Yes — built in | 1 + cos(A,B) (range 0–2) |
innerproduct | raw dot(A,B) | No | dot+1 if dot≥0, else 1/(1−dot) |
l2 | squared Euclidean distance | No (distance-based) | 1 / (1 + distance²) |
The decisive row: cosinesimil bakes the length-division into its own formula. Had your field used cosinesimil, OpenSearch would divide out the magnitudes on every query, giving true cosine regardless of your stored vectors' norms.
But your field is innerproduct, which has no division. It feeds the stored numbers straight into dot(). So innerproduct equals cosine only if you supplied already-normalized vectors. Choosing innerproduct delegates normalization to your ingest pipeline — if ingest doesn't do it, nobody does.
What "score transform" means. A raw dot product can be negative (opposite-pointing vectors). OpenSearch prefers scores to be positive and increasing-with-similarity, so for
innerproductit reportsdot + 1when the dot is ≥ 0 and1/(1 − dot)when negative. This is monotonic — it never changes the ranking order — but it does mean the_scoreyou see is not a cosine value in[−1, 1]. Don't interpret it as one.
Concrete score example, using two of your real vectors (v1 = MT1IMGOST00034M2TL, v2 = MT1NURPHO000YDLE5G; norms 9.17 and 9.75):
raw dot(v1, v2) = 22.2227
cosine(v1, v2) = 0.2486 # the "true" semantic similarity (direction only)
innerproduct _score (dot + 1) = 23.2227 # what your index returns today
cosinesimil _score (1 + cos) = 1.2486 # what you'd get if normalized / using cosinesimil
ratio raw / cosine = 89.40 == ‖v1‖·‖v2‖ = 9.1736 × 9.7454 = 89.40
That 89× ratio is the length inflation ‖v1‖·‖v2‖ from the dot = ‖A‖·‖B‖·cos(θ) identity — pure magnitude injected into the score. After normalizing both vectors, dot(v1n, v2n) = 0.2486, exactly the cosine.
Sample query (the query is identical no matter which metric the field uses — the metric was fixed at index-build time):
GET /mew2026/_search
{
"size": 5,
"query": {
"knn": {
"binary_clip": {
"vector": [ /* 512-dim query embedding */ ],
"k": 5
}
}
}
}
Sample response shape (note the large _score, consistent with un-normalized innerproduct, not a 0–2 cosine score):
{
"hits": {
"hits": [
{ "_id": "tag:mewmew.com,2026:newsml_...", "_score": 23.2227, "_source": { } },
{ "_id": "tag:mewmew.com,2026:newsml_...", "_score": 19.8410, "_source": { } }
]
}
}
6. Why faiss and 16x compression do NOT mean "normalized"
This is the specific misconception, so each config element is dissected on its own.
"method": {
"engine": "faiss",
"space_type": "innerproduct",
"name": "hnsw",
"parameters": {}
},
"mode": "on_disk",
"compression_level": "16x"
engine: faiss — a search library, not a normalizer
faiss (Facebook AI Similarity Search) builds the search structure and computes distances. It computes exactly the metric space_type names (dot() here) on exactly the numbers it's given. faiss performs no automatic normalization — in its own API you must call faiss.normalize_L2(x) on your data yourself if you want cosine, and OpenSearch does not call that for you on an innerproduct field. So faiss tells you how search runs, not whether the data was normalized.
# faiss "cosine" is literally: normalize FIRST, then use inner product.
import faiss, numpy as np
x = np.random.rand(1000, 512).astype('float32')
faiss.normalize_L2(x) # <-- YOU do this explicitly; nothing is implicit
index = faiss.IndexFlatIP(512) # IP = Inner Product
index.add(x) # only now does IP behave like cosine
name: hnsw — the index shape, unrelated to magnitude
HNSW (Hierarchical Navigable Small World) is a graph that lets a query reach its nearest neighbors in a few hops instead of comparing against all 10,000+ vectors. ("Small world" is the same idea as "six degrees of separation" — most points are only a few hops apart.) It changes which candidates are compared and how fast — it never rescales a vector.
compression_level: 16x — lossy storage, not rescaling
First, two words of background:
What a "bit" and a "float" are. A bit is a single 0/1 digit;
nbits can represent2ⁿdistinct values (2 bits → 4 values, 8 bits → 256). Your original numbers are float32 — 32-bit floating-point numbers (the standard IEEE 754 format), giving very fine precision. "Compression" here means storing each number in fewer bits by rounding it to a small menu of allowed values. That rounding is called quantization.
"16x" means 16× smaller than float32, i.e. 32 ÷ 16 = 2 bits per number — so each of the 512 numbers is rounded to one of just 2² = 4 allowed levels. Quantization approximates the numbers you already have; it does not rescale the vector to length 1. A rounded copy of a length-9 vector is still ~length 9.
Compression-level → bits, per the OpenSearch docs:
| compression | bits/dim | levels (2ⁿ) | note |
|---|---|---|---|
| 32x | 1 | 2 | true binary quantization |
| 16x | 2 | 4 | your field |
| 8x | 4 | 16 | |
| 4x | 8 (byte) | 256 | |
| 2x | 16 (fp16) | 65 536 |
Why un-normalized data makes compression worse (toy 2-bit example). With only 4 levels spread across the data's range, if one outlier sits at 3.33 the levels must stretch to cover it, and the ~500 small values collapse together:
value +0.030 -> nearest of 4 levels = +0.1667 (error 0.137) # informative values get crushed
value +0.210 -> nearest of 4 levels = +0.1667 (error 0.043)
value -0.440 -> nearest of 4 levels = -0.5000 (error 0.060)
value +3.330 -> clipped to +0.5000 (error huge) # the outlier wrecks the scale
Normalizing first shrinks those outliers and lets the 4 levels resolve the informative dimensions — so normalization would improve compression, not replace it.
Aside (
on_diskrandom rotation).on_diskapplies a random rotation before quantizing, to spread information more evenly across dimensions. A rotation preserves length (it spins the arrow without stretching it), so it also does not normalize.
mode: on_disk — rescoring uses the same un-normalized vectors
on_disk runs two phases: a fast approximate pass over the compressed vectors, then a rescore using the full-precision vectors from disk (default oversample_factor 2.0). Those full-precision vectors are the same un-normalized numbers, so rescoring makes the score more faithful to the raw dot product — moving you further from cosine, not toward it.
Bottom line: none of faiss, hnsw, 16x, or on_disk inspects or changes vector length. The only thing that makes innerproduct behave as cosine is an explicit unit-normalize in your ingest code before indexing. Whether that ran is a question about the numbers in _source — which we check next.
7. How to verify it yourself (the exact steps)
Step 1 — confirm the metric (rules out Euclidean):
GET /mew2026/_mapping/field/binary_clip
Read ...binary_clip.method.space_type. Yours = innerproduct → not l2, so Euclidean is out; only the normalization question remains.
Step 2 — pull a few stored vectors:
GET /mew2026/_search
{
"size": 5,
"_source": ["binary_clip"],
"query": { "exists": { "field": "binary_clip" } }
}
Step 3 — compute each vector's L2 norm. Normalized ⇒ every norm ≈ 1.0000. Not normalized ⇒ the norms are something else and vary:
import numpy as np
for h in resp["hits"]["hits"]:
v = np.asarray(h["_source"]["binary_clip"], dtype=np.float64)
print(h["_id"][-18:], "norm =", round(float(np.linalg.norm(v)), 4))
Step 4 (optional, conclusive) — scale-invariance check. Query with a stored vector, then again with a ×3 copy of it. True cosine is scale-invariant (same order); raw dot product changes the scores/order. This is the live-query analog of the doubling demo in Section 3.
Step 5 (fallback if binary_clip is excluded from _source) — inspect the ingest code for a normalize step: x / np.linalg.norm(x), sklearn.preprocessing.normalize, or torch.nn.functional.normalize(emb, dim=-1). Hugging Face CLIPModel returns un-normalized image_embeds; the standard pattern is emb = emb / emb.norm(p=2, dim=-1, keepdim=True) right after the forward pass. Present ⇒ cosine-equivalent; absent ⇒ raw dot product.
8. The proof, using your five sampled vectors
You returned five documents from mew2026 with full binary_clip arrays in _source. Applying Step 3:
Results (exact, computed from your data)
Document (_id tail) | Dim | L2 norm | Largest |component| | RMS per component |
|---|---|---|---|---|
MT1IMGOST00034M2TL | 512 | 9.1736 | 3.3277 | 0.4054 |
MT1NURPHO000YDLE5G | 512 | 9.7454 | 5.3205 | 0.4307 |
MT1NURPHO0003GDRTT | 512 | 10.7579 | 6.1741 | 0.4754 |
RC2VZKAGA2RW | 512 | 10.4605 | 5.0318 | 0.4623 |
MT1SIPA000UKMLE2 | 512 | 9.6490 | 3.6192 | 0.4264 |
Summary: mean norm 9.96, range 9.17 → 10.76 (spread ~1.58 across just five docs).
What "RMS per component" is and why it's here. RMS = root-mean-square =
sqrt(average of the squares)=‖v‖ / sqrt(512). It's the "typical size" of a single number in the vector. For a correctly normalized 512-D vector the total length is 1, so each component is tiny: RMS ≈1/√512 ≈ 0.0442. Your RMS is ~0.43 — about 10× too large, matching the norm being ~10× too large. Two independent framings, same conclusion.
What the numbers prove
1. Norms are ~9–11, not ~1 → never normalized. A normalize step would force every norm to exactly 1.0000. Instead they sit near 10.
2. You don't even need the exact norm. A unit vector cannot have any component with magnitude > 1: if a component were 3.3, then ‖v‖ ≥ 3.3 > 1 by the norm formula (the full length is at least as big as any one leg). Every sampled vector has a component past ±1 — up to 6.17. That single fact rules out normalization.
3. Norms vary doc-to-doc (9.17 vs 10.76) → the dot-product fingerprint. From dot = ‖A‖·‖B‖·cos(θ), differing lengths mean the score is scaled by each doc's own length, not by meaning alone. Under true cosine every vector contributes length 1 and this variation disappears.
4. The CLIP "rogue dimension." The largest-magnitude component sits at the same index (~92) and is strongly negative across documents: −3.33, −5.32, −6.17, −5.03, −3.62. Concretely, for the v1·v2 pair:
dimension 92: v1[92] = -3.3277, v2[92] = -5.3205
product term = (-3.3277) × (-5.3205) = 17.7050
median product term across all 512 dims = 0.0363
-> this ONE dimension contributes ~488× a typical dimension
The raw dot product between these two vectors is 22.22 — and a single dimension supplies 17.71 of it (≈80%). So similarity is decided mostly by one outlier dimension, not the full 512-D semantic signal. This is the well-documented CLIP outlier-dimension effect, and L2 normalization is exactly the step that tames it.
9. Conclusion
- Metric in use: raw inner (dot) product —
space_type: innerproductdoes no length-division. - Euclidean? No — the
space_typewould readl2. - Cosine in effect? No.
innerproductequals cosine only on unit vectors; your sampled vectors have norms ~9–11. - Do faiss / 16x / on_disk imply normalization? No. They govern the search library, index structure, memory compression, and rescoring — none touches vector length. Only an ingest-time normalize would, and the norms prove it didn't run.
Net effect: the field ranks by direction and magnitude mixed together, with a single CLIP outlier dimension dominating (~80% of the score in the sampled pair) — not the pure angular (cosine) similarity you normally want from CLIP.
10. How to fix it
Option A — normalize at ingest (recommended). Unit-normalize every embedding before writing, and normalize the query vector identically. Then innerproduct becomes true cosine, outlier dims stop dominating, and quantization improves.
import numpy as np
def to_unit(vec):
v = np.asarray(vec, dtype=np.float32)
n = np.linalg.norm(v)
if n == 0:
raise ValueError("zero vector cannot be normalized")
return v / n
emb = to_unit(clip_image_embedding) # ||emb|| == 1 now
assert abs(np.linalg.norm(emb) - 1.0) < 1e-3 # guardrail so it can't silently regress
# ...index emb into binary_clip...
Option B — switch the field to cosinesimil. OpenSearch then does the division itself and you get cosine regardless of input norms:
PUT /mew2026_v2
{
"settings": { "index": { "knn": true } },
"mappings": {
"properties": {
"binary_clip": {
"type": "knn_vector",
"dimension": 512,
"space_type": "cosinesimil",
"mode": "on_disk",
"compression_level": "16x",
"method": { "name": "hnsw", "engine": "faiss" }
}
}
}
}
Either way it's a reindex, because the metric is baked into the HNSW graph at build time — you can't change space_type on a live field. A typical flow: create the new index (with normalized ingest, or cosinesimil), reindex/re-embed into it, then swap an alias so callers don't change their queries:
POST /_aliases
{
"actions": [
{ "remove": { "index": "mew2026", "alias": "clip_search" } },
{ "add": { "index": "mew2026_v2", "alias": "clip_search" } }
]
}
Sanity check after fixing — re-run Section 7 Step 3; every norm should now read ~1.0000, and innerproduct _score values should fall into the 0–2 band (dot ∈ [−1, 1] → score = dot + 1).
Scope note: this was a 5-document sample. It's conclusive that normalization is absent (norms ~10; components > 1 are impossible for unit vectors), but for full confidence run the Step-3 norm check over a larger random sample — expect the same result unless different code paths write to this field differently.
11. Glossary
- Bit — a single binary digit (0 or 1).
nbits encode2ⁿdistinct values. - CLIP — Contrastive Language–Image Pre-training; the OpenAI model that produced these image embeddings. Trained so images and their text captions land near each other.
- Component / dimension — one number (one slot) in a vector. Your vectors have 512.
- Cosine similarity — the cosine of the angle between two vectors; measures direction only, ignoring length. Range −1 to +1.
- Dot product / inner product — sum of products of matching components (
A·B). Blends direction and length. OpenSearch calls itinnerproduct. - Embedding — a fixed-length list of numbers a model outputs to represent an input's meaning.
- Euclidean — pertaining to ordinary flat-space geometry (after Euclid, ~300 BCE); the L2 norm/distance is "Euclidean" because it's the straight-line Pythagorean length.
- faiss — Facebook AI Similarity Search; the library OpenSearch uses to store and search vectors. Does not modify your numbers.
- float32 — a 32-bit floating-point number (IEEE 754); the high-precision format the original embeddings use before compression.
- HNSW — Hierarchical Navigable Small World; the graph index that finds nearest neighbors in a few hops instead of scanning everything.
- L1 / L2 / L∞ norm — members of the Lᵖ norm family (exponent
p= 1, 2, ∞). L1 = sum of absolute values (Manhattan); L2 = square-root of sum of squares (Euclidean, the default "length"); L∞ = largest absolute component. "L" honors Henri Lebesgue. - Magnitude / length / norm — how "big" a vector is;
‖v‖. Unqualified, it means the L2 norm. - Normalization (unit-normalize) — rescaling a vector to length 1 by dividing by its own norm; keeps direction, removes length.
- Orthogonal — at a 90° angle; cosine 0; "unrelated" directions.
- Quantization — rounding numbers to a small set of allowed values to store them in fewer bits (lossy compression).
- Rescore (on_disk) — a second pass that recomputes scores using full-precision vectors after a fast approximate first pass.
space_type— the OpenSearch setting naming the similarity metric for a vector field (cosinesimil,innerproduct, orl2).- Unit sphere / unit vector — the set of all length-1 vectors / a vector of length 1.
12. References
- OpenSearch — Spaces (
space_typedefinitions and score formulas forcosinesimil,innerproduct,l2): https://docs.opensearch.org/latest/mappings/supported-field-types/knn-spaces/ - OpenSearch — k-NN vector field type: https://docs.opensearch.org/latest/mappings/supported-field-types/knn-vector/
- OpenSearch — Disk-based vector search (
on_diskmode, two-phase rescoring,oversample_factordefault 2.0): https://docs.opensearch.org/latest/vector-search/optimizing-storage/disk-based-vector-search/ - OpenSearch — Memory-optimized vectors (
compression_level, rescoring): https://docs.opensearch.org/latest/mappings/supported-field-types/knn-memory-optimized/ - OpenSearch — Binary quantization (compression-level → bits/dimension: 32x=1-bit, 16x=2-bit, 8x=4-bit): https://docs.opensearch.org/3.0/vector-search/optimizing-storage/binary-quantization/
- faiss wiki — inner product vs L2, and
normalize_L2for cosine: https://github.com/facebookresearch/faiss/wiki - CLIP — Radford et al., Learning Transferable Visual Models From Natural Language Supervision (2021); embeddings are compared after L2 normalization: https://arxiv.org/abs/2103.00020
- Lᵖ spaces / Lebesgue (background on the norm family and its naming): https://en.wikipedia.org/wiki/Lp_space
(All numeric results in this document were computed directly from the five binary_clip vectors you sampled from mew2026.)
Source: vector_plot ·
vector_plot.md· updated 2026-07-10 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Build a single-file HTML tool (vanilla JS + Chart.js from CDN, no build step) called vector-profile-viewer.html that visualizes recommendation embeddings against a base vector.
Input
The tool accepts JSON via (a) a file picker and (b) a paste-in textarea. Schema:
{ "metadata": { "dim": 512, "metric": "cosine", // "cosine" | "l2" | "dot" "model": "optional-model-name", "normalized": true // whether vectors are already L2-normalized }, "base": { "label": "User profile (avg of history)", "vector": [0.012, -0.034, ...] // length must equal metadata.dim }, "items": [ { "id": "item-8841", "label": "Item title", "score": 0.9612, // similarity returned by the ANN engine "vector": [0.011, -0.029, ...] } // ... up to ~50 items ] }
Validate on load: all vectors match metadata.dim, no NaNs, items non-empty. Show clear error messages, never a blank page.
Processing
- If metadata.normalized is false OR any vector norm deviates from 1.0 by more than 1%, L2-normalize all vectors before plotting and show a notice ("vectors were normalized for display").
- Recompute cosine similarity of every item to the base locally; if it differs from the provided score by > 0.01, flag that item in the UI (helps catch metric mismatches with the ANN engine).
- Sort dimensions by the base vector's value (argsort). All plots use this shared column/x order.
- Rank items by recomputed similarity, descending.
Views (tabs or stacked sections)
View 1 — Sorted line profile (default)
- X axis: dimension rank 1..dim (sorted by base value), label it as such.
- Dark 2.5px line: base vector.
- Gray band (fill, ~18% opacity): per-dimension min/max envelope across all items.
- Highlighted lines: closest item (solid green #1baf7a), median item (dashed blue #2a78d6), farthest item (dotted red #e34948). 1.75px.
- Dropdown to swap any highlighted slot for a specific item by label.
- Toggle: raw values vs rolling-mean smoothing (window 5), default raw, state shown on the axis label.
- Y axis: auto-fit to data with 10% padding; if normalized, expect ~±0.15. If any |component| > 5x the median absolute value, list those dims as "outlier dimensions" below the chart instead of letting them flatten it, with a toggle to clip them from the y-range.
- Custom HTML legend above the chart (small squares + labels + sim scores), not Chart.js's default legend. Dash patterns must appear in the legend.
View 2 — Delta heatmap
- Canvas, one row per item (ranked by similarity, best on top), one 1px column per dimension (same sorted order).
- Cell value = item[d] − base[d]; diverging colormap blue↔gray↔red centered at 0, symmetric scale from the 99th percentile of |delta|.
- Row labels: "#rank · label · sim". Hover tooltip: dim index (original, pre-sort), base value, item value, delta.
View 3 — Summary
- Ranked horizontal lollipop chart of similarity to base.
- Stat tiles: n items, mean sim, min sim, mean pairwise sim among items (redundancy indicator).
General
- Everything client-side, no network calls except the Chart.js CDN.
- Include a "Load demo data" button that generates a synthetic base + 20 items with decaying similarity so the tool works before real data is wired in.
- Include an "Export PNG" button per chart and "Copy processed JSON" (with recomputed sims and sort order) for downstream use.
- Clean, minimal styling; must be readable in light and dark OS themes.
Source: ios_sensors ·
ios_sensors.md· updated 2026-06-22 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
iPhone Activity Detection: Walking vs Driving
A Deep-Dive Into Sensor Fusion, CoreMotion, CoreLocation, and the Physics Behind It
Document scope: This reference covers the full stack — from the silicon-level motion coprocessor through iOS frameworks to the application-level fusion classifier — explaining not just what each API does but why it works and when it fails. Code examples are in Swift (iOS 16+). Framework references map to Apple's developer documentation as of iOS 17 / Xcode 15.
Table of Contents
- The Problem: Why GPS Alone Is Not Enough
- The Hardware Layer: Sensors Inside the iPhone
- 2.1 Accelerometer
- 2.2 Gyroscope
- 2.3 Barometer
- 2.4 GPS / GNSS Receiver
- 2.5 The Motion Coprocessor (M-series chip)
- The Framework Layer: Apple's CoreMotion & CoreLocation
- Signal Characteristics: What Each Activity Looks Like
- The Physics and Mathematics Behind Each Signal
- Full Implementation Walkthrough
- 6.1 Project setup and permissions
- 6.2 ActivityType and ActivityResult models
- 6.3 ActivityDetector class structure
- 6.4 Signal 1: CMMotionActivityManager
- 6.5 Signal 2: CMPedometer
- 6.6 Signal 3: Raw accelerometer variance
- 6.7 Signal 4: GPS speed via CLLocationManager
- 6.8 The fusion classifier — vote-based decision logic
- 6.9 Wiring it into a ViewController
- Scenario Walkthroughs
- 7.1 Scenario A: Normal sidewalk walk
- 7.2 Scenario B: Driving on a motorway
- 7.3 Scenario C: Sitting in a slow-moving traffic jam
- 7.4 Scenario D: Passenger in a car (not driving)
- 7.5 Scenario E: Running for a bus
- 7.6 Scenario F: GPS-denied environment (underground car park)
- 7.7 Scenario G: Cycling
- 7.8 Scenario H: Treadmill walking
- Edge Cases and Known Failure Modes
- Real-World Use Cases and Applications
- Battery and Performance Considerations
- Improving Accuracy: Advanced Techniques
- Privacy, Permissions, and App Store Compliance
- Testing Strategy
- Broader Perspectives: How Other Platforms Approach This
- References and Further Reading
1. The Problem: Why GPS Alone Is Not Enough
At first glance, distinguishing walking from driving seems trivial: check the speed. A person walks at roughly 1.4 m/s; a car moves at 14 m/s or more. Just threshold on GPS velocity — done.
In practice this fails in at least five common situations:
Traffic jams. A car crawling at 0.5 m/s through gridlock produces a GPS reading indistinguishable from brisk walking. GPS alone would classify the driver as a pedestrian.
GPS lag and multipath. In urban canyons — surrounded by tall buildings — GPS signals bounce off surfaces before reaching the receiver. This causes "multipath error" where the reported position jumps, making computed speed unreliable or wildly incorrect. A pedestrian walking steadily might briefly read 12 m/s due to a bad fix.
GPS unavailability. Tunnels, underground car parks, dense indoor environments, and some rural areas with weak satellite coverage all produce invalid speed readings (CLLocation.speed == -1). The app must degrade gracefully and rely on other signals.
Speed overlap zones. A slow cyclist (3–5 m/s) overlaps with a runner (2.5–5 m/s). An electric scooter overlaps with a fast cyclist. Speed alone cannot separate these.
Cold starts. A GPS receiver that has been off for minutes needs 30–90 seconds to acquire satellites and produce an accurate fix. During that window, the initial speed readings can be zero or garbage.
The solution is sensor fusion: combining multiple independent signal sources so that each compensates for the others' weaknesses. GPS is unreliable in tunnels; the accelerometer still works underground. The coprocessor is unreliable at 0.5 m/s; GPS is accurate above 6 m/s. Together they cover the failure modes that no single sensor handles alone.
2. The Hardware Layer: Sensors Inside the iPhone
2.1 Accelerometer
The accelerometer is a MEMS (Micro-Electro-Mechanical Systems) device — a microscopic silicon proof mass suspended by springs inside the chip. When the device accelerates, the proof mass deflects relative to the housing. That deflection is measured capacitively (by how the gap between two conducting plates changes) and converted to a voltage proportional to acceleration in each axis.
Modern iPhones use a three-axis accelerometer, producing independent readings along X (left–right), Y (up–down), and Z (front–back) when the phone is held upright. The raw output is in units of g (standard gravity, 9.81 m/s²).
Important note on gravity: Even when the phone is completely stationary, the accelerometer reads approximately 1 g on whichever axis points toward the ground, because it is measuring the electromagnetic contact force that counteracts gravity, not true inertial acceleration. This is why computing the magnitude of the acceleration vector and subtracting 1 g (or comparing variance rather than absolute values) is important for motion analysis.
Sampling rates available through CoreMotion:
| Rate | Typical use |
|---|---|
| 10 Hz | Background step detection, coarse activity |
| 50 Hz | Real-time activity classification (our use case) |
| 100 Hz | Gesture recognition, sports tracking |
| 200–400 Hz (device-dependent) | Vibration analysis, fall detection |
The accelerometer consumes roughly 0.5–1 mA at 50 Hz — modest compared to GPS (~20–30 mA) but non-trivial for background apps.
2.2 Gyroscope
The gyroscope measures angular velocity (rotational rate) in degrees/second or radians/second around each axis. It uses a vibrating MEMS structure; Coriolis forces cause the vibration axis to shift during rotation, which is measured capacitively.
In activity detection, the gyroscope is less directly useful than the accelerometer, but it contributes to:
- Device orientation tracking — knowing whether the phone is in a pocket, on a table, or in a hand changes how you interpret accelerometer data.
- Reducing false positives from hand gestures — shaking the phone while sitting still produces accelerometer variance that could be mistaken for walking. The gyroscope signature of a hand shake (rapid, uncorrelated rotation) differs from a walking signature (periodic linear oscillation with minimal rotation).
- CMDeviceMotion — the
CMDeviceMotionAPI fuses accelerometer + gyroscope + magnetometer using a complementary filter to produce stable attitude (pitch, roll, yaw) estimates. This is not used in our basic implementation but is available.
2.3 Barometer
The barometer measures atmospheric pressure, which decreases at approximately 12 Pa per metre of altitude gain. This makes it useful for floor-level detection in multi-story buildings and staircase recognition.
In walking vs driving detection, the barometer is a secondary signal: a person climbing stairs shows accelerometer patterns similar to walking but with steadily decreasing pressure; a car going up a parking structure ramp shows driving-speed GPS with a slow pressure drop.
2.4 GPS / GNSS Receiver
Modern iPhones support multiple GNSS (Global Navigation Satellite System) constellations simultaneously:
- GPS — US Department of Defense, ~31 satellites
- GLONASS — Russian system, ~24 satellites
- Galileo — European Union, ~28 satellites
- BeiDou — Chinese system, ~35 satellites
- QZSS — Japanese regional system (useful in East Asia)
Supporting multiple constellations means more visible satellites at any given location, which dramatically improves fix accuracy and reduces time-to-first-fix. In open sky, modern iPhones achieve horizontal accuracy of 3–5 metres.
The iOS CLLocation object exposes:
location.speed // m/s, or -1 if invalid
location.speedAccuracy // m/s 1-sigma error; negative means invalid
location.horizontalAccuracy // metres, ~68% confidence radius
location.coordinate // CLLocationCoordinate2D
location.altitude // metres above sea level (WGS-84)
location.timestamp // Date of the reading
The speedAccuracy field is critical and often ignored. A speed reading of 1.5 m/s with speedAccuracy of 0.3 m/s is quite reliable; the same speed reading with speedAccuracy of 4.0 m/s is useless noise. The ActivityDetector implementation checks speedAccuracy >= 0 before trusting the speed.
2.5 The Motion Coprocessor (M-series chip)
Starting with the iPhone 5s (A7 chip with M7 coprocessor), Apple added a dedicated low-power motion coprocessor that runs independently of the main CPU. Even when the iPhone is in deep sleep — screen off, app in background — the M-series chip continues collecting and classifying motion data.
The coprocessor:
- Samples the accelerometer at low rates continuously
- Runs a built-in activity classifier (believed to use a Hidden Markov Model — see Section 5.4)
- Stores up to 7 days of historical activity data in hardware, accessible retrospectively via
CMMotionActivityManager.queryActivityStarting(from:to:to:withHandler:) - Consumes approximately 0.1 mA — orders of magnitude less than keeping the main CPU awake
This is why CMMotionActivityManager is the recommended first-line API for activity detection. You get high-quality classification essentially for free, power-wise.
3. The Framework Layer: Apple's CoreMotion & CoreLocation
3.1 CMMotionActivityManager — the coprocessor shortcut
CMMotionActivityManager is the highest-level API available. It surfaces the coprocessor's built-in activity classifier as a stream of CMMotionActivity objects.
Key properties of CMMotionActivity:
activity.walking // Bool — user is likely walking
activity.running // Bool — user is likely running
activity.cycling // Bool — user is likely cycling
activity.automotive // Bool — user is likely in a vehicle
activity.stationary // Bool — user is likely not moving
activity.confidence // CMMotionActivityConfidence: .low | .medium | .high
activity.startDate // Date when this activity started
Note that multiple booleans can be true simultaneously. This happens during transitions — e.g., stepping out of a car, automotive and walking are both true briefly. The confidence property reflects how certain the coprocessor is.
Checking availability:
guard CMMotionActivityManager.isActivityAvailable() else {
// Older devices (pre-iPhone 5s) or non-supported hardware
return
}
Starting real-time updates:
let manager = CMMotionActivityManager()
manager.startActivityUpdates(to: .main) { activity in
guard let activity = activity else { return }
// Priority order matters — automotive takes precedence
if activity.automotive {
print("Driving — confidence: \(activity.confidence)")
} else if activity.running {
print("Running")
} else if activity.walking {
print("Walking")
} else if activity.stationary {
print("Stationary")
}
}
Querying historical data:
let yesterday = Date().addingTimeInterval(-86400)
let now = Date()
manager.queryActivityStarting(from: yesterday, to: now, to: .main) { activities, error in
guard let activities = activities, error == nil else { return }
let drivingTime = activities
.filter { $0.automotive && $0.confidence != .low }
.map { $0.startDate }
// compute durations between consecutive events...
print("Activities in past 24h: \(activities.count)")
}
Required plist key:
<key>NSMotionUsageDescription</key>
<string>Used to detect whether you are walking or driving to adapt app behaviour.</string>
3.2 CMPedometer — step detection and cadence
CMPedometer provides higher-level walking metrics derived from the accelerometer via the motion coprocessor:
numberOfSteps— cumulative steps since the start datecurrentPace— current pace in seconds per metre (⚠ not per step)currentCadence— steps per second (iOS 9+)distance— estimated walking distance in metresfloorsAscended/floorsDescended— floors climbed/descended (requires barometer)
Current cadence vs. current pace: A subtle but important distinction. currentCadence is directly steps/second. currentPace is seconds/metre — it represents how long it takes to cover a metre of ground, combining both step frequency and stride length. For activity classification, cadence is more directly useful because it doesn't depend on stride length estimation.
let pedometer = CMPedometer()
guard CMPedometer.isStepCountingAvailable() else { return }
pedometer.startUpdates(from: Date()) { data, error in
guard let data = data, error == nil else { return }
// currentCadence is steps/second directly
if let cadence = data.currentCadence {
let stepsPerSecond = cadence.doubleValue
print("Cadence: \(stepsPerSecond) steps/sec")
}
// currentPace is sec/metre — invert for steps/sec if no cadence available
if let pace = data.currentPace {
let secondsPerStep = pace.doubleValue
let stepsPerSecond = 1.0 / secondsPerStep
print("Step freq (from pace): \(stepsPerSecond) steps/sec")
}
}
Why cadence is a powerful discriminator:
Human gait has a remarkably consistent cadence range. Across all ages and heights:
- Strolling: 0.8–1.2 steps/sec
- Normal walking: 1.4–1.8 steps/sec
- Brisk walking: 1.8–2.5 steps/sec
- Running: 2.5–4.0 steps/sec
- Sprint: 4.0–5.0 steps/sec
A person sitting in a car, even on a bumpy road, registers essentially 0 steps/sec. This makes near-zero cadence a strong vote for driving or stationary.
Checking device support before using:
CMPedometer.isStepCountingAvailable() // accelerometer-based step count
CMPedometer.isCadenceAvailable() // iOS 9+, requires M7+
CMPedometer.isDistanceAvailable() // M7+ with motion coprocessor
CMPedometer.isFloorCountingAvailable() // requires barometer
3.3 CMMotionManager — raw accelerometer data
CMMotionManager provides direct access to raw accelerometer (and gyroscope, magnetometer, and device motion) streams. Unlike CMMotionActivityManager, this runs on the main application processor — it stops when the app is backgrounded unless Background Modes are enabled.
Setting up 50 Hz accelerometer updates:
let motionManager = CMMotionManager()
guard motionManager.isAccelerometerAvailable else { return }
// 50 Hz = 0.02 second interval
motionManager.accelerometerUpdateInterval = 1.0 / 50.0
motionManager.startAccelerometerUpdates(to: .main) { data, error in
guard let data = data, error == nil else { return }
// data.acceleration is in g (1 g ≈ 9.81 m/s²)
let x = data.acceleration.x
let y = data.acceleration.y
let z = data.acceleration.z
// Magnitude of the acceleration vector
let magnitude = sqrt(x*x + y*y + z*z)
// At rest: magnitude ≈ 1.0 (gravity vector)
// Walking: magnitude oscillates ~0.8–1.2 with each step
// Driving on smooth road: magnitude ≈ 1.0 with small perturbations
// Pothole or speed bump: magnitude spikes to 1.5–2.5 briefly
}
CMAcceleration axes (device held upright, portrait):
+Y (up)
│
│
──────┼────── +X (right)
│
│
(screen faces +Z)
Since phone orientation varies (pocket, bag, car mount), axis-specific analysis is fragile. The magnitude sqrt(x² + y² + z²) is orientation-invariant and is what we use for variance computation.
Stopping updates properly:
motionManager.stopAccelerometerUpdates()
// Also available:
motionManager.stopGyroUpdates()
motionManager.stopDeviceMotionUpdates()
3.4 CLLocationManager — GPS speed and heading
CLLocationManager is the gateway to all location and heading data. For activity detection, the key fields are speed and speedAccuracy.
Accuracy modes and their trade-offs:
// Best accuracy — full GPS, heavy battery use
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// ~10m accuracy, moderate battery
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
// ~100m accuracy — uses cell towers + WiFi, very battery-efficient
locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
For activity detection, kCLLocationAccuracyBest is recommended because we need the Doppler-derived velocity measurement, which requires a full GNSS fix. Cell tower triangulation does not produce a reliable speed value.
Filtering out bad speed readings:
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
// Negative speedAccuracy means the speed reading is invalid
guard location.speedAccuracy >= 0 else { return }
// Optional: filter out extremely noisy readings
guard location.speedAccuracy < 2.0 else { return } // m/s error too high
// speed can be -1 if no valid speed measurement; clamp to 0
let speed = max(0, location.speed)
print("Speed: \(speed) m/s ±\(location.speedAccuracy) m/s")
}
The distanceFilter property controls how many metres the device must move before a location update fires. Setting distanceFilter = 2 (metres) for walking detection is a good balance — it prevents excessive callbacks when stationary while still catching slow pedestrian movement.
4. Signal Characteristics: What Each Activity Looks Like
4.1 GPS Speed Profile
| Activity | Typical speed range | Notes |
|---|---|---|
| Stationary | 0–0.3 m/s | GPS noise floor; readings oscillate even when standing still |
| Slow walk | 0.5–1.2 m/s | Older adults, browsing on a phone |
| Normal walk | 1.2–1.8 m/s | Average adult pace (~4.5 km/h) |
| Brisk walk | 1.8–2.2 m/s | Commuter pace |
| Running (slow jog) | 2.2–4.0 m/s | 8–14 min/km |
| Running (fast) | 4.0–7.0 m/s | Sub-8 min/km |
| Cycling (slow) | 3.0–5.0 m/s | Urban cycling |
| Cycling (fast) | 5.0–12.0 m/s | Road cycling |
| Traffic jam driving | 0–2.0 m/s | Overlap with walking range |
| Urban driving | 6.0–14.0 m/s | 22–50 km/h |
| Motorway driving | 22–40+ m/s | 80–145+ km/h |
The overlap between walking, slow cycling, and traffic-jam driving at 0–2 m/s is the core challenge GPS speed alone cannot resolve.
4.2 Step Frequency Profile
| Activity | Steps/second | Notes |
|---|---|---|
| Stationary | 0 | Occasional fidget may produce 0.1–0.3 briefly |
| Strolling | 0.8–1.2 | Window shopping, corridors |
| Normal walk | 1.4–1.8 | Most adults |
| Brisk walk | 1.8–2.5 | Exercise walking |
| Running | 2.5–4.0 | Increases with speed |
| Sprint | 4.0–5.0 | Elite sprinters up to 5.5 |
| Cycling | 0 | No footsteps (pedalling doesn't register) |
| Driving | 0 | No footsteps |
Step frequency is the cleanest discriminator between walking/running and everything else. Zero cadence rules out walking or running entirely.
4.3 Accelerometer Variance Profile
Variance measures how much the acceleration magnitude fluctuates around its mean over a 1-second window.
| Activity | Typical variance | Explanation |
|---|---|---|
| Stationary on table | <0.001 | Essentially no motion |
| Held stationary | 0.002–0.010 | Micro hand tremors |
| Driving (smooth motorway) | 0.005–0.020 | Engine vibration, smooth road |
| Driving (city with potholes) | 0.020–0.100 | Road surface irregularities |
| Walking (in pocket) | 0.020–0.080 | Hip-transmitted step bounce |
| Walking (in hand) | 0.050–0.200 | Arm swing adds to hip movement |
| Running | 0.100–0.400 | High-impact footstrikes |
| Cycling | 0.010–0.050 | Smooth cadence, less up-down |
Key insight: Driving on a smooth motorway has similar variance to a phone held in a steady hand, which is lower than walking variance. However, driving over potholes can spike variance to values indistinguishable from walking. This is why variance alone is an unreliable discriminator — it's best used as supporting evidence when GPS or pedometer data is ambiguous.
4.4 Summary Signal Table
A quick reference for the fusion logic:
| Signal | Walking | Driving | Running | Cycling | Stationary |
|---|---|---|---|---|---|
| GPS speed (m/s) | 0.5–2.2 | >6 (usually >10) | 2.2–7 | 3–12 | 0–0.3 |
| Cadence (steps/s) | 1.2–2.5 | ~0 | 2.5–5 | ~0 | ~0 |
| Accel variance | 0.02–0.2 | 0.005–0.05 | 0.1–0.4 | 0.01–0.05 | <0.01 |
| CMMotionActivity | .walking | .automotive | .running | .cycling | .stationary |
5. The Physics and Mathematics Behind Each Signal
5.1 How GNSS computes speed (Doppler vs position-differencing)
There are two methods a GNSS receiver can use to compute speed:
Position-differencing (less accurate):
speed ≈ Δposition / Δtime
You compute two positions 1 second apart and divide the distance by the elapsed time. This is simple but inherits all the noise of position estimation — if either position has a 5-metre error, the computed speed can be wildly wrong at pedestrian speeds.
Doppler measurement (far more accurate):
When a satellite and the receiver are in relative motion, the frequency of the satellite's carrier signal shifts by the Doppler effect:
Δf = (v_rel / c) × f_carrier
Where v_rel is the relative radial velocity, c is the speed of light (~3×10⁸ m/s), and f_carrier is the GPS L1 carrier frequency (1575.42 MHz).
Modern GNSS receivers, including those in iPhones, measure this Doppler shift directly from the carrier phase tracking loop. Because the carrier frequency is ~1.5 GHz, even a 0.1 Hz Doppler shift corresponds to ~0.02 m/s velocity — far more precise than position-differencing.
Apple's CLLocation returns Doppler-derived speed when available. This is why speed from a good GPS fix at pedestrian velocities (1–2 m/s) can be accurate to ±0.3 m/s — useful for distinguishing 1.4 m/s walking from 0.5 m/s shuffle.
5.2 Step detection: peak-finding on the acceleration magnitude vector
The coprocessor's step detection algorithm (and CMPedometer) works by finding peaks in the acceleration magnitude signal. Here's a simplified version of the logic:
magnitude(t) = sqrt(ax(t)² + ay(t)² + az(t)²)
During walking, each footstrike causes the body's centre of mass to decelerate slightly, then accelerate again on toe-off. This produces a signature waveform in the magnitude signal:
Magnitude (g)
1.4 | * *
1.2 | * * * *
1.0 | * * * *
0.8 | * *
|______________________ time →
↑ ↑ ↑ ↑
footstrikes (steps)
The frequency of these peaks is the cadence. Peak detection requires:
- Low-pass filtering to remove high-frequency noise (the coprocessor does this in hardware)
- Finding local maxima above a threshold (~1.2 g for the magnitude)
- Requiring a minimum time between peaks (~0.25 sec = max ~4 steps/sec) to avoid double-counting
The coprocessor also learns your personal gait signature through calibration (done automatically over time or manually via the Health app), which is why step count accuracy improves after the first few days of use.
5.3 Population variance as a motion roughness measure
The variance of a set of N values is:
σ² = (1/N) × Σ(xᵢ - μ)²
where μ = (1/N) × Σxᵢ (the mean)
In Swift:
func variance(of values: [Double]) -> Double {
guard values.count > 1 else { return 0 }
let mean = values.reduce(0, +) / Double(values.count)
let sumSquaredDeviations = values.reduce(0) { $0 + ($1 - mean) * ($1 - mean) }
return sumSquaredDeviations / Double(values.count)
}
Applied to a rolling 50-sample (1-second at 50 Hz) window of acceleration magnitudes, this gives a scalar measure of how "rough" the motion is.
Why a rolling window rather than all-time variance?
A cumulative variance from the moment the app started would be dominated by historical data and would react very slowly to changes. A 1-second rolling window captures the current motion state. The choice of window length involves a trade-off:
- Shorter window (0.5 sec / 25 samples): Responds faster to mode changes, but more susceptible to momentary spikes (a single pothole).
- Longer window (2 sec / 100 samples): More stable classification, but slower to detect transitions from walking to driving.
A 1-second window at 50 Hz is a reasonable default for most applications.
Why magnitude rather than a single axis?
Using the magnitude sqrt(x² + y² + z²) makes the computation rotation-invariant. You don't need to know which way the phone is oriented. A step impulse appears as a magnitude spike regardless of whether the phone is in a jacket pocket, a trouser pocket, or held in the hand.
5.4 Why the M-series coprocessor uses a Hidden Markov Model
The coprocessor's activity classifier almost certainly uses a Hidden Markov Model (HMM), based on Apple patents and academic literature. Here's the intuition:
An HMM models a system that transitions between hidden states (the activities: walking, driving, etc.) over time. The key insight is that activities have temporal continuity — you're unlikely to switch from driving to walking in 0.1 seconds. The HMM encodes this via transition probabilities:
P(driving → walking in 1 sec) ≈ 0.001 (very unlikely)
P(driving → driving in 1 sec) ≈ 0.999 (very likely)
P(walking → running in 1 sec) ≈ 0.05 (possible)
At each time step, the model observes the sensor data (the "emission") and updates its belief about the current hidden state using Bayes' rule:
P(state | observations) ∝ P(observations | state) × P(state | previous_state)
This is what makes the coprocessor robust to brief ambiguous readings. A single bumpy-road sample that looks like walking variance won't flip the classifier to "walking" if the transition probability from automotive to walking is very low and GPS is reading 80 km/h.
The HMM's parameters (transition probabilities and observation distributions) are trained offline on large datasets of labelled activities collected from real users. Apple's implementation also appears to do online adaptation — the classifier personalises to your gait over time, which is why the system becomes more accurate after a few days.
6. Full Implementation Walkthrough
6.1 Project setup and permissions
Add entitlements and plist keys:
In Info.plist:
<!-- Required for CLLocationManager -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to detect whether you are walking or driving.</string>
<!-- Required only if you need location in the background -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Background location allows activity detection to continue when the app is minimised.</string>
<!-- Required for CMMotionActivityManager -->
<key>NSMotionUsageDescription</key>
<string>Motion data lets us distinguish walking from driving without draining your battery.</string>
Background Modes (if you need detection while app is in background):
In Signing & Capabilities → Background Modes:
- ✅ Location updates (for GPS-based detection)
- ✅ Motion and Fitness (automatically enabled by CoreMotion)
Note: CMMotionActivityManager and CMPedometer continue running through the coprocessor regardless of background modes. Only CMMotionManager (raw accelerometer) stops in the background unless you have a specific background entitlement.
Minimum deployment target: iOS 11+ is required for all APIs used. currentCadence on CMPedometer requires iOS 9+. The M-series motion coprocessor is available from iPhone 5s (2013) onward.
6.2 ActivityType and ActivityResult models
import Foundation
/// The classified activity type
enum ActivityType: String, CaseIterable {
case walking = "🚶 Walking"
case driving = "🚗 Driving"
case running = "🏃 Running"
case cycling = "🚴 Cycling"
case stationary = "🧍 Stationary"
case unknown = "❓ Unknown"
/// Human-readable description without emoji
var plainName: String {
switch self {
case .walking: return "Walking"
case .driving: return "Driving"
case .running: return "Running"
case .cycling: return "Cycling"
case .stationary: return "Stationary"
case .unknown: return "Unknown"
}
}
/// Whether this activity type involves self-propulsion on foot
var isPedestrian: Bool {
return self == .walking || self == .running
}
/// Whether this activity suggests the user is inside a vehicle
var isVehicular: Bool {
return self == .driving
}
}
/// A single classified activity observation with all supporting signal values
struct ActivityResult {
let type: ActivityType
let confidence: String // "low", "medium", or "high"
let speed: Double // m/s from GPS (-1 if unavailable)
let stepFrequency: Double // steps/sec from pedometer (0 if not walking)
let accelerationVariance: Double // dimensionless variance of magnitude buffer
let timestamp: Date
/// Speed converted to km/h for display
var speedKMH: Double {
return speed >= 0 ? speed * 3.6 : -1
}
/// Confidence expressed as a 0–1 numeric weight (for downstream weighting)
var confidenceWeight: Double {
switch confidence {
case "high": return 1.0
case "medium": return 0.6
default: return 0.3
}
}
/// Debug description
var debugDescription: String {
let speedStr = speed >= 0 ? String(format: "%.1f km/h", speedKMH) : "unavailable"
return """
─────────────────────────────────────
Activity : \(type.rawValue)
Confidence : \(confidence)
GPS speed : \(speedStr)
Step freq : \(String(format: "%.2f", stepFrequency)) steps/s
Accel var : \(String(format: "%.4f", accelerationVariance))
Time : \(timestamp)
─────────────────────────────────────
"""
}
}
6.3 ActivityDetector class structure
import CoreMotion
import CoreLocation
import Foundation
class ActivityDetector: NSObject {
// ── CoreMotion ────────────────────────────────────────────────────────────
/// High-level coprocessor activity classifier
private let motionActivityManager = CMMotionActivityManager()
/// Step counting and cadence
private let pedometer = CMPedometer()
/// Raw accelerometer stream
private let motionManager = CMMotionManager()
// ── CoreLocation ──────────────────────────────────────────────────────────
/// GPS speed and position
private let locationManager = CLLocationManager()
// ── Rolling signal state ──────────────────────────────────────────────────
/// Last valid GPS speed in m/s; -1 if no valid reading yet
private var currentGPSSpeed: Double = -1
/// Current step frequency in steps/sec from pedometer
private var currentStepFrequency: Double = 0
/// Rolling buffer of acceleration magnitude samples (1 second at 50 Hz)
private var recentAccelSamples: [Double] = []
private let sampleBufferMax = 50
/// Derived variance of the rolling buffer
private var currentAccelVariance: Double = 0
// ── Output ────────────────────────────────────────────────────────────────
/// Called every time a new fused classification is produced.
/// Invoked on the main thread.
var onActivityDetected: ((ActivityResult) -> Void)?
// ── Lifecycle ─────────────────────────────────────────────────────────────
func startDetecting() {
requestPermissions()
startHighLevelActivityMonitoring()
startPedometerUpdates()
startRawAccelerometer()
startGPS()
}
func stopDetecting() {
motionActivityManager.stopActivityUpdates()
pedometer.stopUpdates()
motionManager.stopAccelerometerUpdates()
locationManager.stopUpdatingLocation()
}
}
6.4 Signal 1: CMMotionActivityManager
private func startHighLevelActivityMonitoring() {
guard CMMotionActivityManager.isActivityAvailable() else {
// Pre-M7 devices (iPhone 5 and earlier) don't have the coprocessor.
// In practice this is only relevant if you support iOS 11 on very old hardware.
return
}
motionActivityManager.startActivityUpdates(to: .main) { [weak self] activity in
guard let self = self, let activity = activity else { return }
// This callback fires when the coprocessor detects an activity change.
// It is NOT fired at a fixed rate — only on transitions.
let classified = self.classifyHighLevel(activity)
// Log to console for debugging
print("[CMMotionActivity] \(classified.rawValue) confidence: \(activity.confidence)")
// Note: we don't call evaluateAndReport() here because
// CMMotionActivityManager alone is our sanity check, not the
// primary fused decision-maker. You could add it here if desired.
}
}
/// Translates CMMotionActivity boolean flags to our ActivityType enum.
/// Priority order: automotive > running > walking > cycling > stationary
private func classifyHighLevel(_ a: CMMotionActivity) -> ActivityType {
// automotive flag is most distinct — don't deprioritise it
if a.automotive { return .driving }
if a.running { return .running }
if a.walking { return .walking }
if a.cycling { return .cycling }
if a.stationary { return .stationary }
return .unknown
}
Why prioritise automotive above walking?
In transition moments (getting into a car, stepping onto a moving train platform), multiple flags can be true. automotive is the correct dominant classification in those cases because it means the user is inside a vehicle, regardless of incidental walking motion detected.
6.5 Signal 2: CMPedometer
private var currentStepFrequency: Double = 0
private func startPedometerUpdates() {
guard CMPedometer.isStepCountingAvailable() else {
print("CMPedometer: step counting unavailable on this device")
return
}
// startUpdates fires as steps accumulate; the interval is not fixed.
// Typical update rate: every 1–2 steps.
pedometer.startUpdates(from: Date()) { [weak self] data, error in
guard let self = self, let data = data, error == nil else { return }
// Prefer currentCadence (direct steps/sec) over deriving from currentPace
if let cadence = data.currentCadence {
self.currentStepFrequency = cadence.doubleValue
} else if let pace = data.currentPace {
// pace is seconds/metre; to get steps/sec:
// steps/sec = stride_length_m / seconds_per_step
// Without stride length we approximate:
// seconds_per_step ≈ pace (this is actually sec/m but works as proxy)
self.currentStepFrequency = 1.0 / pace.doubleValue
}
// Trigger a new fused evaluation
DispatchQueue.main.async {
self.evaluateAndReport()
}
}
}
Why does currentPace approximate seconds_per_step reasonably well?
The pedometer's stride length model estimates stride length from cadence and historical calibration. For an average adult at normal walking speed, stride length is approximately 0.75 m. currentPace (sec/metre) ÷ 0.75 gives seconds/step. When used just for the threshold check (is cadence > 1.2?), this approximation is sufficient.
6.6 Signal 3: Raw accelerometer variance
private var currentAccelVariance: Double = 0
private func startRawAccelerometer() {
guard motionManager.isAccelerometerAvailable else { return }
// 50 Hz is a good balance: captures the ~1.5–2 Hz walking frequency
// well above Nyquist (which requires at least 4 Hz sample rate for a 2 Hz signal),
// while not overwhelming the main queue with updates.
motionManager.accelerometerUpdateInterval = 1.0 / 50.0
motionManager.startAccelerometerUpdates(to: .main) { [weak self] data, error in
guard let self = self, let data = data, error == nil else { return }
// Compute orientation-invariant acceleration magnitude
let x = data.acceleration.x
let y = data.acceleration.y
let z = data.acceleration.z
let magnitude = sqrt(x*x + y*y + z*z)
// Maintain a rolling 1-second buffer
self.recentAccelSamples.append(magnitude)
if self.recentAccelSamples.count > self.sampleBufferMax {
self.recentAccelSamples.removeFirst() // O(n) — use a ring buffer for production
}
// Recompute variance over the buffer
self.currentAccelVariance = self.variance(of: self.recentAccelSamples)
// Note: we don't call evaluateAndReport() on every accelerometer sample
// (50 times/sec is too frequent). Pedometer and GPS callbacks drive reporting.
}
}
/// Population variance: σ² = (1/N) Σ(xᵢ - μ)²
private func variance(of values: [Double]) -> Double {
guard values.count > 1 else { return 0.0 }
let count = Double(values.count)
let mean = values.reduce(0.0, +) / count
let sumSquaredDeviations = values.reduce(0.0) { accumulator, value in
let deviation = value - mean
return accumulator + deviation * deviation
}
return sumSquaredDeviations / count
}
Production optimisation — ring buffer:
The removeFirst() call on an array is O(n) because it shifts all remaining elements. For a 50-element buffer at 50 Hz this is negligible, but for higher sample rates, use ArraySlice or a proper ring buffer:
// Simple ring buffer alternative
private var accelRingBuffer = [Double](repeating: 0, count: 50)
private var accelWriteIndex = 0
// In the update handler:
accelRingBuffer[accelWriteIndex % sampleBufferMax] = magnitude
accelWriteIndex += 1
// Variance computation still uses accelRingBuffer[0..<sampleBufferMax]
6.7 Signal 4: GPS speed via CLLocationManager
private func requestPermissions() {
locationManager.delegate = self
locationManager.requestWhenInUseAuthorization()
}
private func startGPS() {
locationManager.desiredAccuracy = kCLLocationAccuracyBest
// Minimum distance filter: update every 2 metres.
// This prevents callbacks when standing still due to GPS noise oscillation.
locationManager.distanceFilter = 2.0
locationManager.startUpdatingLocation()
}
// CLLocationManagerDelegate
extension ActivityDetector: CLLocationManagerDelegate {
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
switch manager.authorizationStatus {
case .authorizedWhenInUse, .authorizedAlways:
manager.startUpdatingLocation()
case .denied, .restricted:
// Degrade gracefully: rely only on motion sensors
print("Location access denied — GPS speed unavailable")
default:
break
}
}
func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
// Use the most recent location
guard let location = locations.last else { return }
// Reject readings where the speed measurement is invalid.
// speedAccuracy < 0 indicates the speed is not available for this fix.
guard location.speedAccuracy >= 0 else { return }
// CLLocation.speed can technically be -1 for no measurement;
// clamp to 0 to avoid negative speeds entering our logic.
currentGPSSpeed = max(0.0, location.speed)
// A new GPS reading triggers a fused evaluation
evaluateAndReport()
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let clError = error as? CLError else { return }
switch clError.code {
case .denied:
print("GPS denied by user")
case .locationUnknown:
// Transient — GPS is trying to acquire a fix
currentGPSSpeed = -1
default:
print("GPS error: \(clError.localizedDescription)")
}
}
}
6.8 The fusion classifier — vote-based decision logic
private func evaluateAndReport() {
let speed = currentGPSSpeed // m/s (-1 = unavailable)
let stepFreq = currentStepFrequency // steps/sec
let accelVar = currentAccelVariance // dimensionless
let (type, confidence) = fuseSignals(
speed: speed,
stepFrequency: stepFreq,
accelVariance: accelVar
)
let result = ActivityResult(
type: type,
confidence: confidence,
speed: speed,
stepFrequency: stepFreq,
accelerationVariance: accelVar,
timestamp: Date()
)
onActivityDetected?(result)
}
private func fuseSignals(
speed: Double,
stepFrequency: Double,
accelVariance: Double
) -> (ActivityType, String) {
// ── Thresholds (tuneable) ─────────────────────────────────────────────
let drivingSpeedThreshold: Double = 6.0 // m/s ≈ 21.6 km/h ≈ 13.4 mph
let walkingSpeedMax: Double = 2.2 // m/s ≈ 7.9 km/h ≈ 4.9 mph
let runningSpeedMin: Double = 2.2 // m/s
let walkingStepMin: Double = 1.2 // steps/sec (slow walk)
let runningStepMin: Double = 2.5 // steps/sec (jog boundary)
let noStepThreshold: Double = 0.2 // steps/sec (essentially zero)
let drivingAccelVarMax: Double = 0.02 // very smooth ride
let walkingAccelVarMin: Double = 0.02 // rhythmic step bounce
// ─────────────────────────────────────────────────────────────────────
var votes: [ActivityType: Int] = [:]
// ── Vote 1: GPS speed (weight 3 for driving, 2 for others) ────────────
// GPS speed is the most definitive signal when reliable.
if speed >= 0 {
if speed > drivingSpeedThreshold {
votes[.driving, default: 0] += 3 // very strong: no pedestrian moves this fast
} else if speed < walkingSpeedMax {
votes[.walking, default: 0] += 2 // probably walking or slow
} else {
// Speed is in the 2.2–6.0 range: could be fast walk, run, or slow cycle
votes[.running, default: 0] += 2
}
}
// If speed < 0 (unavailable), this entire block is skipped —
// the other two signals carry the classification alone.
// ── Vote 2: Step frequency (weight 2) ─────────────────────────────────
// Cadence is a clean binary: you're either stepping or you're not.
if stepFrequency > runningStepMin {
votes[.running, default: 0] += 2
} else if stepFrequency > walkingStepMin {
votes[.walking, default: 0] += 2
} else if stepFrequency < noStepThreshold {
// Near-zero cadence: driving or stationary
votes[.driving, default: 0] += 1
}
// ── Vote 3: Accelerometer variance (weight 1) ──────────────────────────
// Secondary evidence — corroborates but doesn't decide.
if accelVariance < drivingAccelVarMax {
votes[.driving, default: 0] += 1 // smooth: consistent with a vehicle
} else if accelVariance > walkingAccelVarMin {
votes[.walking, default: 0] += 1 // bouncy: consistent with walking
}
// ── Tally ──────────────────────────────────────────────────────────────
let winner = votes.max { $0.value < $1.value }
let type = winner?.key ?? .unknown
let score = winner?.value ?? 0
// Map total vote score to confidence
let confidence: String
switch score {
case 5...: confidence = "high" // ≥2 signals strongly agree
case 3...4: confidence = "medium" // majority signal agrees
default: confidence = "low" // weak or conflicting signals
}
return (type, confidence)
}
How the vote scores map to scenarios:
| Scenario | GPS votes | Cadence votes | Var votes | Total | Confidence |
|---|---|---|---|---|---|
| Walking, GPS available | 2 (walk) | 2 (walk) | 1 (walk) | 5 walk | High |
| Driving at 60 km/h | 3 (drive) | 1 (drive) | 1 (drive) | 5 drive | High |
| Traffic jam (5 km/h) | 2 (walk) | 1 (drive) | 1 (drive) | 2 walk, 2 drive | Low — tie |
| No GPS, walking | — | 2 (walk) | 1 (walk) | 3 walk | Medium |
| Running | 2 (run) | 2 (run) | 1 (walk) | 4 run, 1 walk | Medium-high |
Notice the traffic jam tie case: score is 2 each, resulting in low confidence and potentially .unknown. This is the correct behaviour — we genuinely don't know. Adding the CMMotionActivityManager high-level classification as a tie-breaker is a recommended enhancement (see Section 11).
6.9 Wiring it into a ViewController
import UIKit
class ActivityViewController: UIViewController {
private let detector = ActivityDetector()
// ── UI outlets ────────────────────────────────────────────────────────────
@IBOutlet private weak var activityLabel: UILabel!
@IBOutlet private weak var confidenceLabel: UILabel!
@IBOutlet private weak var speedLabel: UILabel!
@IBOutlet private weak var cadenceLabel: UILabel!
@IBOutlet private weak var varianceLabel: UILabel!
@IBOutlet private weak var timestampLabel: UILabel!
// ── Lifecycle ─────────────────────────────────────────────────────────────
override func viewDidLoad() {
super.viewDidLoad()
configureDetector()
detector.startDetecting()
}
deinit {
detector.stopDetecting()
}
// ── Setup ─────────────────────────────────────────────────────────────────
private func configureDetector() {
detector.onActivityDetected = { [weak self] result in
// Callback is already on main thread
self?.updateUI(with: result)
self?.handleActivityChange(result)
}
}
// ── UI update ─────────────────────────────────────────────────────────────
private func updateUI(with result: ActivityResult) {
activityLabel.text = result.type.rawValue
confidenceLabel.text = "Confidence: \(result.confidence)"
if result.speed >= 0 {
speedLabel.text = String(format: "Speed: %.1f km/h", result.speedKMH)
} else {
speedLabel.text = "Speed: GPS unavailable"
}
cadenceLabel.text = String(format: "Cadence: %.2f steps/s", result.stepFrequency)
varianceLabel.text = String(format: "Variance: %.4f", result.accelerationVariance)
let formatter = DateFormatter()
formatter.timeStyle = .medium
timestampLabel.text = "Updated: \(formatter.string(from: result.timestamp))"
}
// ── Application logic ─────────────────────────────────────────────────────
private func handleActivityChange(_ result: ActivityResult) {
guard result.confidence != "low" else { return }
switch result.type {
case .driving:
enableDrivingMode()
case .walking, .running:
enablePedestrianMode()
case .stationary:
enableStationaryMode()
default:
break
}
}
private func enableDrivingMode() {
// Example: suppress notifications, switch to audio-only interface
print("Driving mode activated")
}
private func enablePedestrianMode() {
// Example: switch to walking navigation, show nearby POIs
print("Pedestrian mode activated")
}
private func enableStationaryMode() {
// Example: offer suggestions based on location
print("Stationary mode activated")
}
}
7. Scenario Walkthroughs
7.1 Scenario A: Normal sidewalk walk
Context: User walking to work at 1.5 m/s on a clear day, phone in jacket pocket.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 1.5 m/s (valid, accuracy ±0.3 m/s) | +2 walking |
| Cadence | 1.7 steps/sec | +2 walking |
| Accel variance | 0.045 | +1 walking |
| Total | 5 walking — High confidence |
CMMotionActivityManager fires .walking with .high confidence simultaneously.
Result: ActivityType.walking, confidence "high" ✅
7.2 Scenario B: Driving on a motorway
Context: User driving at 110 km/h (30.5 m/s) on a motorway, phone on dashboard mount.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 30.5 m/s (valid) | +3 driving |
| Cadence | 0.0 steps/sec | +1 driving |
| Accel variance | 0.008 (smooth tarmac) | +1 driving |
| Total | 5 driving — High confidence |
Result: ActivityType.driving, confidence "high" ✅
7.3 Scenario C: Sitting in a slow-moving traffic jam
Context: Car moving at 0.8 m/s through city traffic, phone in cupholder.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 0.8 m/s | +2 walking (below walkingSpeedMax of 2.2) |
| Cadence | 0.0 steps/sec | +1 driving |
| Accel variance | 0.015 (small bumps) | borderline — 0 votes |
| Total | 2 walking, 1 driving — Low confidence |
Result: Type could be walking or driving; confidence "low". This is expected — GPS is ambiguous. The CMMotionActivityManager should return .automotive here (the coprocessor's HMM knows the user was driving before entering the jam) and can be used as a tie-breaker.
Recommended enhancement: Add a fourth vote source from CMMotionActivityManager:
// In the fusion classifier, add:
if let lastCoprocessorActivity = lastCoprocessorActivity {
if lastCoprocessorActivity.automotive {
votes[.driving, default: 0] += 2
} else if lastCoprocessorActivity.walking {
votes[.walking, default: 0] += 2
}
}
7.4 Scenario D: Passenger in a car (not driving)
From a sensor perspective, a passenger is indistinguishable from a driver. Both are inside an automotive vehicle, both show near-zero cadence, and GPS speed reflects the vehicle speed. This is a fundamental limitation of the approach described here.
To distinguish driver from passenger requires additional signals:
- Bluetooth/CarPlay connection — if the phone is connected to the car's infotainment system, it's likely the driver's phone.
- Steering wheel pattern in gyroscope — the driver's phone (in a cup holder or mount near the steering wheel) shows micro-rotational signals correlated with steering.
- Seat position — not accessible to apps without additional hardware.
Apple's own "Driving Focus" uses CarPlay connection and Bluetooth as the primary discriminator.
7.5 Scenario E: Running for a bus
Context: User sprinting at 4.5 m/s for 15 seconds, then standing at a bus stop.
During the sprint:
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 4.5 m/s | +2 running |
| Cadence | 3.2 steps/sec | +2 running |
| Accel variance | 0.22 | +1 walking |
| Total | 4 running, 1 walking — Medium confidence running |
At the bus stop (after 5 seconds standing):
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 0.1 m/s (GPS noise at standstill) | +2 walking |
| Cadence | 0.0 | +1 driving |
| Accel variance | 0.003 (very still) | +1 driving |
| Total | 2 walking, 2 driving — Low confidence (tie) |
The tie at the bus stop can be resolved by the coprocessor (which will return .stationary after a few seconds of no movement) or by a temporal hysteresis filter that prevents rapid transitions.
7.6 Scenario F: GPS-denied environment (underground car park)
Context: User walking through an underground car park. GPS unavailable, speed returns -1.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | -1 (invalid) | No votes cast |
| Cadence | 1.6 steps/sec | +2 walking |
| Accel variance | 0.040 | +1 walking |
| Total | 3 walking — Medium confidence |
GPS votes are skipped entirely (speed >= 0 check fails). The system correctly falls back to the motion-only signals. Medium confidence reflects the missing GPS corroboration.
This demonstrates why multi-signal fusion is essential: GPS-only classification would produce no output at all in this scenario.
7.7 Scenario G: Cycling
Context: User cycling at 6 m/s on a bike path, phone in jersey pocket.
| Signal | Value | Vote |
|---|---|---|
| GPS speed | 6.0 m/s | Exactly at drivingSpeedThreshold — votes +3 driving |
| Cadence | 0.0 steps/sec (no footsteps on pedals) | +1 driving |
| Accel variance | 0.018 (smooth bike path) | borderline — 0 votes |
| Total | 4 driving — Medium confidence |
This is a known misclassification: cycling at road speed is mistaken for driving. The CMMotionActivityManager returns .cycling here, which can override the fusion result. This highlights the value of including the coprocessor result as a voting signal.
Enhanced fusion including coprocessor:
// Add to fuseSignals:
if let coprocessorType = lastCoprocessorClassification {
switch coprocessorType {
case .cycling:
votes[.cycling, default: 0] += 3 // Trust the coprocessor on cycling
case .driving:
votes[.driving, default: 0] += 2
case .walking:
votes[.walking, default: 0] += 2
default:
break
}
}
7.8 Scenario H: Treadmill walking
Context: User walking on a treadmill at 5 km/h. GPS speed: effectively 0 (not moving through space).
| Signal | Value | Vote |
|---|---|---|
| GPS speed | ~0.1 m/s (GPS noise) | +2 walking |
| Cadence | 1.6 steps/sec | +2 walking |
| Accel variance | 0.035 | +1 walking |
| Total | 5 walking — High confidence |
The treadmill case is handled correctly because GPS is not needed — cadence and variance alone confirm walking. The Health app also correctly logs treadmill walks as walking distance (using step count + stride length rather than GPS displacement).
8. Edge Cases and Known Failure Modes
False walking during car vibration
On cobblestone streets, gravel roads, or over expansion joints on bridges, a car can produce accelerometer variance of 0.05–0.15 — overlapping with the walking range. The cadence signal will remain at zero (no steps), which should prevent misclassification if weighted properly. Ensure cadence has higher weight than variance in your fusion logic.
False driving during wheelchair use
A manual wheelchair user at normal push speed (~1–2 m/s) has near-zero cadence (the arms push, not walk) and may have low accelerometer variance on a smooth floor. GPS correctly reads walking speed, but cadence and variance both vote "driving/stationary". The CMMotionActivityManager typically returns .stationary for wheelchair users on smooth floors, which is technically incorrect.
If your app needs to support wheelchair users, consider adding an explicit wheelchair mode (as Apple's Health app does) with separate thresholds.
False stationary during slow dance or yoga
Some yoga poses involve holding the body still for seconds. Between transitions, the sensor profile can briefly match "stationary". This is usually benign (the transition lasts 1–2 seconds) but can cause flickers in real-time display. Hysteresis (Section 11.3) is the remedy.
Running at low cadence (injury or age)
Elderly users and some injury-recovery patients may run or jog at cadences below 2.5 steps/sec. The classifier would label these as "walking" — usually acceptable since the distinction is minor for most applications.
Child in pushchair
A child's phone in a pushchair shows: GPS at walking speed, near-zero cadence, and moderate variance from the pushchair's vibration. Classification correctly returns "walking" from the parent's perspective (the pushchair is being propelled by a walking adult). The child's own motion (sitting still) is not separately classifiable.
Elevator
Vertical motion (elevator) produces a brief acceleration spike when the elevator starts and stops, and a short period of near-zero horizontal displacement. GPS may not update (indoors). The system typically classifies elevator rides as "stationary" — correct enough for most applications, but CMDeviceMotion.userAcceleration on the Y-axis can detect the vertical acceleration if needed.
9. Real-World Use Cases and Applications
Navigation apps (Apple Maps, Google Maps, Waze)
These apps switch the UI, route type, and turn-by-turn style automatically based on detected activity. When the system detects walking, it may:
- Show pedestrian routing (footpaths, crosswalks)
- Reduce map zoom to show more detail at the walking scale
- Announce turns in seconds/distance rather than motorway-style distance markers
Driving Focus / Do Not Disturb While Driving
iOS's built-in Driving Focus uses the motion stack to automatically enable notification suppression. It responds to CMMotionActivityManager returning .automotive with high confidence, optionally combined with CarPlay/Bluetooth detection.
Fitness and health tracking (Apple Health, Strava, Garmin Connect)
Walking and running activity detected by CMPedometer accumulates in HealthKit under HKQuantityType.stepCount, HKQuantityType.distanceWalkingRunning, and HKQuantityType.activeEnergyBurned. Apps can query and write these quantities:
import HealthKit
let store = HKHealthStore()
let stepType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
let query = HKStatisticsQuery(
quantityType: stepType,
quantitySamplePredicate: nil,
options: .cumulativeSum
) { _, result, error in
let steps = result?.sumQuantity()?.doubleValue(for: .count()) ?? 0
print("Total steps: \(steps)")
}
store.execute(query)
Insurance telematics
Several car insurance providers (Progressive Snapshot, Allstate Drivewise) use smartphone sensors to score driving behaviour. Activity detection ensures the telematics app only analyses driving sessions and ignores walking or public transport. Accelerometer data during confirmed driving sessions is used to detect harsh braking (large negative acceleration spike), rapid cornering (lateral acceleration), and speeding (GPS speed above limit).
Automatic mileage logging
Apps like MileIQ, Driversnote, and Everlance automatically log vehicle trips for expense reporting or tax deduction. Activity detection is the trigger: when the transition from walking to driving is detected, a new trip record begins. When driving transitions to walking (arriving at a destination), the trip ends and a GPS track is stored.
Adaptive UI for accessibility
Apps aimed at elderly users can use activity detection to adjust interface behaviour:
- When walking: increase notification urgency (user may not check phone)
- When stationary: show detailed interactive content
- When driving: suppress all non-critical interruptions
Location-based services optimisation
Continuous GPS use at kCLLocationAccuracyBest drains ~20–30 mA. An app can use activity detection to reduce GPS accuracy when walking or stationary:
detector.onActivityDetected = { [weak self] result in
guard let self = self else { return }
switch result.type {
case .driving:
// High accuracy needed for navigation
self.locationManager.desiredAccuracy = kCLLocationAccuracyBest
self.locationManager.distanceFilter = 5
case .walking:
// Moderate accuracy sufficient
self.locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
self.locationManager.distanceFilter = 10
case .stationary:
// Minimal accuracy saves battery
self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
self.locationManager.distanceFilter = 50
default:
break
}
}
Geofencing smarter triggers
Standard CLCircularRegion geofences fire based on GPS entry/exit regardless of activity. Combining with activity detection enables smarter triggers: "notify when the user arrives at work and is walking (i.e. they've parked and are approaching on foot)" rather than firing while still in the car.
10. Battery and Performance Considerations
| Component | Approximate current drain | Notes |
|---|---|---|
| CMMotionActivityManager | ~0.1 mA | Runs on coprocessor; negligible |
| CMPedometer | ~0.1 mA | Coprocessor-assisted |
| CMMotionManager (50 Hz) | ~0.5–1 mA | Main CPU required; stops in background |
| CLLocationManager (Best accuracy) | ~20–30 mA | Highest drain; use only when needed |
| CLLocationManager (100m accuracy) | ~3–5 mA | Acceptable for coarse activity |
| Total (full stack) | ~22–32 mA | GPS dominates |
Practical battery-saving strategies:
Use CMMotionActivityManager first. If you only need to know "is the user driving or walking?" at a coarse level, CMMotionActivityManager alone costs essentially nothing. Add GPS only when you need precise speed or route data.
Adaptive GPS accuracy (shown in Section 9 above). Reduce GPS accuracy when the user is not driving.
Stop the raw accelerometer when backgrounded. CMMotionManager stops automatically when the app backgrounds. If you need background variance analysis, consider using CMPedometer.queryPedometerData(from:to:withHandler:) batch queries rather than live streaming.
Batch historical queries instead of live streaming. For apps that analyse yesterday's activity pattern (e.g., a fitness summary), use queryActivityStarting(from:to:to:withHandler:) rather than streaming all day.
Implement significantLocationChangeMonitoring for coarse triggers:
locationManager.startMonitoringSignificantLocationChanges()
// Fires only when cell tower changes (~500m accuracy, ~1 mA)
// Enough to detect a major travel context change
11. Improving Accuracy: Advanced Techniques
11.1 Kalman filtering for GPS speed smoothing
Raw GPS speed can jump between updates due to multipath errors. A simple 1D Kalman filter smooths these spikes:
class KalmanFilter {
private var estimate: Double
private var errorCovariance: Double
private let processNoise: Double // How much we trust the model
private let measurementNoise: Double // How much we trust the sensor
init(initial: Double, processNoise: Double = 0.1, measurementNoise: Double = 1.0) {
self.estimate = initial
self.errorCovariance = 1.0
self.processNoise = processNoise
self.measurementNoise = measurementNoise
}
func update(measurement: Double) -> Double {
// Predict
let predictedCovariance = errorCovariance + processNoise
// Update (Kalman gain)
let gain = predictedCovariance / (predictedCovariance + measurementNoise)
estimate = estimate + gain * (measurement - estimate)
errorCovariance = (1 - gain) * predictedCovariance
return estimate
}
}
// Usage
let speedFilter = KalmanFilter(initial: 0, processNoise: 0.05, measurementNoise: 0.5)
// In location update handler:
let rawSpeed = max(0, location.speed)
let filteredSpeed = speedFilter.update(measurement: rawSpeed)
Tuning the filter:
- Higher
processNoise→ filter trusts speed changes more (less smoothing, faster response) - Higher
measurementNoise→ filter trusts GPS less (more smoothing, slower response) - For walking:
processNoise = 0.05,measurementNoise = 0.3(walking speed changes slowly) - For driving:
processNoise = 0.5,measurementNoise = 0.5(speed changes more frequently)
11.2 Frequency-domain analysis (FFT) on accelerometer data
The walking frequency (~1.4–2.5 Hz) is distinct from driving vibration frequencies (engine ~10–100 Hz, road rumble ~5–20 Hz). A Fast Fourier Transform on the accelerometer magnitude signal can identify these frequency bands and provide a much cleaner classifier.
import Accelerate
func dominantFrequency(samples: [Double], sampleRate: Double) -> Double {
let n = samples.count
let log2n = vDSP_Length(log2(Double(n)))
guard let fftSetup = vDSP_create_fftsetupD(log2n, FFTRadix(kFFTRadix2)) else {
return 0
}
defer { vDSP_destroy_fftsetupD(fftSetup) }
// Set up complex buffer
var realParts = samples
var imagParts = [Double](repeating: 0, count: n)
var splitComplex = DSPDoubleSplitComplex(
realp: &realParts,
imagp: &imagParts
)
// Execute FFT
vDSP_fft_zipD(fftSetup, &splitComplex, 1, log2n, FFTDirection(kFFTDirection_Forward))
// Compute magnitudes
var magnitudes = [Double](repeating: 0, count: n / 2)
vDSP_zvmagsD(&splitComplex, 1, &magnitudes, 1, vDSP_Length(n / 2))
// Find peak frequency (excluding DC component at index 0)
let peakIndex = magnitudes[1...].enumerated().max(by: { $0.element < $1.element })?.offset ?? 0
let peakFrequency = Double(peakIndex + 1) * sampleRate / Double(n)
return peakFrequency
}
// Walking peak: 1.4–2.5 Hz
// Driving peak: often 10–80 Hz from engine/road
// If peakFrequency is between 1.0 and 3.0: strong walking indicator
This approach is more computationally expensive but dramatically reduces false positives from bumpy roads. The Accelerate framework's vDSP FFT runs efficiently on the iPhone's NEON SIMD unit.
11.3 Hysteresis to prevent rapid mode-switching
Without hysteresis, a user stopping at a red light can trigger multiple walking↔driving transitions per second as GPS fluctuates around the threshold. Hysteresis requires that a new classification be sustained for a minimum duration before committing to it:
class HystereticActivityFilter {
private var currentType: ActivityType = .unknown
private var pendingType: ActivityType = .unknown
private var pendingStartTime: Date?
/// Minimum duration (seconds) a new activity must be consistently detected
/// before we commit to it.
let hysteresisWindow: TimeInterval
init(hysteresisWindow: TimeInterval = 5.0) {
self.hysteresisWindow = hysteresisWindow
}
/// Feed in a new raw classification. Returns the stable classification.
func filter(rawType: ActivityType) -> ActivityType {
let now = Date()
if rawType == currentType {
// Consistent with current state — reset pending
pendingType = rawType
pendingStartTime = nil
return currentType
}
if rawType == pendingType {
// Same as pending — check if it's been long enough
if let startTime = pendingStartTime,
now.timeIntervalSince(startTime) >= hysteresisWindow {
currentType = rawType
pendingType = rawType
pendingStartTime = nil
}
} else {
// New type — start the hysteresis timer
pendingType = rawType
pendingStartTime = now
}
return currentType
}
}
// Usage
let hystFilter = HystereticActivityFilter(hysteresisWindow: 5.0)
detector.onActivityDetected = { result in
let stableType = hystFilter.filter(rawType: result.type)
print("Stable activity: \(stableType)")
}
A 5-second window is appropriate for most applications. For driving mode detection specifically (where a false disable while stopped at a light is very disruptive), 10–15 seconds is more comfortable.
11.4 Machine learning with Create ML and CoreML
Apple's Create ML (Create ML.app in Xcode) includes an Activity Classifier template that accepts time-series sensor data and produces a CoreML model you can bundle into your app.
Data preparation:
Dataset structure:
data/
walking/
session_001.csv (timestamp, ax, ay, az, gx, gy, gz)
session_002.csv
...
driving/
session_001.csv
...
running/
session_001.csv
...
Each CSV row: a timestamp plus 6 columns of accelerometer and gyroscope data at a fixed sample rate (typically 50 Hz).
Training in Create ML:
- Open Create ML app in Xcode
- Create new Activity Classifier project
- Set prediction window size: 50 samples (1 second at 50 Hz)
- Drag in your labelled data folders
- Train — Create ML automatically extracts features (mean, variance, FFT peaks, etc.)
- Export the
.mlmodelfile
Using the model in your app:
import CoreML
// The generated class name comes from your Create ML project name
let model = try! ActivityClassifier(configuration: MLModelConfiguration())
// Collect 50 samples (1 second at 50 Hz) into a feature provider
// Create ML expects a MLMultiArray with shape [window, channels]
let windowSize = 50
var accelWindow: [[Double]] = [] // collect samples...
// Create MLMultiArray
let inputArray = try! MLMultiArray(
shape: [NSNumber(value: windowSize), 6], // 6 channels: ax,ay,az,gx,gy,gz
dataType: .double
)
// Fill the array with your window of samples
for (i, sample) in accelWindow.enumerated() {
for (j, value) in sample.enumerated() {
inputArray[[NSNumber(value: i), NSNumber(value: j)]] = NSNumber(value: value)
}
}
// Run inference
let input = ActivityClassifierInput(features: inputArray, stateIn: model.stateIn)
let output = try! model.prediction(input: input)
print("Predicted activity: \(output.label)")
print("Probabilities: \(output.labelProbability)")
The CoreML model runs at under 1ms per inference on modern iPhones using the Neural Engine. This approach can achieve >95% accuracy on the walking/driving distinction across a diverse population when trained on sufficient data (>1000 labelled sessions per class).
12. Privacy, Permissions, and App Store Compliance
Required permission descriptions
At minimum, your Info.plist must include:
<!-- Motion data — required for CMMotionActivityManager, CMPedometer, CMMotionManager -->
<key>NSMotionUsageDescription</key>
<string>App name uses motion data to detect whether you are walking or driving,
adapting navigation guidance to your current mode of transport.</string>
<!-- Location — required for CLLocationManager -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>App name uses your location while open to provide speed-aware activity
detection and accurate journey tracking.</string>
If you use requestAlwaysAuthorization() (for background detection):
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Background location access allows App name to automatically start and stop
journey logging without requiring the app to be open.</string>
What data is collected and how to explain it to users
Apple's App Privacy framework requires you to declare in App Store Connect:
- Precise location: Yes, if you use
kCLLocationAccuracyBest - Location (not precise): Yes, if you use coarser accuracy
- Motion & Fitness: Yes, if you use
CMMotionActivityManagerorCMPedometer - Usage data: Depends on whether you log activity to your own servers
Best practice: Do not send raw sensor data to your servers. Process everything on-device. Only transmit derived labels (e.g., "trip from A to B, 15 minutes driving") rather than raw GPS tracks or accelerometer data.
App Store review considerations
Apple's reviewer guidelines (4.1, 5.1.1) require that location and motion use be "directly relevant to the core functionality" of the app. Activity detection to switch navigation modes is clearly justified. Using motion data purely for advertising targeting would not be approved.
The NSMotionUsageDescription string is shown to the user in the system permission prompt. Make it clear and honest — vague strings like "Used for app functionality" will result in App Store rejection.
13. Testing Strategy
Unit testing the fusion classifier
The fuseSignals function is a pure function (no side effects, no I/O) and is easily unit-tested:
import XCTest
@testable import YourApp
class ActivityDetectorTests: XCTestCase {
var detector: ActivityDetector!
override func setUp() {
detector = ActivityDetector()
}
func testHighwayDrivingDetected() {
let (type, confidence) = detector.fuseSignals(
speed: 30.0, // 108 km/h
stepFrequency: 0.0,
accelVariance: 0.008
)
XCTAssertEqual(type, .driving)
XCTAssertEqual(confidence, "high")
}
func testNormalWalkingDetected() {
let (type, confidence) = detector.fuseSignals(
speed: 1.5,
stepFrequency: 1.7,
accelVariance: 0.045
)
XCTAssertEqual(type, .walking)
XCTAssertEqual(confidence, "high")
}
func testTrafficJamIsAmbiguous() {
let (_, confidence) = detector.fuseSignals(
speed: 0.8, // Could be walk or very slow drive
stepFrequency: 0.0,
accelVariance: 0.015
)
XCTAssertEqual(confidence, "low")
}
func testGPSUnavailableStillClassifiesWalking() {
let (type, confidence) = detector.fuseSignals(
speed: -1.0, // GPS invalid
stepFrequency: 1.6,
accelVariance: 0.040
)
XCTAssertEqual(type, .walking)
XCTAssertEqual(confidence, "medium") // Only 2 signals agree, so medium not high
}
func testRunningDetected() {
let (type, _) = detector.fuseSignals(
speed: 4.0,
stepFrequency: 3.1,
accelVariance: 0.180
)
XCTAssertEqual(type, .running)
}
}
Integration testing on a real device
Simulators cannot simulate motion sensor data in a meaningful way for activity detection. All integration testing must be done on physical iPhones. Recommended test procedure:
- Walking test: Walk around a building exterior for 2 minutes; verify consistent walking detection with high confidence.
- Driving test: Drive on a suburban road for 5 minutes including stops at traffic lights; verify driving detection survives the stops.
- Transition test: Walk to a car, get in, start driving; measure transition detection latency.
- GPS-denied test: Walk through a shopping centre or car park; verify fallback to motion-only classification.
- Pocket vs hand test: Repeat walking test with phone in various positions; verify orientation invariance.
Using simulated GPS data for repeatable tests
For CI pipelines, you can inject GPX routes as simulated location data in the iOS Simulator:
Xcode → Debug → Simulate Location → Add GPX File to Project
Or inject programmatically using XCTest:
// In a UI test target
let app = XCUIApplication()
app.launchArguments.append("-simulateLocation")
app.launchArguments.append("walkingRoute.gpx")
14. Broader Perspectives: How Other Platforms Approach This
Android — Activity Recognition API
Google's Activity Recognition API (part of Google Play Services) offers a similar high-level interface:
val client = ActivityRecognition.getClient(context)
val intent = Intent(context, ActivityTransitionReceiver::class.java)
val transitions = listOf(
ActivityTransition.Builder()
.setActivityType(DetectedActivity.WALKING)
.setActivityTransitionType(ActivityTransition.ACTIVITY_TRANSITION_ENTER)
.build()
)
val request = ActivityTransitionRequest(transitions)
client.requestActivityTransitionUpdates(request, pendingIntent)
Google classifies into: IN_VEHICLE, ON_BICYCLE, ON_FOOT, RUNNING, WALKING, STILL, TILTING, UNKNOWN. Like Apple, Google uses a combination of accelerometer, gyroscope, and GPS.
Key difference: Google's classifier runs partly server-side on older Android devices (to compensate for less capable hardware), introducing a network dependency that Apple avoids by running everything on the M-series coprocessor.
Wear OS / Apple Watch
Apple Watch adds significant discriminating capability:
- Wrist accelerometer directly measures arm swing (a key walking signal) rather than relying on in-pocket/in-bag phone motion.
- Heart rate sensor distinguishes exercise walking (elevated HR) from casual walking.
- Optical heart rate correlation with step frequency provides a cleaner cadence signal than the phone accelerometer alone.
- Gyroscope can distinguish walking arm swing from car passenger hand-arm motion.
The WKInterfaceController and CMMotionManager on watchOS follow the same APIs as iOS, with the benefit of more reliable placement (wrist vs arbitrary body location).
Dedicated activity trackers (Garmin, Fitbit)
Purpose-built fitness trackers solve the placement problem by guaranteeing wrist placement. They use simpler algorithms (step counting + threshold on cadence and GPS speed) because the accelerometer position is consistent. Without GPS (Fitbit Charge), pure accelerometer step counting has ~95% accuracy for walking vs stationary.
Research approaches: beyond rule-based classifiers
Academic literature (2015–2024) has explored several techniques beyond the rule-based approach described here:
Deep learning on raw sensor data. Convolutional neural networks applied directly to 2-second windows of raw accelerometer data (at 50 Hz) achieve 97–99% accuracy on the standard HAPT (Human Activity and Postural Transitions) benchmark dataset. The key advantage is automatic feature extraction — the network learns what features matter from data, eliminating hand-tuned thresholds.
Transformer-based models. Self-attention mechanisms applied to IMU time series outperform LSTMs on ambiguous transitions (slow run vs fast walk) by capturing long-range temporal dependencies.
Federated learning. Training activity classifiers across millions of devices without centralising sensitive sensor data. Apple's Core ML and on-device training infrastructure supports this pattern.
Multi-modal fusion with neural networks. Rather than rule-based voting, a fusion network takes all sensor streams as input and learns the optimal fusion weights end-to-end. This handles correlated signals and non-linear interactions that rule-based systems miss.
15. References and Further Reading
Apple Developer Documentation
CMMotionActivityManager— https://developer.apple.com/documentation/coremotion/cmmotionactivitymanagerCMPedometer— https://developer.apple.com/documentation/coremotion/cmpedometerCMMotionManager— https://developer.apple.com/documentation/coremotion/cmmotionmanagerCLLocationManager— https://developer.apple.com/documentation/corelocation/cllocationmanagerCLLocation.speedandspeedAccuracy— https://developer.apple.com/documentation/corelocation/cllocation/speed- Create ML Activity Classifier — https://developer.apple.com/documentation/createml/creating_an_activity_classifier_model
WWDC Sessions
- WWDC 2014 Session 612 — "Core Motion" — Introduced CMMotionActivityManager and the M7 coprocessor
- WWDC 2019 Session 705 — "Advances in Core Motion and Motion Tracking"
- WWDC 2021 Session 10054 — "Create ML for activity classification"
- WWDC 2023 Session 10044 — "Explore improvements in CoreMotion"
Academic Papers
- Kwapisz, J.R., Weiss, G.M., & Moore, S.A. (2011). "Activity recognition using cell phone accelerometers." ACM SIGKDD Explorations. — One of the foundational papers on smartphone-based activity recognition.
- Anjum, A., & Ilyas, M.U. (2013). "Activity recognition using smartphone sensors." IEEE CCNC. — Established the threshold ranges for walking vs driving that inform implementations like this one.
- Chen, Z., et al. (2021). "Deep learning for sensor-based activity recognition." Information Fusion. — Survey of neural network approaches.
- Demrozi, F., et al. (2020). "Human activity recognition using inertial, physiological and environmental sensors." IEEE Access.
Open Datasets for Training and Benchmarking
- WISDM (Wireless Sensor Data Mining Lab) — labelled accelerometer data for walking, jogging, sitting, standing, upstairs, downstairs. http://www.cis.fordham.edu/wisdm/dataset.php
- UCI HAPT (Human Activity and Postural Transitions) — smartphone sensor data at 50 Hz with 12 activity labels including walking, walking upstairs, sitting, standing. UCI Machine Learning Repository.
- ExtraSensory — 300,000+ minutes of labelled data from real-world free-living settings. http://extrasensory.ucsd.edu
Related iOS APIs
HKWorkoutSession— HealthKit workout tracking integrated with activity detectionCMFallDetectionManager— fall detection using activity context (requires Apple Watch Ultra/Series 4+)CLBeaconRegion— indoor positioning using Bluetooth beacons (complements GPS-denied detection)WKExtendedRuntimeSession— watchOS background session for continuous activity monitoring
Document version 1.0 — Last updated June 2026 Swift version: 5.9+ | iOS deployment target: iOS 16+ | Xcode 15+
autocmds
Source: autocmds · updated 2026-06-11 · 🔒 secret gist
Synced verbatim from gist.github.com/bl9.
-- Tabline that shows ws_name tab-local variable when set, else falls back to
-- the buffer name. Used by LoadWorkspace to label tabs by folder name.
_G.WsTabline = function()
local s = ""
local ntabs = vim.fn.tabpagenr("$")
for i = 1, ntabs do
local label = vim.fn.gettabvar(i, "ws_name", "")
if label == "" then
local bufs = vim.fn.tabpagebuflist(i)
label = vim.fn.fnamemodify(vim.fn.bufname(bufs[1]), ":t")
if label == "" then label = "[No Name]" end
end
if i == vim.fn.tabpagenr() then
s = s .. "%#TabLineSel# " .. i .. ":" .. label .. " "
else
s = s .. "%#TabLine# " .. i .. ":" .. label .. " "
end
end
return s .. "%#TabLineFill#"
end
vim.opt.tabline = "%!v:lua.WsTabline()"
vim.opt.showtabline = 2
-- Cycle tabs with Alt-l / Alt-h
vim.keymap.set("n", "<A-l>", "<cmd>tabnext<cr>", { desc = "Next tab" })
vim.keymap.set("n", "<A-h>", "<cmd>tabprev<cr>", { desc = "Prev tab" })
-- Fuzzy-pick a tab with <leader>T (Alt+t unreliable on macOS)
vim.keymap.set("n", "<leader>T", function()
local tabs = {}
for i = 1, vim.fn.tabpagenr("$") do
local name = vim.fn.gettabvar(i, "ws_name", "")
if name == "" then
local bufs = vim.fn.tabpagebuflist(i)
name = vim.fn.fnamemodify(vim.fn.bufname(bufs[1]), ":t")
if name == "" then name = "[No Name]" end
end
table.insert(tabs, { text = i .. ": " .. name, idx = i })
end
Snacks.picker.pick({
title = "Tabs",
items = tabs,
format = function(item) return { { item.text } } end,
confirm = function(picker, item)
picker:close()
if item then vim.cmd("tabn " .. item.idx) end
end,
})
end, { desc = "Pick tab" })
local function open_vscode_workspace(workspace_file)
local f = io.open(workspace_file, "r")
if not f then
vim.notify("Could not open workspace file: " .. workspace_file, vim.log.levels.ERROR)
return
end
local content = f:read("*a")
f:close()
local ok, ws = pcall(vim.json.decode, content)
if not ok or not ws.folders then
vim.notify("Failed to parse workspace file", vim.log.levels.ERROR)
return
end
local ws_dir = vim.fn.fnamemodify(workspace_file, ":p:h")
local paths = {}
for _, folder in ipairs(ws.folders) do
local path = folder.path
if not vim.startswith(path, "/") then
path = vim.fn.simplify(ws_dir .. "/" .. path)
end
if vim.fn.isdirectory(path) == 0 then
vim.notify("Skipping missing directory: " .. path, vim.log.levels.WARN)
else
table.insert(paths, path)
end
end
if #paths == 0 then return end
for i, path in ipairs(paths) do
local name = vim.fn.fnamemodify(path, ":t")
if i == 1 then
vim.cmd("tcd " .. vim.fn.fnameescape(path))
else
vim.cmd("tabnew")
vim.cmd("tcd " .. vim.fn.fnameescape(path))
end
vim.t.ws_name = name
end
-- Open tab 1's explorer immediately; lazily open the rest on first TabEnter.
-- Opening multiple Snacks pickers in the same event loop causes BufWinEnter
-- nesting (E218) due to snacks' internal nvim_win_set_buf autocmd cascade.
vim.cmd("tabfirst")
local pending = {}
for i = 2, #paths do
pending[i] = paths[i]
end
local aug = vim.api.nvim_create_augroup("LoadWorkspaceExplorers", { clear = true })
vim.api.nvim_create_autocmd("TabEnter", {
group = aug,
callback = function()
local tabnr = vim.fn.tabpagenr()
if pending[tabnr] then
local p = pending[tabnr]
pending[tabnr] = nil
vim.schedule(function()
Snacks.explorer.open({ cwd = p })
end)
-- Clean up autocmd once all tabs have been visited
if vim.tbl_isempty(pending) then
vim.api.nvim_del_augroup_by_id(aug)
end
end
end,
})
-- Open the first tab's explorer last so it ends up focused
vim.schedule(function()
Snacks.explorer.open({ cwd = paths[1] })
end)
end
vim.api.nvim_create_user_command("LoadWorkspace", function(opts)
open_vscode_workspace(opts.args)
end, { nargs = 1, complete = "file" })
Source: skull ·
skull.md· updated 2026-06-03 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
I want you to create a thorough product and technical plan for a Go-based command-line tool that manages AI-agent “skills” hosted in GitHub repositories.
The tool should behave like a terminal-native extension manager for AI coding tools, similar in spirit to VS Code extensions, but focused only on GitHub-hosted skill directories.
Core goal: Build a single Go executable that allows users to register GitHub skill sources, discover valid skills from those sources, browse/search them, install them, validate them, detect updates, preview changes, update them, remove them, and use shell autocomplete and a polished TUI.
Important constraint: The user should install only one executable binary. The tool must not require users to install Git, SQLite, Node, Python, unzip, jq, or any external service. Any local storage must be embedded and created automatically by the executable.
Storage requirement: Do not use plain JSON as the primary internal store. Plan for a solid embedded local storage system suitable for many sources, many skills, autocomplete caching, discovery results, installed state, update status, file hashes, and TUI filtering. Prefer embedded SQLite unless there is a stronger reason to choose another embedded store. JSON may still be used for portable exports, lockfiles, or project-level configuration, but not as the main internal database.
GitHub-only scope: The tool should support GitHub only. It should not attempt to support GitLab, Bitbucket, generic Git, or marketplace payments in the initial design.
Discovery model: Do not scan all of GitHub. Do not scan entire repositories. Discovery must be explicit and bounded.
A user should register one or more GitHub skill sources. Each source should include:
- source name
- GitHub repo, for example owner/repo
- ref, branch, tag, or commit
- exact root path where skills are expected to live, for example skills/ or approved/skills/
Example: skill source add agensi github:agensi/skills@main:skills
The tool should discover only under configured source paths.
A single repo may have multiple source paths, for example:
- github:agensi/skills@main:skills
- github:agensi/skills@main:experimental
- github:company/ai-skills@main:approved
Discovery should support:
- source add/list/edit/remove/enable/disable/test
- discover all enabled sources
- refresh catalog and update status
- autocomplete based on discovered catalog
- TUI browsing based on discovered catalog
Discovery validation: The tool must verify that a configured source path is actually a skills root, not just a random GitHub directory.
A source path should be considered valid only if it contains a recognized skill index file or contains valid skill directories according to strict rules.
Preferred model: A source root may contain a skills index file, for example skills.index.json, listing skill directories and metadata.
Fallback model: If no index exists, discovery may inspect direct child directories only, or a carefully bounded max depth. It must not recursively scan unlimited directories. It should detect valid skill directories by the presence of required files.
Skill validation: A valid skill directory should include at minimum:
- skill.json manifest
- SKILL.md entry file
The tool must validate both during discovery and again before install/update.
Validation should reject or warn on:
- missing skill.json
- missing SKILL.md
- invalid manifest schema
- manifest name mismatch
- invalid or duplicate skill names
- paths outside the configured source root
- path traversal such as ../
- absolute paths
- symlinks unless explicitly allowed
- Git submodules unless explicitly allowed
- hidden/sensitive files such as .env, *.pem, *.key
- unexpected files not included by the manifest allowlist
- huge files over configured size limits
- binary files unless explicitly allowed
- directories that are near the source path but are not actually skills
The plan should include how invalid directories are reported to the user during discovery without breaking discovery for the whole source.
Installation model: The installer should download only the selected skill files from GitHub. It should not clone the whole repo. It should use GitHub APIs to fetch the tree and blobs/content for only the relevant paths.
Before installing, the tool should show an install preview:
- skill name
- source
- resolved commit
- target
- install path
- files to install
- warnings
- validation result
Install should be atomic:
- download to temporary location
- validate content
- backup existing installation
- swap into final target directory
- write local metadata
- support rollback
Target model: The tool should support installing skills into different AI-agent targets, for example:
- Claude Code
- OpenClaw
- Codex CLI
- Cursor, if applicable
The plan should define a clean target abstraction, because a skill may be installed for one target and not another.
Revision and update model: Do not rely on GitHub Releases. Do not assume skills have formal versions. Skills may only be Markdown files, and a single text change can materially change behavior.
The tool should track:
- optional human version from skill.json
- GitHub ref being tracked, for example main
- resolved installed commit
- latest resolved commit
- content hash of the actual skill files
- file-level hashes
- changed files
Update detection should be based primarily on content changes in the actual skill files, not only on repo commit changes and not only on manifest version changes.
The tool should clearly show:
- current version and latest version if available
- current revision and latest revision
- whether content changed
- which files changed
- whether version changed
- whether the skill is pinned
- whether the upstream source disappeared
- whether local files were modified
The user must be able to run:
- skill check
- skill outdated
- skill diff
- skill update
- skill update --all
- skill pin
- skill rollback
Updates should never be applied silently by default. The user should see what changed before applying, especially for Markdown content changes.
Autocomplete: The CLI should provide excellent shell autocomplete for Bash, Zsh, Fish, and PowerShell.
Autocomplete must not call GitHub directly. It should query the local embedded database/cache only.
Autocomplete should support:
- install: discovered but not installed skills
- update: installed skills with updates available
- remove: installed skills
- source commands: configured source names
- target names
- tags or filters where useful
TUI: The tool should include a polished terminal UI written in Go.
The TUI should include screens such as:
- Store / Catalog
- Installed
- Updates
- Sources
- Skill details
- Diff / change preview
- Doctor / health check
The TUI should allow:
- search
- filtering by source, target, installed status, update status, tags
- install/update/remove actions
- viewing validation warnings
- viewing changed files
- refreshing sources
- adding/editing sources
CLI and CI: The tool should also work well in non-interactive mode.
Support:
- --json output for machine-readable commands
- --ci mode
- deterministic exit codes
- no prompts in CI mode
- sync from project lockfile
- check for drift or updates
- project-level source and lock files for teams
Project-level files: The plan should include optional project files such as:
- skills.sources.json
- skills.lock
These files should be portable and reviewable, but the main internal local store should still be embedded storage.
Doctor/repair: The tool should include a doctor command that can detect and optionally fix:
- missing target directories
- broken installed metadata
- stale cache
- missing source paths
- disabled or unreachable sources
- invalid installed skills
- local modifications
- missing upstream skills
- autocomplete cache issues
Security and trust: The plan should include safety and trust rules:
- no arbitrary script execution during install
- no implicit executable file installation unless allowed
- clear validation before install/update
- content hashes
- provenance metadata for every installed skill
- backup and rollback
- transparent diff before update
- support for GitHub token via env var or stored login
- no GitHub calls during autocomplete
Expected output: Produce a complete product and technical plan, but do not write implementation code.
Structure the answer with:
- Product summary
- Primary workflows
- Source and discovery model
- Skill validation model
- Storage architecture
- Update/revision tracking model
- CLI command design
- TUI design
- Autocomplete design
- Install/update/remove lifecycle
- CI and lockfile behavior
- Data model at a conceptual level
- Failure modes and how the tool should report them
- MVP scope
- Future enhancements
- Open design questions
Keep the plan practical, implementation-oriented, and opinionated. Avoid vague marketplace language. The product should do one thing very well: manage GitHub-hosted AI skills from explicitly configured GitHub source paths.
tez
Source: tez · updated 2026-06-03 · 🔒 secret gist
Synced verbatim from gist.github.com/bl9.
prompt.txt
# Prompt: Convert Academic Paper / PDF to Clean Readable Text
## Purpose
Use this prompt to extract and clean the full body text from an academic paper or any structured PDF document, producing a plain-text file optimized for reading or text-to-speech (TTS) playback.
---
## The Prompt
I have attached a PDF of an academic paper [or: a structured document]. Please extract and convert its full content into a clean plain-text (.txt) file suitable for reading or text-to-speech playback.
Follow these instructions precisely:
WHAT TO INCLUDE
- The paper title (at the top)
- All section headings and sub-headings, preserved exactly as written
- All body text, in the correct reading order, flowing naturally as prose
- Numbered or bulleted lists from the body text, reformatted as clean plain text
- Footnotes or endnotes that contain substantive content (not just citations)
- The Acknowledgements section, if present
WHAT TO REMOVE — completely omit the following:
- Author names, institutional affiliations, and email addresses
- The abstract label is fine to keep, but remove everything else on the title page that is not the title or abstract text
- All copyright notices, license text, and permission statements
- Conference or journal metadata (venue name, date, location, proceedings info)
- DOI links, ACM/ISBN numbers, and any other publication identifiers
- All page numbers (whether inline, in headers, or footers)
- All figure captions (lines starting with "Figure N:" or "Fig. N:")
- All table captions (lines starting with "Table N:")
- All inline citation brackets (e.g. [1], [24], [3, 14]) — strip them silently from the text
- The entire References / Bibliography section (the last section listing all cited works)
- Any diagram labels, ASCII-art figures, or layout artifacts from multi-column formatting — these appear as isolated words or short tokens that belong to a figure or diagram, not to the prose, and have no meaning out of context
- Running headers or footers that repeat the paper title or section name on every page
FORMATTING RULES
- Output pure plain text — no Markdown, no HTML, no bold, no italics
- Separate major sections with a single blank line above the section heading
- Use two blank lines between major top-level sections
- Preserve paragraph breaks as a single blank line
- Re-join hyphenated words that were split across a line break (e.g. "architec-\nture" becomes "architecture")
- For two-column PDF layouts: reconstruct the correct reading order — finish the left column completely before starting the right column, per page
- Do not add any commentary, preamble, or summary — output only the cleaned document text
OUTPUT Deliver the result as a downloadable .txt file named after the paper title (use underscores for spaces).
---
## Notes on Usage
- **Multi-column PDFs** are the trickiest case. If the output looks scrambled (left and right columns interleaved), explicitly add to the prompt: *"This PDF uses a two-column academic layout. Reconstruct each page left-column-first, then right-column, before moving to the next page."*
- **Scanned PDFs** (no text layer) require OCR first. Add: *"This PDF appears to be a scanned image. Please use OCR to extract the text before cleaning."*
- **If citations in-text are important to keep** (e.g. you want to know which claims are cited), replace the "remove inline citation brackets" instruction with: *"Keep inline citations but rewrite them as readable callouts, e.g. [24] → (ref 24)."*
- **For books or long reports** with many chapters, add: *"Process and output one chapter at a time, clearly labeled, so the output stays manageable."*
- **For non-English papers**, add: *"The paper is written in [language]. Preserve the original language exactly — do not translate."*
tez.txt
Apache Tez: A Unifying Framework for Modeling and Building Data Processing Applications
ABSTRACT
The broad success of Hadoop has led to a fast-evolving and diverse ecosystem of application engines that are building upon the YARN resource management layer. The open-source implementation of MapReduce is being slowly replaced by a collection of engines dedicated to specific verticals. This has led to growing fragmentation and repeated efforts—with each new vertical engine re-implementing fundamental features (e.g. fault-tolerance, security, stragglers mitigation, etc.) from scratch.
In this paper, we introduce Apache Tez, an open-source framework designed to build data-flow driven processing runtimes. Tez provides a scaffolding and library components that can be used to quickly build scalable and efficient data-flow centric engines. Central to our design is fostering component re-use, without hindering customizability of the performance-critical data plane. This is in fact the key differentiator with respect to the previous generation of systems (e.g. Dryad, MapReduce) and even emerging ones (e.g. Spark), that provided and mandated a fixed data plane implementation. Furthermore, Tez provides native support to build runtime optimizations, such as dynamic partition pruning for Hive.
Tez is deployed at Yahoo!, Microsoft Azure, LinkedIn and numerous Hortonworks customer sites, and a growing number of engines are being integrated with it. This confirms our intuition that most of the popular vertical engines can leverage a core set of building blocks. We complement qualitative accounts of real-world adoption with quantitative experimental evidence that Tez-based implementations of Hive, Pig, Spark, and Cascading on YARN outperform their original YARN implementation on popular benchmarks (TPC-DS, TPC-H) and production workloads.
1. INTRODUCTION
Large scale data analytics, once an exotic technology leveraged exclusively by large web-companies, is nowadays available and indispensable for most modern organizations. This broader user base has fostered an explosion of interest in this area, and led to a flourishing BigData industry. In this paper, we use the lens of the Hadoop ecosystem to describe industry-wide trends, as this provides the ideal context for introducing our system: Apache Tez.
We postpone to Section 8 a broader comparison with the related projects like Dryad, Nephele, Hyracks etc., which undeniably served as an inspiration and sometimes the blueprint for the design of Tez.
Hadoop, which was initially designed as a single-purpose system (to run MapReduce jobs to build a web index), has evolved into a catch-all data analytics platform. The first phase of this journey consisted of several efforts proposing higher level abstractions atop MapReduce, examples of which are Hive, Pig, and Cascading. This sped-up the adoption of Hadoop, but led to inefficiencies and poor performance. These limitations and the pressure towards more flexibility and efficiency led to the refactoring of Hadoop into a general purpose, OS-like resource management layer, namely YARN, and an application framework layer allowing for arbitrary execution engines. This enabled different applications to share a cluster, and made MapReduce just another application in the Hadoop ecosystem. Important examples of applications that break-free of the MapReduce model (and runtime) are Spark, Impala and Flink. This has accelerated innovation, but also led to a less efficient ecosystem, where common functionalities were being replicated across frameworks. For example, MapReduce and Spark independently developed mechanisms to implement delay scheduling.
In this paper, we introduce Tez, a project that embraces the architectural shift to YARN, and pushes it further, by proposing a reusable, flexible and extensible scaffolding that can support arbitrary data-flow oriented frameworks, while avoiding replicated functionalities. Tez APIs allow frameworks to clearly model the logical and physical semantics of their data flow graphs, with minimal code. It is important to clarify that Tez is a library to build data-flow based runtimes/engines and not an engine by itself—for example, the Hive runtime engine for Hadoop has been rewritten in version 0.13 to use Tez libraries.
Tez makes the following key contributions:
1. Allows users to model computation as a DAG (Directed-Acyclic-Graph) — akin to Dryad/Nephele/Hyracks. The novelty lies in a finer grained decomposition of the classical notions of vertex and edge, that delivers greater control and customization of the data plane.
2. Exposes APIs to dynamically evolve the (finer grained) DAG definition. This enables sophisticated runtime query optimizations, such as pruning data partitions, based on online information.
3. Provides a scalable and efficient implementation of state-of-the-art features, e.g., YARN-compatible security, data-locality awareness, resource-reuse, fault-tolerance and speculation.
4. Provides the opportunity for framework writers and researchers to innovate quickly and create real-world impact by providing experimentation support via pluggable APIs, and an open-source community to learn about the project and contribute back to it.
What sets Tez aside from many alternative proposals of 'unification frameworks' is: 1) proven flexibility and dynamic adaptation, 2) attention to operational concerns (production readiness), and 3) a community-driven effort to embed Tez in multiple existing domain-specific engines.
This is proven by the Tez support of MapReduce, Hive, Pig, Spark, Flink, Cascading, and Scalding, and its adoption in production data-processing clusters at Yahoo, Microsoft Azure, LinkedIn as well as several other organizations using the Hortonworks Data Platform. Beyond discussing the broad practical adoption of Tez, we demonstrate its competence to support Hive, Pig, and Spark, by running standard benchmarks such as TPC-H and TPC-DS, and production workloads from Yahoo!.
The rest of this paper is organized as follows: Section 2 provides some more historical context, and rationale for the design of Tez, while Section 3 introduces the architecture of Tez. Section 4 discusses the implementation of Tez, and highlights pragmatic considerations on efficiency and production-readiness. Section 5 and 6 are devoted to prove its practical relevance, by presenting real-world applications, and a broad experimental evaluation. We conclude by discussing future and related work in Sections 7 and 8, and conclude in Section 9.
2. BACKGROUND AND RATIONALE
To understand the motivation and rationale behind Tez, we must first start by providing some background on terminology, and a historical context of distributed computation in Hadoop. The reader not interested in this historical and motivational perspective is invited to continue to Section 3, where we dive into the technical aspects of the Tez architecture.
Terminology. We have used graph terminology so far, appealing to the reader's intuitions. We now introduce our terminology more precisely:
DAG: Directed Acyclic Graph representing the structure of a data processing workflow. Data flows in the direction of the edges.
Vertex: Represents a logical step of processing. A processing step transforms data by applying application-supplied code to filter, or modify the data.
Logical DAG: A logical DAG is comprised of a set of vertices, where each vertex represents a specific step of the computation.
Task: Represents a unit of work in a vertex. In distributed processing, the logical work represented by a single vertex is physically executed as a set of tasks running on potentially multiple machines of the cluster. Each task is an instantiation of the vertex, that processes a subset (or partition) of the input data for that vertex.
Physical DAG: A physical DAG comprises of the set of tasks that are produced by expanding the vertices of a logical DAG into their constituent tasks.
Edge: Represents movement of data between producers and consumers. An edge between vertices of a logical DAG represents the logical data dependency between them. An edge between tasks in the physical DAG represents data transfers between the tasks.
This applies to problems in which different steps can be partitioned into smaller pieces that can be processed in parallel. Typically, the partitioning aligns with distributed shards of data and tries to co-locate processing with its data, thus reducing the cost of computation.
Hadoop 1 and Hadoop 2 (YARN). Hadoop started off as a single monolithic software stack where MapReduce was the only execution engine. All manners of data processing had to translate their logic into a single MapReduce job or a series of MapReduce jobs. MapReduce was also responsible for cluster resource management and resource allocation. Hadoop 2 is the current generation of Hadoop which separates these responsibilities by creating a general purpose resource management layer named YARN. This de-couples applications from the core Hadoop platform and allows multiple application types to execute in a Hadoop cluster in addition to MapReduce. There are many domain-specific applications like Apache Hive for SQL-like data processing, Apache Pig for ETL scripting or Cascading for writing data processing applications in Java, which were earlier restricted to relying on MapReduce to execute their custom logic. These applications can now have a more customized implementation of their logic by running natively on YARN.
While specialization can deliver performance advantages, there is a substantial opportunity to create a common set of building blocks that can be used by these applications for their customized implementation on YARN. We try to seize that opportunity with Tez, as discussed next. An analysis of popular Hadoop ecosystem applications like Apache Hive, Pig, Spark etc. suggests that there are shared features that all of them need. These include negotiating resources from YARN to run application's tasks, handling security within the clusters, recovering from hardware failures, publishing metrics and statistics etc. A lot of this is highly specialized, hard to develop infrastructure that everyone has to replicate when building from scratch. A common implementation makes it easier to write applications because it removes that burden from the application writers and lets them focus on the unique logic of their application.
Henceforth, unless otherwise specified, when we mention Hadoop we imply the Hadoop 2 compute stack with YARN as the underlying resource allocation layer; and by Hadoop ecosystem we imply the compute ecosystem consisting of open source and commercial projects running on YARN such as Apache Hive, Pig etc.
An effort to provide these common features requires the creation of a framework to express and model these workloads optimally. Then this model can be applied and executed on the YARN application framework via a shared substrate library. This rationalizes the following requirements for such a shared library, which we have highlighted by comparisons with MapReduce — a general purpose engine that has been forced to act as shared substrate until now.
Expressiveness. MapReduce has a simple modeling API for describing the computation by requiring all application algorithms to be translated into map and reduce functions. As observed by others before, this is too constraining, and a DAG-oriented model can more naturally capture a broader set of computations. Thus we define Tez's central model around DAGs of execution as well. Moreover, MapReduce also provides built-in semantics to the logic running in map/reduce steps and imposed a sorted & partitioned movement of data between map and reduce steps. These built-in semantics, ideal in some core use cases, could be pure overhead in many other scenarios and even undesirable in some. The observation here is the need for an API to describe the structure of arbitrary DAGs without adding unrelated semantics to that DAG structure.
Data-plane Customizability. Once the structure of distributed computation has been defined, there can be a variety of alternative implementations of the actual logic that executes in that structure. These could be algorithmic, e.g. different ways of partitioning the data or these could be related to using different hardware, e.g. using remote memory access (RDMA) where available. In the context of MapReduce, the built-in semantics of the engine makes such customizations difficult because they intrude in the implementation of the engine itself. Secondly, the monolithic structure of the tasks executing the MapReduce job on the cluster makes plugging in alternative implementations difficult. This motivates that data transformations and data movements that define the data plane need to be completely customizable. There is a need to be able to model different aspects of task execution in a manner that allows individual aspects of the execution, e.g. reading input, processing data etc. to be customized easily. Interviewing several members of the Hadoop community we confirmed that evolving existing engines (e.g., changing the shuffle behavior in MapReduce) is far from trivial.
While other frameworks already support a more general notion of DAGs, they share the same limitation of MapReduce, built-in semantics and implementations of the data-plane. With Tez we provide a lower level abstraction, that enables such semantics and specialized implementations to be added on top of a basic shared scaffolding.
Late-binding Runtime Optimizations. Applications need to make late-binding decisions on their data processing logic for performance. The algorithm, e.g. join strategies and scan mechanisms, could change based on dynamically observing data being read. Partition cardinality and work division could change as the application gets a better understanding of its data and environment. Hadoop clusters can be very dynamic in their usage and load characteristics. Users and jobs enter and exit the cluster continuously and have varying resource utilization. This makes it important for an application to determine its execution characteristics based on the current state of the cluster. We designed Tez to make this late-binding and on-line decision-making easier to implement, by enabling updates to key abstractions at runtime.
This concludes our overview of historical context and rationale for building Tez. We now turn to describing the high level architecture of Tez, and provide some insight into the key building blocks.
3. ARCHITECTURE
Apache Tez is designed and implemented with a focus on the issues discussed above, in summary: 1) expressiveness of the underlying model, 2) customizability of the data plane, and 3) facilitate runtime optimizations. Instead of building a general purpose execution engine, we realize the need for Tez to provide a unifying framework for creating purpose-built engines that customize data processing for their specific needs. Tez solves the common, yet hard problem of orchestrating and running a distributed data processing application on Hadoop and enables the application to focus on providing specific semantics and optimizations. There is a clear separation of concerns between the application layer and the Tez library layer. Apache Tez provides cluster resource negotiation, fault tolerance, resource elasticity, security, built-in performance optimizations and a shared library of ready to use components. The application provides custom application logic, custom data plane and specialized optimizations.
This leads to three key benefits: 1) amortized development costs (Hive and Pig completely rewrote their engines using the Tez libraries in about 6 months), 2) improved performance (we show in Section 6 up to 10x performance improvement while using Tez), and 3) enabling future pipelines that leverage multiple engines, to be run more efficiently because of a shared substrate.
Tez is composed of a set of core APIs that define the data processing and an orchestration framework to launch that on the cluster. Applications are expected to implement these APIs to provide the execution context to the orchestration framework. Its useful to think of Tez as a library to create a scaffolding representing the structure of the data flow, into which the application injects its custom logic (say operators) and data transfer code (say reading from remote machine disks). This design is both tactical and strategic. Long-term, this makes Tez remain application agnostic while in the short term, allows existing applications like Hive or Pig to leverage Tez without significant changes in their core operator pipelines. We will begin with describing the DAG API and Runtime API. These are the primary application facing interfaces used to describe the DAG structure of the application and the code to be executed at runtime. Next we explain support for applying runtime optimizations to the DAG via an event based control plane using VertexManagers and DataSourceInitializers. Finally, in Section 4 we describe the YARN based orchestration framework to execute the all of this on a Hadoop cluster. In particular, we will focus on the performance and production-readiness aspects of the implementation.
3.1 DAG API
The Tez DAG API is exposed to runtime engine builders as an expressive way to capture the structure of their computation in a concise way. The class of data processing application we focus on, are naturally represented as DAGs, where data proceeds from data sources towards data sinks, while being transformed in intermediate vertices. Tez focuses on acyclic graphs, and by assuming deterministic computation on the vertex and data routing on the edges, we enable re-execution based fault tolerance. Modeling computations as a DAG is not new but hitherto most systems have typically designed DAG APIs in the context of supporting a higher level engine. Tez is designed to model this data flow graph as the main focus. Using well-known concepts of vertices and edges the DAG API enables a clear and concise description of the structure of the computation.
Vertex. A vertex in the DAG API represents transformation of data and is one of the steps in processing the data. This is where the core application logic gets applied to the data. Hence a vertex must be configured with a user-provided processor class that defines the logic to be executed in each task. One 'vertex' in the DAG is often executed in parallel across a (possibly massive) number of parallel tasks. The definition of a vertex controls such parallelism. Parallelism is usually determined by the need to process data that is distributed across machines or by the need to divide a large operation into smaller pieces. The task parallelism of a vertex may be defined statically during DAG definition but is typically determined dynamically at runtime.
Edge. An edge in the graph represents the logical and physical aspects of data movement between producer and consumer vertices.
Connection Pattern: The logical aspect of an edge is the connection pattern between producer and consumer vertex tasks and their scheduling dependency. This enables the orchestration framework to route data from the output of the producer task to the correct input of the consumer task. This routing table must be specified by implementing a pluggable EdgeManagerPlugin API. There are 3 common connection patterns (one-to-one, broadcast, scatter-gather), that can be used to express most DAG connections and come built-in with the project. For cases where custom routing is needed, applications are allowed to define their own routing by providing their own implementation.
Transport Mechanism: The physical aspect of an edge is the storage or transport mechanism employed to move the data. This could be local-disk, or local/remote main-memory, etc. The actual data transfer operation of the edge is performed by a compatible pair of input and output classes that are specified for the edge. Compatibility is based on using the same data format and physical transport mechanisms. E.g. both operate on key-value pairs and operate on disks, or both operate on byte streams and use main memory. Tez comes with built-in inputs and outputs for common use cases.
Vertex parallelism and the edge properties can be used by Tez to expand the logical DAG to the real physical task execution DAG during execution.
Data Sources and Sinks. The DAG can be defined by creating vertices and connecting them via edges using the DAG API. Typically, the data flow will read initial input from some data sources and write final output to some data sinks. Data sources may be associated with a DataSourceInitializer that can be invoked at runtime to determine the optimal reading pattern for the initial input. E.g. in MapReduce parlance, this corresponds to 'split' calculation where a split is a shard of distributed data that is read by a map task. The initial split calculation for map tasks can be performed using an initializer that considers the data distribution, data locality and available compute capacity to determine the number of splits and the optimal size of each split. Similarly, data sinks may be associated with a DataSinkCommitter that is invoked at runtime to commit the final output. The definition of commit may vary with the output type but is guaranteed to be done once, and typically involves making the output visible to external observers after successful completion.
This manner of DAG assembly allows for pluggable and re-usable components. A common shared library of inputs and outputs can be re-used by different applications, thus only needing to supply the processor logic in a vertex. Conversely, the same DAG structure may be executed more optimally in a different hardware environment by replacing the inputs/outputs on the edges. Tez comes with an input/output library for data services built into Hadoop - HDFS and the YARN Shuffle Service. This enables Hadoop eco-system applications like Hive and Pig to quickly leverage Tez by implementing only their custom processors.
3.2 Runtime API
The DAG API defines the scaffolding structure of the data processing. The Runtime API is used to inject the actual application code that fills the scaffolding. Concretely, the Runtime API defines the interfaces to be implemented to create processor, input and output classes that are specified in the DAG above.
Inputs, Processor, Outputs. A vertex is a logical representation of a transformation step in the DAG. The actual transformations are applied by running tasks, for that vertex, on machines in the cluster. Tez defines each task as a composition of a set of inputs, a processor and a set of outputs (IPO). The processor is defined by the vertex for that task. The inputs are defined by the output classes of the incoming edges to that vertex. The outputs by the input classes of the outgoing edges from that vertex. This enables the processor to have a logical view of the processing, thus retaining the simplified programming model popularized in MapReduce. The inputs and outputs hide details like the data transport, partitioning of data and/or aggregation of distributed shards. The Runtime API is a thin wrapper to instantiate and interact with inputs, processors and outputs. After the IPO objects have been created, they are configured.
IPO Configuration. The framework configures IPOs via an opaque binary payload specified during DAG creation. This manner of binary payload configuration is a common theme to configure any application specific entity in Tez. This allows applications to instantiate their code using any mechanism of their choice. Not only can this be used for simple configuration but also for code injection. After configuration, the processor is presented with all its inputs and outputs and asked to run. Thereafter, it's up to the processor, inputs and outputs to cooperate with each other to complete the task. The framework interacts with them via a context object to send and receive events about completion, update progress, report errors etc.
Data Plane Agnostic. Tez specifies no data format and in fact, is not part of the data plane during DAG execution. The actual data transfer is performed by the inputs and outputs with Tez only routing connection information between producers and consumers. When a producer task output generates data then it can send metadata about it, say its access URL and size, via Tez, to the consumer task input. Tez routes this metadata using the connection pattern encoded in the edge connecting the producer and consumer. Thus Tez adds minimal overhead on the data plane. This also makes Tez data format agnostic. The inputs, processor and outputs can choose their own data formats (e.g. bytes, records or key-value pairs etc.) as suited for the application.
This novel IPO based approach to task composition allows for separation of concerns and makes the system pluggable. The same DAG structure can be instantiated with environment dependent IOs. E.g. different cloud environments can plug in IOs that are optimized for their storage subsystems. We will see in the next sections, how the IPOs can be dynamically configured during execution for even further runtime customizations.
3.3 Event Based Control Plane
The open architecture of the Tez orchestration framework requires a de-coupled control plane that allows a variety of entities to communicate control information with each other. In order to achieve this Tez has an event based control plane that is also exposed to the application. Software components generate events that get routed to receivers. By design, this is an asynchronous, non-blocking, push-based method of communication. Events are used for all communications, be it framework to framework, application to framework and vice versa, or application to application.
A DataEvent is generated by the output of a producer task and contains output metadata (say a URL) for the consumer task to read the data. This event is received by the framework and routed to the input of the consumer task by utilizing the connection information specified by the edge. If a task input has an error while reading its data then it can send an ErrorEvent to the framework. Based on such error events, Tez could re-execute the producer task to re-generate the data. Other events could be used to send statistics, progress etc. Event based communication also provides the flexibility to add more entities and communication channels without changing the interaction model or APIs. Tez only routes the events. Each event has an opaque binary payload that is interpreted by the sender and receiver to exchange control metadata. Events flow to and from tasks to the orchestrator on every task heartbeat. Event transfer latency depends on the heartbeat latency and processing latency at the orchestrator. These latencies increase in proportion to the size of the job as they depend on the number of concurrent connections and event load supported by the orchestrator. If control plane events lie on the data plane critical path then they would negatively affect application latency but if they are used only for data plane setup then Tez would not introduce any additional latency on the data plane for low latency applications.
3.4 Vertex Manager: dynamically adapting the execution
As motivated earlier, data processing clusters have variability in compute capacity or data distribution (where data is stored on physical nodes) that applications may consider to plan their work. Data dependent actions like sample based range partitioning or optimizations like partition pruning need the ability to change the DAG on the fly. It is not possible to encode all such current and future graph re-configurations statically, nor can this be done by Tez itself (as it requires too much domain knowledge). Thus Tez needs to allow the application to make such decisions at runtime and coordinate with Tez to dynamically adapt the DAG and its execution. This is enabled via the VertexManager abstraction.
Runtime Graph Re-configuration. When constructing the DAG, each vertex can be associated with a VertexManager. The VertexManager is responsible for vertex re-configuration during DAG execution. The orchestration framework contains various state machines that control the life-cycle of vertices, tasks etc. and the vertex state machine is designed to interact with the VertexManager during state transitions. The VertexManager is provided a context object that notifies it about state changes like task completions etc. Using the context object, the VertexManager can make changes to its own vertex's state. Among other things, the VertexManager can control the vertex parallelism, the configuration payloads of the inputs, processors and outputs, the edge properties and scheduling of tasks. As with other entities there is a VertexManager API that can be implemented by applications to customize the vertex execution. Using the same API, Tez comes with some built-in VertexManagers. If a VertexManager is not specified in the DAG, then Tez will pick one of these built-in implementations based on the vertex characteristics.
Automatic Partition Cardinality Estimation. As an example of a runtime optimization, we present a solution to a well-known problem in MapReduce about determining the correct number of tasks in the reduce phase. This number typically depends on the size of the data being shuffled from the mappers to the reducers and is accurately available only at runtime. Shuffle is the term used to describe the cross-network read and aggregation of partitioned input done prior to invoking the reduce operation. In Tez, the ShuffleVertexManager can be used to control the vertices that are reading shuffled data. The tasks producing the data to be shuffled, send data statistics to the ShuffleVertexManager using VertexManager events. The ShuffleVertexManager gathers these statistics to calculate the total data size and estimate the correct number of reducers to read that data using a per-reducer desired data size heuristic. Since the number of reducers essentially represents the partition cardinality, this solution can be generalized to estimating the optimal number of partitions at runtime (e.g. partitions participating in a distributed join operation).
Scheduling Optimizations. VertexManagers also control the scheduling of tasks in their vertex. Typically, tasks should be started after their input data is ready. However, if the tasks can proceed meaningfully with partial input then they could be started out of order and use any free compute capacity. The shuffle operation mentioned above is an example of a case where partial inputs can be read by tasks pro-actively. This is an expensive data transfer across the network and starting early can help hide its latency by overlapping it with the completion of tasks that will produce the remaining input. Out of order scheduling can result in scheduling deadlocks in a resource constrained cluster where an out of order task ends up blocking one of its input tasks because it has occupied resources in the cluster. Tez has built-in deadlock detection and preemption to take care of such situations. It will use the DAG dependency to detect tasks running out of order and preempt them to resolve the deadlock.
3.5 Data Source Initializer
In Tez we have modeled data sources as first class entities in our design. The first step in a DAG usually involves reading initial input from data sources like distributed file systems, and typically is the largest in terms of resource consumption. Hence, a good or bad decision at this step can significantly improve or degrade performance. A data source in a DAG can be associated with a DataSourceInitializer that is invoked by the framework before running tasks for the vertex reading that data source. The initializer has the opportunity to use accurate information available at runtime to determine how to optimally read the input. Like the VertexManager, the initializer can also send and receive events from other entities. It also has access to cluster information via its framework context object. Based on these and other sources of information, the initializer can configure the task inputs or notify the vertex manager about vertex re-configurations (e.g. the optimal parallelism needed to process the input).
As an example, we will present a Hive dynamic partition pruning use case. It often happens that a data source will be read and subsequently joined on some key. If the join key space is known then we could only read a subset of the data that is relevant to the join. Sometimes this metadata is only available at runtime after inspecting the data in a different sub-graph of the DAG. Hive uses InputInitializer events to send this metadata from tasks in the other vertices to the initializer of the data source. The initializer uses that metadata to decide the relevant subset of data to read. This can lead to large performance gains depending on the join selectivity.
The above discussion has been a broad overview of the architecture and features in Tez. More details about the semantics around the APIs and user defined entities is available in the API documentation on the project website.
4. IMPLEMENTATION AND PRACTICAL CONSIDERATIONS
We now turn to describing how the architecture of the previous section is instantiated in YARN, and discuss in more details efficiency and production-readiness aspects of Tez. From an engineering perspective this is where much of our effort was devoted, and what makes Tez a useful building block for data-processing engines.
4.1 Implementation in YARN
The Apache Tez project consists of 3 main parts:
- API library: This provides the DAG and Runtime APIs and other client side libraries to build applications.
- Orchestration framework: This has been implemented as a YARN Application Master (hereafter referred to as AM) to execute the DAG in a Hadoop cluster via YARN.
- Runtime library: This provides implementations of various inputs and outputs that can be used out of the box.
Typical Tez Application Lifecycle. A Tez based application is written using the API library by constructing the DAG representing the application logic. Typically, higher level applications like Apache Pig construct DAGs on the fly by encoding their native language constructs into Tez DAGs. Since Tez is designed to operate in Hadoop clusters we have provided implementations of inputs and outputs to standard storage services present in all Hadoop clusters - HDFS for reliable data storage and YARN Shuffle Service for temporary data storage. Applications that use only these services, need to implement just their processors to get up and running. Typically applications create a generic processor host that can be configured to execute DAG dependent operators. Tez inputs and outputs are based on the key-value data format for ease of use within the key-value dominated Hadoop ecosystem of projects like Apache Hive, Pig etc., and can be extended to other data formats. The DAG is then submitted to a YARN cluster using the Tez client library. YARN launches the Tez Application Master (AM - a per-application controller) to orchestrate the DAG execution. The DAG executed by the AM is typically a logical DAG that describes the data flow graph. The AM expands this graph to incorporate task parallelism per vertex. It does this using the input initializers and vertex managers specified in the DAG. The AM then requests YARN for resources to run the tasks from different vertices. YARN responds in the form of containers. A container is a unit of resource allocation on a cluster node. The AM launches tasks on these containers and routes control events. Tasks are typically executed in their dependency order and the DAG completes when all its tasks complete. The AM logs tracing and metadata information for monitoring and debugging.
By leveraging existing libraries and services from YARN and MapReduce, we have been able to quickly build on top of several man-years of production ready code for security and high volume network data shuffling; and integrate with the proven resource sharing and multi-tenancy model in YARN. Thus, applications built using Tez will benefit from all these without expending further effort.
4.2 Execution Efficiency
The YARN implementation of the orchestration framework is built with execution efficiency and performance in mind and incorporates well known ideas learned over the years in various distributed data processing systems.
Locality Aware Scheduling. Scheduling processing close to the data location is important for large-scale data-processing. Tez tries to run tasks close to their input data location. Location may be specified statically during DAG creation but is typically determined at runtime. The tasks that read from initial input data sources typically get locality information from their data sources while intermediate task locality is inferred from their source tasks and edge connections. E.g. tasks with scatter-gather inputs have no specific locality but may prefer to run close to the larger input data shards. 1-1 edges specify strict locality relationships between their source and destination tasks. Since getting perfect locality may not be guaranteed in a busy cluster, the framework automatically relaxes locality from node to rack and so on with delay scheduling used to add a wait period before each relaxation.
Speculation. Large clusters can have heterogeneous hardware and varying loads and hardware-aging. This can lead to environment induced task slowdowns. Such slow tasks are termed stragglers and launching a clone of such tasks is typically used to mitigate their effects on latency. Tez monitors task progress and tries to detect straggler tasks that may be running much slower than other tasks in the same vertex. Upon detecting such a task, a speculative attempt may be launched that runs in parallel with the original task and races it to completion. If the speculative attempt finishes first then it is successful in improving the completion time.
Container Reuse. Recall that the Tez AM runs tasks in containers allocated to it by YARN. When a task completes, the AM has an option to return the container to YARN and ask for another container with different capabilities or locality. However, each container allocation cycle has overheads associated with resource negotiation from YARN as well as launching the container process. This overhead can be minimized by re-using the container to run other pending tasks that match the resource allocation and locality of that container. When there are no such matching tasks, the Tez AM releases the idle containers back to YARN in return for new resources with different capabilities. In the Java world, this reuse has the additional benefit of giving the JVM optimizer a longer time to observe and optimize the hot code paths leading to further performance benefits.
Session. A session takes the concept of container reuse one step further. A Tez AM can be run in session mode in which it can run a sequence of DAGs submitted to it by the client. This allows tasks from multiple DAGs to reuse containers and leads to further efficiencies and performance gains. In addition, a session can be pre-warmed by requesting the AM to launch containers before the first DAG is ready to execute. These pre-warmed containers can execute some pre-determined code to allow JVM optimizations to kick in. This extends the benefits of container reuse to the first DAG that gets submitted to the session. E.g. Apache Hive and Pig use the session mode to run multiple drill-down queries in the same session for performance benefits. Tez sessions also enable iterative processing to be performed efficiently. Each iteration can be represented as a new DAG and submitted to a shared session for efficient execution using pre-warmed session resources.
Shared Object Registry. Tez extends the benefits of container reuse to the application by providing an in-memory cache of objects that can be populated by a task and then re-used by subsequent tasks. The lifecycle of objects in the cache can be limited to a vertex, a DAG or the session and is managed by the framework. It can be used to avoid re-computing results when possible. E.g. Apache Hive populates the hash table for the smaller side of a map join in Hive parlance (broadcast join). Once a hash table has been constructed by a join task, other join tasks don't need to re-compute it and improve their performance.
4.3 Production Readiness
While performance and efficiency are important for a framework such as Tez, we cannot ignore the standard abilities that are prerequisites for a production ready and dependable framework for large scale data processing. A plethora of entities use technologies like Apache Hive, Pig and other commercial software like Cascading to run mission critical operations. If they are to confidently build using Apache Tez, then items like fault tolerance, security and multi-tenancy become necessary requirements. Fortunately, Tez has been able to build on top of proven and tested platforms like Hadoop YARN and MapReduce and draws from their strengths for achieving some of these abilities. The YARN integration exemplifies the specialized code implemented in Tez that can be leveraged by higher-level engines using Tez.
Multi-Tenancy. Data processing clusters are becoming increasingly large and sharing their capital expenditure among multiple applications and users is essential from a capex point of view. Applications must be written with such sharing and cooperative behavior in mind. The discrete task based processing model in Tez lends itself nicely to such cooperative behavior. Short lived ephemeral tasks allow resources to be periodically released by Tez applications so that they can be allocated to other users and applications as deemed appropriate by the cluster resource allocation policy. This also enables higher resource utilization by transferring resources from applications that don't need them to applications that do.
This is where engines that effectively deploy services daemons suffer from a drawback. Typically, the service daemons have to pre-allocate a large share of resources that cannot be shared with other applications. For better utilization, these daemons try to run multiple 'tasks' concurrently but that is not useful when there isn't enough load on the system, besides introducing the possibility of interference between concurrent tasks. With Tez, since each task runs in its own container process, the resource allocations are much finer grained. This improves utilization (by reducing allocated resources that are idle) and also provides process-based resource isolation (for CPU/memory etc.). This also provides resource elasticity to Tez applications in that they can scale up to utilize as much resources as the cluster can spare to speed up job execution time while gracefully degrading performance but still completing the job when resources are scarce. To be clear, this discussion about daemon based designs is in the specific context of ephemeral data processing jobs. There are many contexts like data storage, web services, PAAS applications etc. where a long running shared service provided by a daemon based engine is suitable.
Security. Security is real concern with the variety and volume of data stored in modern data processing clusters. Hadoop has built-in Kerberos and token based authentication and access control and Tez natively integrates with the Hadoop security framework to provide the application with secure access. In addition to that, the inputs and outputs provided with Tez support encryption for data read across the network. Security is a real concern with the variety of data stored and concurrent access from multiple users. Being outside the data plane reduces the contribution of Tez in the threat surface of the application. The only interaction between Tez and the app is control metadata routed via events by Tez. This metadata is presented to Tez as an opaque binary payload and thus can be protected by the app by encryption or other techniques as deemed necessary. In the control plane, secure Hadoop provides Kerberos and token based authentication for applications to access storage or compute resources and Tez integrates with the secure APIs exposed by Hadoop. Tez has some built-in input and output libraries for HDFS and local storage. In a secure Hadoop cluster, these libraries use HDFS token based authentication to access the data. In a secure cluster local data is written in the OS security content of the user and read via secure SSL channel provided by the YARN Shuffle Service.
Another aspect of security is isolation between tasks running on the same machine but belonging to different users. YARN provides this security isolation between containers by running the containers in the security context of the application user. Due to its fine-grained, ephemeral task model, Tez can leverage this container security by running tasks for single user in the containers of an application, thus guaranteeing user level isolation. This is much harder to achieve when using application engines that deploy service daemons. The daemons need to run tasks from different users in the same daemon process, making security isolation difficult or impossible. To work-around this, multiple instances of the service daemons need to be launched (one per user) and that may reduce resource utilization, as described above. We believe that the fine-grained, ephemeral task model of Tez makes it more suitable for secure and multi-tenant YARN clusters.
Fault Tolerance. Failures are a norm in clusters of commodity hardware. Failures can be on the compute nodes or the network. Tez provides robust fault tolerance against failures using task re-execution as a means of recovering from errors. When a task fails due to machine errors, it is re-executed on a different machine. Task re-execution based fault tolerance depends on deterministic and side-effect free task execution. Being side-effect free allows a task to be executed multiple times. Being deterministic, guarantees that if identical code is executed on identical input data then it will produce identical output data for each execution. These enable the system to safely re-execute tasks to recover from failures and data loss. Since the outputs are identical, the already completed consumer tasks of that output do not need to be re-executed. This limits the amount of re-execution and reduces the cost of failures.
Since Tez is not on the data plane, it exposes an InputReadError event that task inputs can use to notify Tez about loss of intermediate data. Using the DAG dependency information Tez can determine which task outputs produced the missing data and re-execute that task to regenerate the data. It may happen that the re-executed task also reports an input error. This would cause Tez to go up one more step in the DAG dependency and so on, until it has found stable intermediate data. The edge API allows for the specification of intermediate data resiliency such that Tez can be informed that a given edge data has been reliably stored, thus creating a barrier to cascading failures. Tez built-in input/output libraries leverage heuristics inherited from MapReduce for mitigating and recovering from network errors and cascading failures when shuffling large volumes of data. E.g. temporary network errors are retried with back-off before reporting an error event. Partially fetched data is cached and the consumer task stays alive until the remaining missing data is regenerated. The Tez AM periodically checkpoints its state. If the node, that is running the Tez AM, has a failure then YARN will restart the AM on another node and the AM can recover its state from the checkpoint data.
Tez tightly integrates with YARN to handle planned and unplanned cluster outages. It listens to notifications about machine loss or decommissioning and pro-actively re-executes the tasks that were completed on such machines. This decreases the chance that consumers of those task outputs will fail. Tez also understands actions taken by YARN such as preempting containers for capacity rebalancing or terminating badly behaving containers and responds to those actions appropriately.
Limitations. The current implementation of Tez is Java based and thus we are limited to JVM based applications right now. The Tez based MapReduce implementation has successfully executed non-JVM user code using MapReduce's approach of forking off non-Java code. However, a more native Tez support would need non-Java APIs for writing IPOs and executors to support them. Work is in progress to have a portable text based representation of the DAG API to enable non-Java compilers that target Tez. Tez can only be used in a YARN based Hadoop cluster because the current scheduling implementation has been written for YARN. Our recent work to enable developer debugging capability has abstracted out the dependence on a cluster. Extensions of that work could enable Tez to utilize other systems for execution. The current fault tolerance model depends on the assumption that intermediate task outputs are localized to the machine on which the task ran. Thus intermediate data loss causes re-execution of the task on a different machine. This may not be true of all IOs. e.g. if data is being streamed directly over the network. Also, such network streaming may result in collapse of the connected streaming sub-graph, which would need extensions of the fault tolerance model to handle such correlated failures.
5. APPLICATIONS & ADOPTION
In this section we will outline projects that have been updated or prototyped to use the Tez framework to run on YARN. These projects represent a significant variety of application types and help show the applicability of the Tez APIs for modeling and building high-performance data processing applications.
5.1 Apache MapReduce
MapReduce is a simple yet powerful means of scalable data processing that can be credited with ushering in the era of inexpensive hyper-scale data processing. At its core, it is a simple 2 vertex connected graph. In Tez, it can be represented with a map vertex and a reduce vertex that are connected using a scatter-gather edge. Tez has a built-in MapProcessor and a ReduceProcessor that run in the respective Map and Reduce vertices and provide the MapReduce interface functionality. Thus MapReduce can be easily written as a Tez based application and, in fact, the Tez project comes with a built-in implementation of MapReduce. Any MapReduce based application can be executed without change using the Tez version of MapReduce by simply changing a MapReduce configuration on a YARN cluster.
5.2 Apache Hive
Apache Hive is one of the most popular SQL-based declarative query engines in the Hadoop ecosystem. It used to translate queries written in HiveQL (a SQL-like dialect) to MapReduce jobs and run them on a Hadoop cluster. Like other SQL engines, Hive translates the queries into optimized query trees. Often these translations to MapReduce were inefficient due to the restricted expressiveness of MapReduce. These trees translate directly to DAGs specified using the Tez DAG API. Thus they can be represented efficiently in Tez. In addition, Hive uses custom edges (written to the Tez API) to perform sophisticated joins that were hitherto very difficult to do. E.g. In a join variant called Dynamically Partitioned Hash Join; Hive uses a custom vertex manager to determine which subsets of data shards to join with each other and creates a custom edge that routes the appropriate shards to their consumer tasks. While these query planning improvements provide algorithmic performance gains, Hive benefits from the execution efficiencies to get significant performance benefits out of the box. This integration has been implemented by the Apache Hive community with Hive 0.13 being the first release of Hive to have Tez integration. Further Tez-based optimizations (like dynamic partition pruning) have been released in Hive 0.14 with more work planned in future releases.
5.3 Apache Pig
Apache Pig provides a procedural scripting language (named PigLatin) that is designed to write complex batch processing ETL pipelines. The procedural nature of PigLatin allows the creation of complex DAGs with vertices having multiple outputs. In MapReduce, applications could write only 1 output and thus were forced to use creative workarounds like tagging the data or writing side-effect outputs. Being able to model multiple outputs explicitly via the Tez APIs allows the planning and execution code in Pig to be clean and maintainable. Pig supports joins with data-skew detection and this was earlier done by running different MapReduce jobs to read and sample the data, then create histograms based on the samples on the client machine and finally run another job that uses the histogram to read and partition the data. This complex workflow of jobs can now be represented as a sub-graph of any Pig DAG when using Tez. The samples are collected in a histogram vertex that calculates the histogram. The histogram is sent via an event to a custom vertex manager that re-configures the partition vertex to perform the optimal partitioning. Pig developers from Yahoo, Netflix, LinkedIn, Twitter and Hortonworks came together to implement this integration. Pig 0.14 is the first release of Pig to support Tez based execution in addition to MapReduce.
5.4 Apache Spark
Apache Spark is a new compute engine that provides an elegant language integrated Scala API for distributed data processing. It specializes in machine learning but the API lends itself to mixed workloads and pipeline processing. Data distribution metadata is captured at the language layer in a concept called Resilient Distributed Dataset (RDD) and this metadata is used during compilation to construct a DAG of tasks that perform the distributed computation. Spark comes with its own processing engine service to execute these tasks. We were able to encode the post-compilation Spark DAG into a Tez DAG and run it successfully in a YARN cluster that was not running the Spark engine service. User defined Spark code is serialized into a Tez processor payload and injected into a generic Spark processor that deserializes and executes the user code. This allows unmodified Spark programs to run on YARN using Spark's own runtime operators. Apache Hive and Pig were already designed to translate to MapReduce and the translation to Tez is an evolutionary step. Modeling a net new engine like Spark on YARN using Tez presents a strong proof of the generality and modeling power of the Tez framework. Tez sessions also enable Spark machine learning iterations to run efficiently by submitting the per-iteration DAGs to a shared Tez session. This work is an experimental prototype and not part of the Spark project.
5.5 Apache Flink
Apache Flink is a new project in the Apache community with roots in the Stratosphere research project of the TU Berlin data management community. It is a parallel processing engine that provides programming APIs in Java and Scala, a cost-based optimizer for these APIs, as well as its own execution engine. Flink is another example of a new platform that could be integrated with YARN using the Tez framework instead of running it as a standalone service. The post-optimization DAG is translated to a Tez DAG for this integration. While Apache Hive and Pig work on key-value data formats and could use the built-in Tez inputs and outputs, Flink keeps intermediate data in a custom binary format. This format can be used to perform operations like group by etc. without much deserialization overhead. The pluggable and composable Tez task model allowed Flink to incorporate its runtime operators and binary format inside Tez tasks, thus allowing unmodified programs to run on YARN using Flink's native runtime model. This work is currently in progress in the Apache Flink developer community.
5.6 Commercial Adoption
The customizability and performance focused design of Tez has resulted in rapid uptake from commercial software projects. Concurrent Inc. supports an open source language integrated API in Java, called Cascading, for distributed data processing. Cascading has been updated to run on YARN using the Tez framework with promising performance results. Scalding is a Scala dialect over Cascading that automatically gets the benefits of Tez via Cascading. Cascading 3.0 is currently available as a developer preview and integrates with Tez. Datameer provides a visual analytics platform that uses Tez to run optimized analytics queries on YARN. It also uses Tez sessions to maintain a query service pool for fast response times in a secure, multi-tenant environment. Datameer 5.0 is the first release that uses Tez. Release 8 of Syncsort's Hadoop product, DMX-h, shipped with an intelligent execution layer to enable transparent targeting of execution frameworks other than Mapreduce. Following this, they are in the process of integrating with Tez as one of their supported execution frameworks.
5.7 Deployments
Apache Tez has been deployed across multiple organizations and on a variety of cluster configurations. Most prominently, Yahoo! has deployed Tez on multiple clusters ranging from 100s to 1000s of nodes to run Hive and Pig with Tez. LinkedIn has completed migration of all their Hadoop clusters to YARN and is running Hive and Pig with Tez. Microsoft Azure has deployed Hive with Tez as part of its cloud Hadoop offering. Hortonworks has provided Tez as a part of its Hadoop distribution since April 2014 and is seeing rapid adoption of Tez by its install base. At the time of publication, nearly 100 Hortonworks customers have explored the capabilities of Tez.
The growing adoption of Tez we described in this section provides a qualitative metric of the project success. In the next section, we turn to the experiment results obtained as a result of these applications being integrated with Tez.
6. EXPERIMENTAL RESULTS
We devote this section to present several experiments, showcasing how Tez-based implementations of Hive, Pig and Spark on YARN outperform their original implementation on YARN. The experiments are derived from both standard benchmarks, and production workloads.
6.1 Hive 0.14 Performance Tests
Hive utilizes various features available in Tez, such as broadcast edges, runtime re-configuration and custom vertex managers, to achieve a better overall execution of the user's processing goals. In conjunction with Hive 0.14's Cost Based Optimizer, Tez enables the execution of bushy join plans which can take advantage of intermediate broadcast joins. The pluggable task model of Tez allows Hive to use custom vectorized operators throughout the processing. Custom edges are used to perform efficient Hive sort-merge-bucket joins. The Tez-based implementation substantially outperforms the traditional MapReduce based one on a TPC-DS derived Hive workload, run at 30 terabytes scale on a 20 node cluster with 16 cores, 256Gb RAM and 6 x 4Tb drives per node.
6.2 Yahoo Hive Scale Tests
A comparative scale test of Hive on Tez was done with a TPC-H derived Hive workload, at 10 terabytes scale on a 350 node research cluster with 16 cores, 24Gb RAM and 6 x 2Tb drives per node. This was presented at Hadoop Summit 2014, San Jose. This shows that Tez based implementation outperforms the MapReduce based implementation at large cluster scale.
6.3 Yahoo Pig Production Tests
At Yahoo!, Pig on Tez was tested on large production ETL pig jobs that run in the order of minutes to hours. To test different aspects of scale and features of the implementation, the pig scripts run had varying characteristics like terabytes of input, 100K+ tasks, complex DAGs with 20 to 50 vertices and doing a combination of various operations like group by, union, distinct, join, order by, etc. The tests were run on different production clusters where data resided and already running regular jobs with average utilization of 60-70%. The cluster had 4,200 servers, 46 PB HDFS storage and 90TB aggregate memory. Most data nodes were with 12/24G RAM, 2 x Xeon 2.40GHz, 6 x 2TB SATA on Hadoop 2.5, RHEL 6.5, JDK 1.7. There were performance improvements of 1.5 to 2x compared to MapReduce keeping all the configuration (memory, shuffle configuration, etc.) same as MapReduce.
6.4 Pig KMeans Iteration Tests
As noted in Section 4.2, Tez session and container-reuse features work in favor of fast iterative workloads, which require consecutive DAGs to execute over the same data-set. Performance improvements were demonstrated for a K-means iterative PIG script, run for 10, 50 and 100 iterations against a 10,000 row input dataset on a single node. This was presented at the Hadoop Summit 2014, San Jose.
6.5 Spark Multi-Tenancy on YARN Tests
As explained in Section 4.3, Tez's ephemeral task based model is better for multi-tenancy and resource-sharing. This is demonstrated by comparing service-based vs Tez-based implementations of Spark on YARN. The Tez based implementation releases idle resources that get assigned to other jobs that need them, thus speeding them up, while the service-based implementation holds on to resources for the life of the service. For the experiment, we have a 5-user concurrency test of partitioning a TPC-H lineitem data-set along the L_SHIPDATE column, on a 20 node cluster. The tests were run across data sets which correspond to 100 GB, 200 GB, 500 GB and 1 TB warehouse scale factors. The cluster used to run this workload was identical to the Hive 0.14 benchmarks, having 16 cores, 256Gb of RAM and 6 x 4Tb disks per node.
7. OPEN SOURCE AND FUTURE WORK
Apache Tez has been developed as an open source project under the Apache Software Foundation. It's a community driven project with contributors from Microsoft, Yahoo, Hortonworks, LinkedIn among others as well as individual enthusiasts. The source code for the project is available at http://git.apache.org/tez.git and the project website is at http://tez.apache.org.
The open architecture of Tez and its fundamentally customizable design lends it to becoming a platform for experimentation and innovation. We believe that the current use cases built on Tez are only the initial steps of a longer journey. There is considerable interest in a variety of areas to improve and leverage Tez. Progressive query optimization which allows a complex and large query to be executed partially and optimized incrementally as the query proceeds. Apache Hive and Apache Calcite are working together on materialized views for speeding up common sub-queries. We want to provide deep integration with in-memory storage capabilities being added to HDFS so that Tez applications can benefit from in-memory computing. Tez currently supports Java applications and extending it to support other languages would widen the scope of applications built using Tez. Another area of interest is tooling for debugging failure and performance bottlenecks. Increasingly, geographical distribution and legal/privacy requirements are making cross data-center job execution important. Improving the Tez orchestration and API to model such jobs may help in executing them efficiently. The above are only a few of the many possibilities in which Tez may be evolved or used by academic and commercial communities. Many runtime optimizations are also in the works. E.g. automatically choosing optimal data transport mechanisms like in-memory data for machine co-located tasks or using a reliable store for outputs of extremely long tasks so that their outputs are safeguarded against loss. A tactical idea is to create tooling that enables a full MapReduce workflow to be stitched into a single Tez DAG. This would enable legacy MapReduce workflows to easily use the MapReduce implementation in Tez.
8. RELATED WORK
Apache Tez has been fortunate to learn from the development and experiences of similar systems such as Dryad, Hyracks and Nephele. All of them share the concept of modeling data processing as DAGs with vertices representing application logic and edges or channels representing data transfer. Tez makes this more fine-grained by adding the concepts of inputs, processor and outputs to formally define the tasks executing the DAGs, leading to clear separation of concerns and allowing pluggable task composition. All of them participate to varied extents in the data plane and define some form of data format, which allows applications to define custom formats that derive from the base definition. All of them define on-disk, over-network and in-memory communication channels. Tez, on the other hand, does not define any data format and is not part of the data plane at all. On a similar note, Hyracks defines an operator model for execution that allows it to understand the data flow better for scheduling. Tez treats processors as black boxes so that the application logic can be completely decoupled from the framework. Nephele is optimized for cloud environments where it can elastically increase or decrease resources and choose appropriate virtual machines. Tez also enables resource elasticity by acquiring and releasing resources in YARN. Dryad and Tez share the concept of vertex managers for dynamic graph re-configurations. Tez takes this concept a step further by formalizing an API that allows the managers to be written without knowing the internals of the framework and also defining an event based communication mechanism that enables application code in tasks to communicate with application code in vertex managers in order to actuate the re-configurations. In addition, Tez adds the concept of input initializers to formally model primary data sources and apply runtime optimizations while reading them. Dryad schedules tasks when all the inputs of the tasks are ready to be consumed, to prevent scheduling deadlocks. Tez allows out of order execution for performance reasons and has built-in preemption to resolve scheduling deadlocks. Overall, Tez differs from these systems in its modeling capabilities and the design goal of being a library to build engines rather than being an engine by itself. MapReduce is, of course, the incumbent engine in the Hadoop ecosystem. Tez subsumes the MapReduce APIs such that it is possible to write a fully functional MapReduce application using Tez.
Dremel is a processing framework for interactive analysis of large data sets based on multi-level execution trees that is optimized for aggregation queries and has motivated systems like Presto and Apache Drill. These, and other SQL query engines like Impala or Apache Tajo, differ from Tez by being engines optimized for specific processing domains whereas Tez is a library to build data processing applications. Spark is a new general purpose data processing engine. It exposes a Resilient Distributed Dataset (RDD) based computation model that eventually gets executed on an in-memory storage and compute engine. Tez, again differs being a library and not a general purpose engine. Tez does not provide any storage service but applications can use existing in-memory stores, e.g. HDFS memory storage, to get the advantage of in-memory computing. The Spark notion of using RDDs as a means of implicitly capturing lineage dependency between steps of processing can be related to capturing that same dependency explicitly via defining the DAG using Tez APIs.
An important category of systems to compare against are other frameworks to build YARN-applications. The two most relevant in this space are Apache REEF and Apache Twill. These systems focus on a much broader class of applications (including services), than Tez, and thus provide a lower-level API. Tez focuses on supporting data-flow driven applications, and thus consciously chooses to provide a structured DAG-based control-flow.
9. CONCLUSIONS
Today, Hadoop is a booming ecosystem for large-scale data processing, blessed with an ever growing set of application frameworks, providing diverse abstractions to process data. We recognize that this is invaluable, yet we highlight substantial concerns of fragmentation and repeated work, as each application framework solves similar fundamental problems over and over again.
To address this issue we present Apache Tez, an open-source framework designed to build data-flow driven processing engines. Tez provides a scaffolding and libraries to facilitate the design and implementation of DAG-centric data processing applications, and focuses on re-use, while balancing customizability of the performance critical data plane. Tez makes a conscious effort to enable dynamic optimizations, such as partition pruning. Besides these key architectural choices, what sets Tez apart from other attempts of unifying frameworks is a sizeable open-source community, that is pushing Tez towards becoming the framework of choice for building DAG-oriented data processing engines. As of today, the most popular projects (Hive, Pig and Cascading) have integrated with Tez. We demonstrated experimentally that the Tez-based incarnations of these systems deliver substantial performance benefits beyond the qualitative argument of leveraging common functionalities.
We argue that the standardization we are promoting can foster even faster innovation, and enable integration plays that would be otherwise cumbersome (e.g., pipelines made up of multiple application frameworks). Tez's customizability and open-source community makes it an ideal playground for research, as novel ideas can be tested, integrated, and gain real-world impact with minimal overhead.
Acknowledgements
Apache Tez is an open source community driven project with contributions gratefully accepted from numerous individuals and organizations. In particular we would like to call out Rajesh Balamohan for keeping a watchful eye on performance and Tassapol Athiapinya and Yesha Vora for testing and system validation. We would like to thank members of other project communities who have helped in adopting and demonstrating the value of Tez. Notably, Gunther Hagleitner and Vikram Dixit for Apache Hive; Rohini Palaniswamy, Cheolsoo Park, Daniel Dai, Olga Natkovich, Mark Wagner and Alex Bain for Apache Pig; Chris Wensel for Cascading; Oleg Zhurakousky for Apache Spark; Kostas Tzoumas and Stephan Ewen for Apache Flink. We are also grateful to Yahoo and Hortonworks for providing experimentation infrastructure. We hope that the innovation platform provided by Tez will lead to further contributions from many more.
Source: apache_tez ·
apache_tez.md· updated 2026-05-29 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
Apache Tez: A Deep Dive Into Architecture, Internals & Source Code
Table of Contents
- What Is Apache Tez?
- Why Tez Exists — The MapReduce Problem
- Architecture Overview
- Core Concepts
- Source Code Structure
- Deep Dive: Key Modules
- The DAG Execution Lifecycle
- The Shuffle Pipeline
- Runtime Reconfiguration
- How Hive and Pig Use Tez
- Tez on YARN — The Full Picture
- Reading the Source: A Guided Path
- Simpler Open Source Alternatives
- References
1. What Is Apache Tez?
Apache Tez is a distributed data-processing execution engine built on top of Apache Hadoop YARN. It generalizes the rigid two-phase MapReduce model into an arbitrary directed acyclic graph (DAG) of tasks, enabling higher-level frameworks like Hive and Pig to express complex query plans as a single, optimized execution graph rather than a chain of independent MapReduce jobs.
Tez is not an end-user framework. You don't typically write Tez applications directly (though you can). Instead, Tez acts as the execution backend for tools like:
- Apache Hive (SQL on Hadoop)
- Apache Pig (data flow scripting)
- Cascading (Java data pipelines)
Key Stats
| Metric | Value |
|---|---|
| Language | Java |
| Lines of Code | ~225,000 |
| Contributors | 109+ |
| First Commit | April 2013 |
| License | Apache 2.0 |
| Repository | github.com/apache/tez |
2. Why Tez Exists — The MapReduce Problem
The Pain Points of MapReduce
Classic MapReduce forces every computation into a rigid two-phase pattern:
Input → Map → Shuffle/Sort → Reduce → Output (HDFS)
For a complex Hive query that involves multiple joins and aggregations, MapReduce had to chain several independent jobs:
MR Job 1: Scan + Filter → write to HDFS
MR Job 2: Join → read from HDFS, write to HDFS
MR Job 3: Aggregate → read from HDFS, write to HDFS
MR Job 4: Final sort + output → read from HDFS, write to HDFS
Each boundary between jobs meant:
- Materializing intermediate data to HDFS (disk I/O + replication overhead)
- Launching new YARN containers (JVM startup latency)
- No pipelining between stages (the next job can't start until the previous one fully finishes writing)
What Tez Changes
Tez lets you express the same computation as a single DAG:
Scan+Filter ─┐
├─→ Join ─→ Aggregate ─→ Sort ─→ Output
Scan+Filter ─┘
Intermediate data flows through in-memory edges (or local disk, never HDFS) between vertices in the same DAG. Containers are reused across vertices. The entire plan runs as one YARN application, not four.
Performance Impact
For Hive queries, switching from MapReduce to Tez typically yields:
- 2–5x faster for simple queries (fewer job launches, no HDFS writes between stages)
- 10–100x faster for complex multi-stage queries (eliminates cascading HDFS I/O)
- Near-interactive latency for many queries that previously took minutes
3. Architecture Overview
Tez has a clean three-layer architecture:
┌─────────────────────────────────────────────────┐
│ CLIENT APPLICATION │
│ (Hive, Pig, or custom Tez app) │
│ │
│ Uses DAG API to build: Vertices + Edges │
│ Submits DAG to the Tez ApplicationMaster │
└────────────────────┬────────────────────────────┘
│ Submit DAG
▼
┌─────────────────────────────────────────────────┐
│ TEZ APPLICATION MASTER (AM) │
│ (runs as a YARN ApplicationMaster) │
│ │
│ • DAG state machine (DAGImpl) │
│ • Vertex state machine (VertexImpl) │
│ • Task state machine (TaskImpl) │
│ • TaskAttempt state machine (TaskAttemptImpl) │
│ • Container management & reuse │
│ • Speculative execution │
│ • Runtime reconfiguration │
│ • Scheduling & data locality │
└────────────────────┬────────────────────────────┘
│ Launch tasks in containers
▼
┌─────────────────────────────────────────────────┐
│ TASK RUNTIME (per container) │
│ │
│ • Input (reads data: HDFS, shuffle, etc.) │
│ • Processor (user logic: map, reduce, join) │
│ • Output (writes data: shuffle, HDFS, etc.) │
│ │
│ Tasks run the Input-Processor-Output pipeline │
│ within YARN containers on cluster nodes │
└─────────────────────────────────────────────────┘
The Two Core Components (Per the Tez README)
At its heart, Tez has just two components:
-
The data-processing pipeline engine — where you plug in Input, Processor, and Output implementations to perform arbitrary data processing within each task.
-
The DAG-based application master — where you compose those tasks into a DAG and the master handles scheduling, fault tolerance, and resource negotiation with YARN.
4. Core Concepts
DAG (Directed Acyclic Graph)
The top-level execution unit. A DAG contains vertices connected by edges. It maps to a single YARN application.
DAG dag = DAG.create("my-query");
dag.addVertex(scanVertex);
dag.addVertex(joinVertex);
dag.addEdge(Edge.create(scanVertex, joinVertex, edgeConfig));
Vertex
A logical stage in the pipeline. Each vertex has:
- A parallelism (number of tasks)
- A Processor class (the computation logic)
- Zero or more Inputs and Outputs
A vertex with parallelism=100 runs 100 tasks, each executing the same Processor on different data partitions.
Edge
Defines how data flows between two vertices. An edge specifies:
- Data movement type: ONE_TO_ONE, SCATTER_GATHER, BROADCAST
- Scheduling type: SEQUENTIAL (downstream waits) or CONCURRENT
- Data source type: PERSISTED (written to disk) or EPHEMERAL (pipelined)
- Edge manager plugin: controls routing of data between producer and consumer tasks
Task
A single unit of work within a vertex. Runs in a YARN container with an Input → Processor → Output pipeline.
Input / Processor / Output
The pluggable components of each task:
- Input: reads key-value pairs (from HDFS, from shuffle, from another source)
- Processor: transforms data (the user's computation logic)
- Output: writes key-value pairs (to shuffle, to HDFS, to another sink)
This is the fundamental abstraction. MapReduce hardcodes the inputs and outputs; Tez makes them pluggable.
5. Source Code Structure
The repository (github.com/apache/tez) is organized into these Maven modules:
tez/
├── tez-api/ # Public-facing API (DAG, Vertex, Edge, configs)
├── tez-common/ # Shared utilities, counters, security
├── tez-dag/ # ★ THE CORE: ApplicationMaster, state machines
├── tez-runtime-internals/ # ★ Task runtime framework (Input/Processor/Output lifecycle)
├── tez-runtime-library/ # ★ Built-in I/O implementations (shuffle, sort, merge)
├── tez-mapreduce/ # MapReduce compatibility layer
├── tez-examples/ # WordCount, OrderedWordCount examples
├── tez-plugins/ # History logging, YARN plugin
├── tez-tests/ # Integration tests
├── tez-ui/ # Web UI (Ember.js)
└── tez-dist/ # Distribution packaging
Complexity by Module
| Module | Approx. LOC | Complexity | What It Does |
|---|---|---|---|
tez-api | ~20k | Medium | User-facing API: DAG, Vertex, Edge builders, configuration |
tez-dag | ~70k | Very High | ApplicationMaster: DAG/Vertex/Task state machines, scheduling, container mgmt |
tez-runtime-internals | ~15k | High | Task-level runtime: manages the I/P/O pipeline lifecycle inside containers |
tez-runtime-library | ~30k | Very High | Shuffle, sort, merge, partitioning — the data movement engine |
tez-mapreduce | ~15k | Medium | Wraps MR Mapper/Reducer to run as Tez Processors |
tez-common | ~5k | Low | Utilities, counters |
tez-examples | ~2k | Low | Best starting point for reading the code |
tez-plugins | ~10k | Medium | History event logging, YARN task communicator |
tez-ui | ~20k | Medium | Web interface (JavaScript/Ember.js) |
6. Deep Dive: Key Modules
6.1 tez-api — The Public API
This is the module that Hive, Pig, and custom applications interact with. The key classes:
DAG.java
The builder for a DAG. Lets you:
- Create a named DAG
- Add vertices and edges
- Set DAG-level configuration and credentials
- Attach security tokens
DAG dag = DAG.create("word-count");
Vertex tokenizer = Vertex.create("Tokenizer", ProcessorDescriptor.create(
TokenProcessor.class.getName()), numTasks);
Vertex summation = Vertex.create("Summation", ProcessorDescriptor.create(
SumProcessor.class.getName()), numTasks);
dag.addVertex(tokenizer)
.addVertex(summation)
.addEdge(Edge.create(tokenizer, summation, edgeConfig));
Vertex.java
Defines a processing stage. Configuration includes:
- Processor class
- Parallelism (task count)
- Data sources (inputs that don't come from other vertices)
- Environment settings (JVM opts, environment variables)
- Vertex manager plugin (for dynamic parallelism)
Edge.java
Connects two vertices. The EdgeProperty specifies:
EdgeProperty.create(
DataMovementType.SCATTER_GATHER, // How data is routed
DataSourceType.PERSISTED, // Disk or in-memory
SchedulingType.SEQUENTIAL, // When downstream starts
outputDescriptor, // Output class on producer side
inputDescriptor // Input class on consumer side
);
Data movement types — the critical routing strategies:
| Type | Description | Use Case |
|---|---|---|
ONE_TO_ONE | Task i in source → Task i in dest | Pipeline stages with same parallelism |
SCATTER_GATHER | All source tasks → all dest tasks (with partitioning) | Shuffle/sort (like MR's shuffle) |
BROADCAST | Every source task sends to every dest task | Broadcast joins (small table to all reducers) |
CUSTOM | User-defined routing via EdgeManagerPlugin | Specialized data routing |
TezClient.java
The client-side entry point. Creates a YARN application, uploads the DAG plan, and submits it to the Tez ApplicationMaster.
6.2 tez-dag — The ApplicationMaster (The Hard Core)
This is where most of the complexity lives. The AM runs as a YARN ApplicationMaster and manages the entire DAG execution.
State Machines
The AM is built around four nested state machines, each implemented with Hadoop's StateMachineFactory:
DAGImpl (DAG lifecycle)
└── VertexImpl (per-vertex lifecycle)
└── TaskImpl (per-task lifecycle)
└── TaskAttemptImpl (per-attempt lifecycle)
DAGImpl.java — DAG State Machine
States: NEW → INITED → RUNNING → COMMITTING → SUCCEEDED / FAILED / KILLED / ERROR
Key transitions:
| Event | From State | To State | What Happens |
|---|---|---|---|
| DAG_INIT | NEW | INITED | Parse DAG plan, create VertexImpl objects |
| DAG_START | INITED | RUNNING | Start root vertices (those with no inputs from other vertices) |
| VERTEX_COMPLETED | RUNNING | RUNNING | Check if downstream vertices can start |
| ALL_VERTICES_DONE | RUNNING | COMMITTING | All vertices succeeded, commit outputs |
| DAG_COMPLETED | COMMITTING | SUCCEEDED | Final commit done |
| INTERNAL_ERROR | any | ERROR | Unrecoverable failure |
This state machine orchestrates the entire execution. When a vertex completes, the DAG checks which downstream vertices have all their input-vertices completed and starts them.
Tip: You can generate a visual Graphviz diagram of this state machine:
mvn compile -Pvisualize \
-Dtez.dag.state.classes=org.apache.tez.dag.app.dag.impl.DAGImpl \
-DskipTests=true
This outputs Tez.gv which you can render with dot -Tpng Tez.gv -o dag-states.png.
VertexImpl.java — Vertex State Machine
States: NEW → INITIALIZING → INITED → RUNNING → COMMITTING → SUCCEEDED / FAILED / KILLED
This is the most complex state machine. A vertex:
- Initializes its tasks based on input splits or configured parallelism
- Waits for the VertexManager to signal readiness
- Schedules tasks on available containers
- Tracks task completion, handles failures and retries
- Commits output when all tasks succeed
Key complexity here: vertex reconfiguration. At runtime, a vertex can change its parallelism based on actual data sizes (see Section 9).
TaskImpl.java and TaskAttemptImpl.java
Task manages one logical unit of work. TaskAttempt tracks a single execution attempt of that task (there may be multiple attempts due to failures or speculative execution).
TaskAttempt handles:
- Container assignment
- Launch on a NodeManager
- Status updates and heartbeats
- Output commit
- Failure handling (retry logic)
Container Management
The AM doesn't just schedule tasks — it manages container reuse. Key classes:
TaskSchedulerManager— coordinates container requests with YARNContainerLauncherManager— launches tasks in containersTaskCommunicatorManager— heartbeat protocol with running tasks
Container reuse is a critical optimization. When a task finishes, the AM can reassign the same container to the next task in the DAG without releasing it back to YARN and requesting a new one. This avoids the JVM startup penalty that made chained MapReduce jobs slow.
6.3 tez-runtime-internals — The Task Runtime
This module manages the lifecycle of a single task running inside a YARN container.
LogicalIOProcessorRuntimeTask.java
The main class that runs inside each container. It:
- Initializes the configured Inputs
- Initializes the Processor
- Initializes the configured Outputs
- Calls
processor.run(inputs, outputs)— this is where user code executes - Handles cleanup and status reporting
The Input-Processor-Output Model
┌──────────┐ ┌──────────────┐ ┌───────────┐
│ Input 1 │────→│ │────→│ Output 1 │
├──────────┤ │ Processor │ ├───────────┤
│ Input 2 │────→│ │────→│ Output 2 │
└──────────┘ └──────────────┘ └───────────┘
Each component is pluggable:
- Inputs implement
LogicalInput— they know how to read from a data source - Processors implement
LogicalIOProcessor— they transform data - Outputs implement
LogicalOutput— they know how to write to a destination
The task runtime wires them together based on the DAG plan. This is the key abstraction that makes Tez flexible — MapReduce hardcodes the wiring; Tez lets you plug in any combination.
6.4 tez-runtime-library — Built-in I/O Implementations
This is the other highly complex module. It provides the actual data movement implementations.
Key Classes
| Class | Purpose |
|---|---|
OrderedPartitionedKVOutput | The "map output" — partitions, sorts, and optionally combines data for a scatter-gather edge |
OrderedGroupedKVInput | The "reduce input" — fetches shuffled data, merges sorted streams |
UnorderedKVOutput | Unpartitioned output (for broadcast edges) |
UnorderedKVInput | Reads broadcast data |
ShuffleManager | Coordinates fetching shuffle data from upstream tasks |
MergeManager | Manages the merge of sorted runs (in-memory and on-disk) |
IFile / IFileOutputStream | The intermediate file format for shuffle data |
ExternalSorter | Sorts data in memory, spills to disk when memory is exhausted |
The Shuffle Pipeline (Scatter-Gather)
This is the most performance-critical code path. When a producer vertex sends data to a consumer vertex via a SCATTER_GATHER edge:
Producer side (OrderedPartitionedKVOutput):
Records from Processor
→ Partition (by key, using configured Partitioner)
→ Sort (in-memory sort buffer)
→ Optional combine (reduce locally before shipping)
→ Spill to disk when buffer full
→ Merge spills into a single sorted, partitioned file
Consumer side (OrderedGroupedKVInput):
Fetch partition data from all producer tasks (HTTP)
→ Merge sorted streams (k-way merge)
→ Present to Processor as a sorted key-value stream
This is conceptually the same as MapReduce's shuffle, but with two critical improvements:
- No HDFS materialization — intermediate data goes to local disk or stays in memory
- Pipelining — consumers can start fetching before all producers finish (configurable via slow-start)
7. The DAG Execution Lifecycle
Here's what happens when Hive submits a query via Tez:
Step 1: DAG Construction (Client Side)
Hive's query optimizer produces a physical plan. The Tez execution engine (inside Hive) translates this into a Tez DAG:
Hive Query: SELECT dept, COUNT(*) FROM employees GROUP BY dept
Tez DAG:
Vertex "Map 1" (parallelism=10) → reads employees table from HDFS
│
│ SCATTER_GATHER edge (partition by dept)
▼
Vertex "Reducer 2" (parallelism=4) → aggregates counts, writes to HDFS
Step 2: DAG Submission
The TezClient submits the DAG:
- Serializes the DAG plan as a Protocol Buffer message
- Uploads the plan + JARs + configuration to HDFS
- Submits a YARN application (or reuses an existing Tez session)
Step 3: ApplicationMaster Initialization
The Tez AM starts on a cluster node:
DAGAppMaster.main()— entry point- Registers with YARN ResourceManager
- Deserializes the DAG plan
- Creates
DAGImpl, which createsVertexImplobjects for each vertex
Step 4: Vertex Scheduling
The DAG starts root vertices (those with no upstream dependencies):
VertexImpldetermines parallelism (from input splits or configured value)VertexImplcreatesTaskImplobjects- Each task requests a YARN container (with data locality preferences)
- The
TaskSchedulerManagernegotiates containers from the ResourceManager
Step 5: Task Execution
For each scheduled task:
- The AM launches the task in an allocated container
TezTaskRunnerstarts in the container's JVM- The runtime initializes Input → Processor → Output
Processor.run()executes (this is where the actual computation happens)- The task reports status back to the AM via heartbeats
Step 6: Inter-Vertex Data Flow
When "Map 1" tasks produce output:
OrderedPartitionedKVOutputwrites sorted, partitioned data to local disk- The AM notifies "Reducer 2" that data is available
- "Reducer 2" tasks fetch their partitions from "Map 1" tasks via HTTP
ShuffleManager+MergeManagerhandle the fetch and merge
Step 7: Completion
- All tasks in the final vertex complete
- Output is committed (to HDFS or wherever configured)
- The DAG transitions to SUCCEEDED
- The AM reports completion to the client
- Containers are released (or held for session reuse)
8. The Shuffle Pipeline
The shuffle is the most performance-sensitive code in Tez. Here's how data moves through it in detail.
Producer Side
The flow in OrderedPartitionedKVOutput:
1. Processor writes key-value pairs
2. Each record is:
a. Partitioned → which downstream task gets it
b. Serialized → converted to bytes
c. Written to a circular in-memory buffer (sort buffer)
3. When buffer reaches threshold (default 80%):
a. Sort the buffer by (partition, key)
b. Optionally run combiner on each partition's data
c. Spill sorted data to local disk as an IFile
4. After all records:
a. Merge all spill files into a single output file
b. Create an index file mapping partition → offset in the output file
c. Register the output with the AM
Key configuration:
| Property | Default | Description |
|---|---|---|
tez.runtime.io.sort.mb | 100 MB | Size of the in-memory sort buffer |
tez.runtime.sort.spill.percent | 0.8 | Buffer threshold that triggers a spill |
tez.runtime.combine.min.spills | 3 | Minimum spills before combiner runs |
Consumer Side
The flow in OrderedGroupedKVInput:
1. ShuffleManager determines which source tasks to fetch from
2. For each source task:
a. HTTP GET to fetch this consumer's partition from the source's output
b. If small enough → keep in memory
c. If too large → write to local disk
3. MergeManager performs k-way merge:
a. In-memory merge when memory segments exceed threshold
b. On-disk merge when disk segments exceed threshold
c. Final merge produces a single sorted stream
4. Processor reads merged sorted key-value pairs
The merge uses a priority queue (min-heap) over sorted segments — the same algorithm as MapReduce, but with better memory management and configurable thresholds.
9. Runtime Reconfiguration
One of Tez's most powerful features — and a major source of code complexity.
The Problem
When building the DAG, you often don't know the right parallelism for downstream vertices. Example: if "Map 1" produces 10 GB of shuffle data, you might want 100 reducers. If it produces 100 MB, you might want 2.
MapReduce forces you to guess at job submission time. Get it wrong, and you either waste resources (too many reducers) or create a bottleneck (too few).
How Tez Solves It
VertexManager plugins can dynamically reconfigure a vertex at runtime based on actual data from upstream:
public class ShuffleVertexManager extends VertexManagerPlugin {
@Override
public void onVertexManagerEventReceived(VertexManagerEvent event) {
// Receive actual output size from upstream tasks
actualOutputSize += event.getOutputSize();
}
@Override
public void onVertexStateUpdated(VertexStateUpdate update) {
// When enough upstream tasks have reported:
int newParallelism = actualOutputSize / desiredTaskInputSize;
getContext().reconfigureVertex(newParallelism, ...);
}
}
ShuffleVertexManager (built-in) is the most important VertexManager. It:
- Collects output-size statistics from completed upstream tasks
- Estimates the total output size
- Calculates the optimal parallelism for the downstream vertex
- Reconfigures the vertex (changes task count and routing) before tasks launch
This means a Hive query doesn't need SET mapreduce.job.reduces=100 — Tez figures it out automatically.
Key Source Files
ShuffleVertexManager.javaintez-runtime-library— the auto-parallelism logicVertexImpl.java— handles the reconfigure event, re-creates tasksVertexManager.javaintez-api— the plugin interface
10. How Hive and Pig Use Tez
Hive on Tez
When you run a Hive query with hive.execution.engine=tez:
- Hive compiles the HiveQL into an operator tree (Scan → Filter → Join → Aggregate → File)
- Hive's optimizer (Calcite-based) optimizes the plan
TezCompilertranslates the operator tree into a Tez DAG:- Each "work" unit (a group of operators that run together) becomes a Vertex
- Dependencies between work units become Edges
- Shuffle boundaries (GROUP BY, JOIN, DISTRIBUTE BY) become SCATTER_GATHER edges
- Broadcast joins become BROADCAST edges
TezSessionStatesubmits the DAG to a running Tez session (or starts a new one)- Tez executes the DAG on YARN
- Hive reads the output from HDFS
Example: Multi-Stage Query
SELECT d.name, COUNT(*)
FROM employees e
JOIN departments d ON e.dept_id = d.id
WHERE e.salary > 50000
GROUP BY d.name
ORDER BY COUNT(*) DESC;
As MapReduce (3 separate jobs):
Job 1: Scan employees, filter salary > 50000, scan departments → Join → HDFS
Job 2: Read join output → Group by dept name, count → HDFS
Job 3: Read aggregated output → Sort by count DESC → HDFS
As Tez (1 DAG, 4 vertices):
Vertex "Map 1" (scan employees + filter) ──┐
├─→ Vertex "Join" ─→ Vertex "GroupBy" ─→ Vertex "Sort"
Vertex "Map 2" (scan departments) ─────────┘
No HDFS writes between stages. Containers reused. Dynamic parallelism at each boundary.
Pig on Tez
Pig's integration is similar. Pig's TezCompiler converts Pig's logical plan into a Tez DAG. Each Pig operator (LOAD, FILTER, GROUP, FOREACH, STORE) maps to Tez vertices and edges.
11. Tez on YARN — The Full Picture
Session Mode vs. Non-Session Mode
Non-session mode: Each DAG submission creates a new YARN application. The AM starts, runs the DAG, and exits. Simple but has launch overhead.
Session mode: A long-lived Tez AM stays running between DAG submissions. Multiple DAGs can be submitted to the same session sequentially. This is what Hive uses for interactive queries — the first query pays the AM startup cost, but subsequent queries start immediately.
TezClient (session mode)
→ DAG 1: submitted, runs, completes
→ DAG 2: submitted immediately (no AM restart), runs, completes
→ DAG 3: ...
→ session.stop() — AM releases all containers and exits
Container Reuse
Within a session (and even within a single DAG), Tez reuses containers:
- Task A finishes in container C on node N
- The AM checks if any pending task prefers node N (data locality)
- If yes: reassign container C to that task — no YARN negotiation needed
- The container's JVM runs the new task's Input/Processor/Output
This is managed by AMContainerMap and the TaskSchedulerManager in tez-dag.
Speculative Execution
Tez supports speculative execution: if a task is running significantly slower than its peers, the AM launches a duplicate attempt on a different node. Whichever finishes first wins; the other is killed.
Controlled by: tez.am.speculation.enabled=true
12. Reading the Source: A Guided Path
If you want to understand how Tez works by reading the code, follow this sequence:
Level 1: The API (1–2 hours)
Start with the examples and the public API to understand the programming model.
-
tez-examples/WordCount.java(~200 lines) — A complete Tez application. Shows how to define vertices, edges, processors, and submit a DAG. This is the "Hello World" of Tez. -
tez-examples/OrderedWordCount.java— Adds a second vertex for sorting. Shows a multi-vertex DAG with a SCATTER_GATHER edge. -
tez-api/DAG.java— Read the builder methods. Clean API. -
tez-api/Vertex.java— How vertices are configured. -
tez-api/Edge.java+EdgeProperty.java— How edges are defined. Pay attention toDataMovementType.
Level 2: The Task Runtime (2–4 hours)
Understand what happens inside each task.
-
tez-runtime-internals/LogicalIOProcessorRuntimeTask.java— The task entry point. Follows the I/P/O lifecycle clearly. -
tez-api/Processor.javainterface — Simple: justrun(Map<String, LogicalInput>, Map<String, LogicalOutput>). -
tez-runtime-library/OrderedPartitionedKVOutput.java— The producer side of shuffle. Follow fromwrite()through sort and spill. -
tez-runtime-library/OrderedGroupedKVInput.java— The consumer side. Follow from initialization through fetch and merge.
Level 3: The ApplicationMaster (4–8 hours)
The hardest part. Read the state machines.
-
Generate the state diagram first:
mvn compile -Pvisualize \ -Dtez.dag.state.classes=org.apache.tez.dag.app.dag.impl.DAGImpl \ -DskipTests=trueRender the
.gvfile and keep it open as a reference. -
tez-dag/DAGImpl.java— Focus on theStateMachineFactoryat the top of the file. Each.addTransition()call defines one edge in the state diagram. Read the transition handlers to understand what happens at each step. -
tez-dag/VertexImpl.java— The most complex file. Focus on:- The state machine definition
handleInitEvent()— how a vertex initializesscheduleTasks()— how tasks are scheduledreconfigureVertex()— runtime parallelism changes
-
tez-dag/TaskImpl.java— Simpler. Focus on attempt management. -
tez-dag/TaskAttemptImpl.java— Focus on launch, completion, and failure handling.
Level 4: Container & Scheduling (Advanced)
-
tez-dag/TaskSchedulerManager.java— How the AM interacts with YARN for containers. -
tez-dag/AMContainerMap.java— Container reuse logic. -
tez-runtime-library/ShuffleVertexManager.java— Auto-parallelism. TheonVertexManagerEventReceived()andreconfigureVertex()methods are where the magic happens.
13. Simpler Open Source Alternatives
If the Tez codebase feels overwhelming, these projects implement the same core ideas in much less code. Study them first to build intuition, then come back to Tez.
For Understanding DAG Execution Logic
Luigi (Python, by Spotify)
Repository: github.com/spotify/luigi Size: ~15,000 lines of Python Best for: Understanding DAG dependency resolution and task scheduling
Luigi implements the core job of Tez's DAG engine — scheduling tasks in dependency order, handling retries, tracking state — in readable Python. The key files:
scheduler.py— the central scheduler that resolves dependenciesworker.py— the worker that pulls and executes taskstask.py— the Task base class withrequires()andrun()
The requires() pattern maps directly to Tez's Edge concept — each task declares its upstream dependencies.
Dask (Python)
Repository: github.com/dask/dask
Size: ~100k total, but local.py is ~400 lines
Best for: Understanding the minimal DAG execution algorithm
Dask's dask/local.py contains a complete single-machine DAG executor in under 400 lines. It's the clearest possible implementation of the core algorithm:
- Build a DAG of function calls
- Identify tasks with no dependencies
- Execute them
- Remove completed tasks from the graph
- Repeat until empty
For the distributed version, distributed/scheduler.py adds work-stealing and data locality — concepts directly relevant to Tez's TaskSchedulerManager.
Prefect (Python)
Repository: github.com/PrefectHQ/prefect Size: ~50,000 lines Best for: Modern task/flow model with clean state management
Prefect's Task and Flow abstractions map almost 1:1 to Tez's Processor and DAG. Their state machine is simpler and better documented than Tez's.
For Understanding Distributed Execution
Ray (Python / C++)
Repository: github.com/ray-project/ray Best for: Understanding distributed task execution without YARN complexity
Ray's task model (remote functions, object store, scheduling) is conceptually the closest modern equivalent to Tez. Their architecture whitepaper and documentation are excellent. The key insight Ray shares with Tez: tasks produce objects, and downstream tasks consume those objects — the system handles transfer.
Spark Core (Scala)
Repository: github.com/apache/spark Best for: Comparing Tez's approach to the main competing engine
Spark's DAGScheduler plays the same role as Tez's DAGImpl + VertexImpl. Comparing the two is illuminating:
| Concept | Tez | Spark |
|---|---|---|
| Execution unit | DAG | Job |
| Stage | Vertex | Stage |
| Task | Task | Task |
| Shuffle | OrderedPartitionedKVOutput | ShuffleMapTask |
| Dynamic parallelism | ShuffleVertexManager | Adaptive Query Execution |
| Container reuse | AM-managed | Executor model (always reused) |
For Understanding the Shuffle Pipeline
There's no great "simple" version of a distributed shuffle, because it's inherently complex. The most readable reference implementations:
- Spark's
SortShuffleWriterandExternalSorter— same concepts as Tez'sOrderedPartitionedKVOutput, but in Scala with better comments - Hadoop MapReduce's
MapOutputBuffer— the original implementation that Tez's shuffle is based on (inhadoop-mapreduce-client-core)
Summary Comparison Table
| Project | Language | Lines of Code | What It Teaches | Learning Time |
|---|---|---|---|---|
Dask local.py | Python | ~400 | Minimal DAG executor algorithm | 1 hour |
| Luigi | Python | ~15k | Dependency resolution, scheduling, retry | 1 day |
| Prefect core | Python | ~50k | Task/flow model, modern state management | 1–2 days |
| Tez examples | Java | ~2k | Tez API surface, I/P/O model | 2 hours |
| Ray core | Python/C++ | Large | Distributed object-based task execution | 2–3 days |
| Spark DAGScheduler | Scala | ~5k | Stage-based DAG execution (Tez competitor) | 1 day |
Tez tez-dag | Java | ~70k | Production DAG AM with full state machines | 1 week+ |
Tez tez-runtime-library | Java | ~30k | Production shuffle/sort/merge pipeline | 1 week+ |
Recommended Learning Path
- Read Dask
local.py— understand the core algorithm (1 hour) - Read Luigi's
scheduler.py— add dependency resolution and retry (half day) - Read Tez
tez-examples/WordCount.java— see how Tez exposes the DAG API (1 hour) - Read Tez
tez-api/DAG.java+Vertex.java+Edge.java— the user-facing API (2 hours) - Generate Tez's state diagram — visualize the AM's state machine (30 min)
- Read Tez
DAGImpl.javawith the state diagram open — the AM core (half day) - Read Tez
OrderedPartitionedKVOutput.java— the shuffle producer (half day)
14. References
Official Resources
- Apache Tez Website: https://tez.apache.org
- GitHub Repository: https://github.com/apache/tez
- Tez Design Documents: https://cwiki.apache.org/confluence/display/TEZ
- How to Contribute: https://cwiki.apache.org/confluence/display/TEZ/How+to+Contribute+to+Tez
Key Papers & Talks
- "Apache Tez: Accelerating Hadoop Query Processing" — Bikas Saha, Arun Murthy (Hortonworks, 2013)
- "Hive + Tez: A Performance Deep Dive" — Jitendra Pandey, Gopal V
- InfoQ Article: "What is Apache Tez?" — https://www.infoq.com/articles/apache-tez-saha-murthy/
Source Code Reading Guides
- Tez source reading notes by @oza: https://gist.github.com/oza/470e961ff10b60778772
- BUILDING.txt in the repo: Module structure and build instructions
Simpler Alternatives (GitHub)
- Luigi: https://github.com/spotify/luigi
- Dask: https://github.com/dask/dask
- Prefect: https://github.com/PrefectHQ/prefect
- pydags: https://github.com/DavidTorpey/pydags
- simple-dag: https://github.com/leokster/simple_dag
- Dagu: https://github.com/dagucloud/dagu
- Ray: https://github.com/ray-project/ray
Source: AI Tools ·
ai_tools.md· updated 2026-05-19 · 🔒 secret gistSynced verbatim from gist.github.com/bl9.
AI Productivity Tips & Tools
A curated list of creative tools, workflows, and ideas to supercharge how you use AI.
Input & Capture
- Whisper-based dictation (Superwhisper, MacWhisper, Wispr Flow) — way better than macOS native, works system-wide via hotkey
- Text expanders (Espanso, Raycast, Alfred, aText) — save long prompts as snippets like
;reviewor;email - Karabiner-Elements — remap a key (like Caps Lock) to instantly trigger your AI tool
- Clipboard managers (Raycast, Paste, Maccy) — paste previous prompts/outputs without losing them
- Voice memos → AI — record rambling thoughts on a walk, transcribe, ask AI to organize
- Screenshot → AI — drag any screenshot in to ask about errors, charts, UIs, handwriting
- Apple Watch dictation → notes app → AI later — capture ideas anywhere
- Hotkey to send selected text to AI — most launcher apps support this
Launchers & Integration
- Raycast AI — invoke AI from anywhere on Mac with a hotkey, no app-switching
- Keyboard Maestro / Hammerspoon — automate any repetitive Mac task and chain AI calls into them
- Shortcuts app — build "share sheet" actions that send selected text to AI for summarizing, translating, rewriting
- AI browser extensions — summarize any page, ask questions about it
- Reader-mode → AI pipeline — clean article, then summarize
- Multi-monitor workflow — keep AI chat always open on a side screen
- Split-screen editing — draft in one window, AI critiques in another
Research & Learning
- NotebookLM — dump 20 PDFs in, ask questions with citations, no hallucinations beyond sources
- Perplexity — for research that needs current info with citations
- YouTube transcripts → AI — get the gist without watching
- Podcast summaries — Snipd, Airchat, or transcript → AI
- Reading mode — paste a dense article, ask for ELI5 or for the 3 key takeaways
- Learning loops — ask AI to quiz you on a topic after explaining it
- Flashcard generation — feed notes, get Anki-ready cards
- Spaced repetition prompts — schedule AI to quiz you on past learnings
- Book / movie summaries — decide if it's worth your time
- Reading list curator — describe interests, get recommendations with reasoning
Coding & Development
- Claude Code / Cursor / Windsurf — AI in your terminal/editor instead of copy-pasting
- Code explainer — paste any unfamiliar code, get a walkthrough
- Codebase Q&A — tools like Cursor or Claude Code can answer "where is X defined?"
- Documentation writer — point AI at code, get docs
- Test generator — paste a function, get unit tests
- Boilerplate killer — describe what you need, skip the scaffolding
- AI for regex / shell commands / SQL — describe what you want in plain English
- Error message decoder — paste any stack trace, get plain-English explanation
- Git commit messages — paste diff, get a clean commit message
- PR descriptions — same idea, from diff to description
- API exploration — paste docs, ask "how do I do X?"
- JSON / YAML / config translators — paste one format, get another
- Bulk file renaming / sorting — describe the pattern, get a script
Context Management
- Personal context doc — keep an "about me" file (role, projects, style) to paste at start of chats
- Project-specific context docs — one per ongoing project, paste when relevant
- Prompt library — Notion/Obsidian page with your best prompts, organized by use case
- Custom GPTs / Projects / Claude Projects — pre-loaded contexts for recurring tasks
- Memory / persistent context features — let AI remember preferences across chats
Workflow Patterns
- AI as a rubber duck — explain your stuck problem out loud, AI responds, often unblocks you
- "What am I missing?" prompts — ask AI what an expert would push back on
- Pre-mortems — describe a plan, ask AI what could go wrong before you commit
- Draft → critique → revise loop — iterate instead of trying to one-shot
- Two-AI workflow — use one AI to critique another's output
- AI as second opinion — before any important send, paste it and ask "anything I'm missing?"
- Decision frameworks — describe a choice, ask AI to lay out pros/cons or apply a framework
Writing & Communication
- Tone shifter — paste blunt draft, ask for "diplomatic version" or "casual version"
- Length compressor — "make this 50% shorter without losing key points"
- Length expander — "turn these bullets into a full paragraph in my voice"
- Refactoring prose — paste anything you wrote, ask for tightening
- Translation in real-time — paste any foreign text, get instant context
- AI for naming things — files, variables, projects, products — endless options instantly
- AI for emoji / formatting — never hunt for the right emoji again
- Markdown → anywhere — write in markdown, AI converts to whatever format you need
Email & Messaging
- Email triage — paste inbox subjects, ask AI to rank by urgency or draft replies
- Cold email drafting — paste recipient's bio, get personalized opener
- Slack / email tone-check — paste before sending, catch misreads
- Out-of-office / auto-reply drafting — context-specific responses
- Saying no gracefully — paste request, get polite decline
- Difficult conversation rehearsal — role-play with AI before the real talk
- Feedback drafting — turn harsh thoughts into constructive feedback
- Negotiation prep — describe situation, ask for likely counterarguments
Daily Productivity
- Morning planning — dump your todo list, ask AI to prioritize and time-block it
- End-of-day brain dump — dictate everything you did, ask AI to format it as a status update
- Calendar prep — paste your day's meetings, ask for prep notes and questions to ask
- Calendar invite descriptions — turn vague meeting into a clear agenda
- AI for meeting notes — Granola, Otter, Fireflies record and summarize automatically
- Onboarding docs — generate from your codebase or process notes
Career & Professional
- Resume / LinkedIn tailoring — paste job description, get tailored bullets
- Cover letter starter — never write one from scratch again
Data & Spreadsheets
- AI for spreadsheets — describe the formula you want in English
- Data cleaning — paste messy CSV rows, ask AI to standardize
- Chart suggestions — describe your data, ask what visualization fits best
Visuals & Design
- Image generation for placeholders, mockups, slide visuals (Midjourney, DALL-E, Ideogram)
- Image editing via prompt — Photoshop's generative fill, Canva's AI tools
- Background removal / upscaling — single-purpose AI tools beat manual editing
- AI presentation builders — Gamma, Tome for first-draft decks
Life Admin
- Travel planning — itinerary drafts in seconds, then refine
- Recipe adapter — "I have X, Y, Z in the fridge, what can I make?"
- Grocery list generator — from a week of planned meals
- Workout plans — describe equipment and goals, get a routine
- Habit tracking prompts — daily check-in via AI chat
- Journaling prompts — AI asks you reflective questions
Mindset
- Voice + dictation combo — talk while walking, edit when you sit down
- The compounding rule — every time something feels tedious, pause and ask if AI could do it
c_linked_list
Source: c_linked_list · updated 2024-08-19 · public gist
Synced verbatim from gist.github.com/bl9.
#include <stdio.h>
#include <stdlib.h>
typedef struct Node {
int data;
struct Node* next;
} Node;
typedef struct Stack {
Node* head;
} Stack;
void push(Stack *stack, int val) {
Node *newNode = (Node *)malloc(sizeof(Node));
newNode->data = val;
newNode->next = stack->head;
stack->head = newNode;
}
void pop(Stack *stack) {
Node *current = stack->head;
stack->head = current->next;
}
void printStack(Stack *stack) {
Node *current = stack->head;
while (current != NULL) {
printf("%d\n", current->data);
current = current->next;
}
}
void initStack(Stack *stack) { stack->head = NULL; }
int main() {
Stack s;
initStack(&s);
push(&s, 1);
push(&s, 2);
push(&s, 3);
push(&s, 4);
printStack(&s);
pop(&s);
printf("------------\n");
printStack(&s);
printf("------------\n");
push(&s, 5);
printStack(&s);
return 0;
}