{"course":{"slug":"context-engineering","title":"Context Engineering","url":"https://addaly.com/learn/context-engineering","level":"advanced","track":"build"},"lesson":{"slug":"context-assembly-as-a-function","title":"Making the assembler testable","url":"https://addaly.com/learn/context-engineering/context-assembly-as-a-function","position":9,"of":87,"minutes":9,"answer":"Make context assembly a pure function of injected inputs (no clock, no network, no hidden randomness) so the request can be snapshot-tested, reproduced from a log and versioned by a hash of its configuration.","keyIdea":"Make context assembly a pure function of injected inputs (no clock, no network, no hidden randomness) so the request can be snapshot-tested, reproduced from a log and versioned by a hash of its configuration."},"body":{"format":"markdown","text":"## The shape that makes everything else possible\n\nMost of the debugging pain in a context-heavy system comes from one design decision: the code that builds the request also fetches the material, calls the model, and parses the answer, all in one function. Nothing can be tested without a network, nothing can be reproduced, and the only way to see what the model received is to add a print statement and wait for the bug to happen again.\n\nThe fix is unglamorous and worth a lesson because it changes what is possible later.\n\n```python\n@dataclass(frozen=True)\nclass ContextInputs:\n    question: str\n    passages: list[Passage]     # already retrieved\n    history: list[Turn]         # already loaded\n    user: UserProfile\n    now: datetime               # injected, never called inside\n\ndef build_context(inp: ContextInputs, cfg: AssemblyConfig) -> Request:\n    \"\"\"Pure. No IO, no clock, no randomness. Same inputs, same bytes.\"\"\"\n```\n\nRetrieval happens outside. The clock is passed in. The function returns the request object it would send, and sends nothing.\n\n## What purity buys you\n\n**Snapshot tests.** You can assert on the exact assembled request in a test that runs in eight milliseconds and needs no API key.\n\n```python\ndef test_assembly_snapshot(snapshot):\n    req = build_context(FIXTURE, CFG)\n    assert render(req) == snapshot(\"support_v3.txt\")\n```\n\nWhen somebody changes a template, the diff appears in code review as text a human can read. This catches the class of bug that is otherwise invisible: a stray newline that multiplies across turns, a heading that stopped rendering, a passage separator that silently changed and broke the model's ability to tell one document from the next.\n\n**Reproducing a production failure.** Log the `ContextInputs` (or a reference to them) and you can rebuild the exact request locally from a trace. Without the separation you can log the final string, which helps, but you cannot re-run the assembler with a changed configuration to see whether your fix would have helped.\n\n**Cheap experiments.** Assembly variants become configuration rather than branches. Ordering, chunk count, whether the instruction is repeated at the end, how history is compacted, all of these become fields in `AssemblyConfig`, and an experiment is a different config value rather than a code change.\n\n**Honest token accounting.** The counter runs on the output of the assembler, which is the thing actually sent, rather than on an estimate built somewhere else that drifts out of sync within a fortnight.\n\n## The three things that break purity\n\nIn practice three impurities creep in, all with the same fix.\n\n1. **`datetime.now()` inside the builder.** Common, because you want to tell the model today's date. Inject it. Otherwise your snapshot test fails at midnight and somebody deletes the test.\n2. **A retrieval call inside the builder.** Convenient, and it makes every test require a network and a live index. Fetch above, pass the results down.\n3. **Randomness**: sampling which exemplars to include, shuffling passages to combat position bias. Legitimate techniques; pass the seed in.\n\nThe pattern is the same each time: move the non-determinism up to the caller, where it can be controlled by a test.\n\n## Version the configuration, not just the prompt\n\nAssembly is more than a template. Two requests can use the same words and behave differently because one retrieved eight passages and the other five. So the version identifier has to cover the whole configuration:\n\n```python\nAV = hashlib.sha256(\n    json.dumps(asdict(cfg), sort_keys=True).encode()\n).hexdigest()[:12]\n```\n\nPut `AV` on every log line and every stored evaluation result. When a metric moves next month, the first question (*what changed in assembly*) becomes answerable in one query instead of a git archaeology session.\n\n## Render for humans as well as models\n\nAdd one function nobody regrets writing:\n\n```python\ndef render(req: Request) -> str:\n    \"\"\"The assembled request as readable text, with a token count per block.\"\"\"\n```\n\nWire it to a debug route, a CLI flag, or a file dropped next to a failed test. The first time you read your own assembled context end to end you will find something wrong with it. Everyone does. Common finds: a passage included twice because two chunks overlapped, a header repeated eleven times, a tool description that says \"TODO: describe this\", and history entries containing the model's own previous errors being carried forward as though they were facts.\n\n## The boundary this creates\n\nOnce assembly is a pure function, the system divides cleanly into three parts you can reason about separately: **gathering** (retrieval, database reads, tool calls: all IO, all mockable), **assembly** (pure, tested, versioned), and **calling** (the model, retries, streaming, parsing).\n\nEach of the remaining modules in this course lives mostly in one of those. Selection and retrieval are gathering. Ordering, caching and trust boundaries are assembly. Measurement spans all three, and it only works because the middle one is deterministic."},"check":{"question":"A team's `build_prompt()` calls the vector index internally and stamps `datetime.now()` into the system prompt. Which consequence follows most directly from those two choices?","options":[{"text":"The system prompt becomes untrusted, because injected values cannot be verified.","correct":false,"tempting":"Confuses trust with determinism; a timestamp and a retrieval result are both the team's own data, and the problem is reproducibility."},{"text":"Assembly cannot be snapshot-tested or reproduced from a log, and the changing timestamp also breaks prefix caching for everything below it.","correct":true},{"text":"Retrieved passages will be positioned incorrectly relative to the instructions.","correct":false,"tempting":"Assumes an ordering bug that neither choice implies; position is decided by the template, not by where retrieval is called."},{"text":"Token counts become unreliable because the index returns variable-length passages.","correct":false,"tempting":"Variable passage length is normal and countable; the counter runs on the assembled request regardless of where the passages came from."}]},"prev":{"slug":"effective-context-versus-advertised","title":"The advertised window and the usable one","url":"https://addaly.com/learn/context-engineering/effective-context-versus-advertised"},"next":{"slug":"what-breaks-when-it-overflows","title":"What actually happens when it does not fit","url":"https://addaly.com/learn/context-engineering/what-breaks-when-it-overflows"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Making the assembler testable', in Context Engineering. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/context-engineering/context-assembly-as-a-function (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Making the assembler testable","courseTitle":"Context Engineering","module":{"index":1,"slug":"the-window-as-a-surface","title":"The window as an engineering surface"},"answer":null,"keyIdea":"Make context assembly a pure function of injected inputs (no clock, no network, no hidden randomness) so the request can be snapshot-tested, reproduced from a log and versioned by a hash of its configuration.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-shape-that-makes-everything-else-possible","runs":[{"text":"The shape that makes everything else possible"}]},{"t":"p","runs":[{"text":"Most of the debugging pain in a context-heavy system comes from one design decision: the code that builds the request also fetches the material, calls the model, and parses the answer, all in one function. Nothing can be tested without a network, nothing can be reproduced, and the only way to see what the model received is to add a print statement and wait for the bug to happen again."}]},{"t":"p","runs":[{"text":"The fix is unglamorous and worth a lesson because it changes what is possible later."}]},{"t":"code","lang":"python","label":"Python","text":"@dataclass(frozen=True)\nclass ContextInputs:\n    question: str\n    passages: list[Passage]     # already retrieved\n    history: list[Turn]         # already loaded\n    user: UserProfile\n    now: datetime               # injected, never called inside\n\ndef build_context(inp: ContextInputs, cfg: AssemblyConfig) -> Request:\n    \"\"\"Pure. No IO, no clock, no randomness. Same inputs, same bytes.\"\"\""},{"t":"p","runs":[{"text":"Retrieval happens outside. The clock is passed in. The function returns the request object it would send, and sends nothing."}]},{"t":"h2","id":"what-purity-buys-you","runs":[{"text":"What purity buys you"}]},{"t":"p","runs":[{"text":"Snapshot tests.","b":true},{"text":" You can assert on the exact assembled request in a test that runs in eight milliseconds and needs no API key."}]},{"t":"code","lang":"python","label":"Python","text":"def test_assembly_snapshot(snapshot):\n    req = build_context(FIXTURE, CFG)\n    assert render(req) == snapshot(\"support_v3.txt\")"},{"t":"p","runs":[{"text":"When somebody changes a template, the diff appears in code review as text a human can read. This catches the class of bug that is otherwise invisible: a stray newline that multiplies across turns, a heading that stopped rendering, a passage separator that silently changed and broke the model’s ability to tell one document from the next."}]},{"t":"p","runs":[{"text":"Reproducing a production failure.","b":true},{"text":" Log the "},{"text":"ContextInputs","code":true},{"text":" (or a reference to them) and you can rebuild the exact request locally from a trace. Without the separation you can log the final string, which helps, but you cannot re-run the assembler with a changed configuration to see whether your fix would have helped."}]},{"t":"p","runs":[{"text":"Cheap experiments.","b":true},{"text":" Assembly variants become configuration rather than branches. Ordering, chunk count, whether the instruction is repeated at the end, how history is compacted, all of these become fields in "},{"text":"AssemblyConfig","code":true},{"text":", and an experiment is a different config value rather than a code change."}]},{"t":"p","runs":[{"text":"Honest token accounting.","b":true},{"text":" The counter runs on the output of the assembler, which is the thing actually sent, rather than on an estimate built somewhere else that drifts out of sync within a fortnight."}]},{"t":"h2","id":"the-three-things-that-break-purity","runs":[{"text":"The three things that break purity"}]},{"t":"p","runs":[{"text":"In practice three impurities creep in, all with the same fix."}]},{"t":"ol","start":1,"items":[[{"text":"datetime.now()","b":true,"code":true},{"text":" inside the builder.","b":true},{"text":" Common, because you want to tell the model today’s date. Inject it. Otherwise your snapshot test fails at midnight and somebody deletes the test."}],[{"text":"A retrieval call inside the builder.","b":true},{"text":" Convenient, and it makes every test require a network and a live index. Fetch above, pass the results down."}],[{"text":"Randomness","b":true},{"text":": sampling which exemplars to include, shuffling passages to combat position bias. Legitimate techniques; pass the seed in."}]]},{"t":"p","runs":[{"text":"The pattern is the same each time: move the non-determinism up to the caller, where it can be controlled by a test."}]},{"t":"h2","id":"version-the-configuration-not-just-the-prompt","runs":[{"text":"Version the configuration, not just the prompt"}]},{"t":"p","runs":[{"text":"Assembly is more than a template. Two requests can use the same words and behave differently because one retrieved eight passages and the other five. So the version identifier has to cover the whole configuration:"}]},{"t":"code","lang":"python","label":"Python","text":"AV = hashlib.sha256(\n    json.dumps(asdict(cfg), sort_keys=True).encode()\n).hexdigest()[:12]"},{"t":"p","runs":[{"text":"Put "},{"text":"AV","code":true},{"text":" on every log line and every stored evaluation result. When a metric moves next month, the first question ("},{"text":"what changed in assembly","i":true},{"text":") becomes answerable in one query instead of a git archaeology session."}]},{"t":"h2","id":"render-for-humans-as-well-as-models","runs":[{"text":"Render for humans as well as models"}]},{"t":"p","runs":[{"text":"Add one function nobody regrets writing:"}]},{"t":"code","lang":"python","label":"Python","text":"def render(req: Request) -> str:\n    \"\"\"The assembled request as readable text, with a token count per block.\"\"\""},{"t":"p","runs":[{"text":"Wire it to a debug route, a CLI flag, or a file dropped next to a failed test. The first time you read your own assembled context end to end you will find something wrong with it. Everyone does. Common finds: a passage included twice because two chunks overlapped, a header repeated eleven times, a tool description that says “TODO: describe this”, and history entries containing the model’s own previous errors being carried forward as though they were facts."}]},{"t":"h2","id":"the-boundary-this-creates","runs":[{"text":"The boundary this creates"}]},{"t":"p","runs":[{"text":"Once assembly is a pure function, the system divides cleanly into three parts you can reason about separately: "},{"text":"gathering","b":true},{"text":" (retrieval, database reads, tool calls: all IO, all mockable), "},{"text":"assembly","b":true},{"text":" (pure, tested, versioned), and "},{"text":"calling","b":true},{"text":" (the model, retries, streaming, parsing)."}]},{"t":"p","runs":[{"text":"Each of the remaining modules in this course lives mostly in one of those. Selection and retrieval are gathering. Ordering, caching and trust boundaries are assembly. Measurement spans all three, and it only works because the middle one is deterministic."}]}],"check":{"question":[{"text":"A team’s "},{"text":"build_prompt()","code":true},{"text":" calls the vector index internally and stamps "},{"text":"datetime.now()","code":true},{"text":" into the system prompt. Which consequence follows most directly from those two choices?"}],"options":[{"text":[{"text":"The system prompt becomes untrusted, because injected values cannot be verified."}],"correct":false,"tempting":[{"text":"Confuses trust with determinism; a timestamp and a retrieval result are both the team’s own data, and the problem is reproducibility."}]},{"text":[{"text":"Assembly cannot be snapshot-tested or reproduced from a log, and the changing timestamp also breaks prefix caching for everything below it."}],"correct":true,"tempting":null},{"text":[{"text":"Retrieved passages will be positioned incorrectly relative to the instructions."}],"correct":false,"tempting":[{"text":"Assumes an ordering bug that neither choice implies; position is decided by the template, not by where retrieval is called."}]},{"text":[{"text":"Token counts become unreliable because the index returns variable-length passages."}],"correct":false,"tempting":[{"text":"Variable passage length is normal and countable; the counter runs on the assembled request regardless of where the passages came from."}]}],"order":[3,1,0,2],"why":null},"prev":{"slug":"effective-context-versus-advertised","title":"The advertised window and the usable one","minutes":10},"next":{"slug":"what-breaks-when-it-overflows","title":"What actually happens when it does not fit","minutes":8}}}