Module 08 Exercise Answers¶
Use these answers only after running the corresponding proof. Compare reasoning, failure analysis, and architectural boundaries rather than copying assertions.
Async plans: prove fresh execution without promising equal effects¶
Calling an async def function creates a coroutine object. A coroutine object is
single-use, so caching it and returning it twice makes the second await fail with
RuntimeError: cannot reuse already awaited coroutine.
The defensible shape is a callable such as lambda: read_sequence().
async_lift stores that callable as the plan. Construction does not call it;
each plan() call asks it for a new coroutine.
Common wrong turns:
- creating
coroutine = read_sequence()outside the plan performs premature coroutine allocation and makes replay invalid; - asserting the two results are equal confuses replayability with deterministic effect output;
- driving the plan from domain code erases the interpreter boundary;
- testing only
callable(plan)says nothing about deferral.
The test proves zero calls during construction and one fresh execution per plan call. It does not prove the external capability is deterministic, idempotent, or safe to retry.
In FuncPipe, the shell may replay an async description when policy allows it. Whether the underlying storage or embedding effect may be repeated remains a separate domain decision.
Async generators: observe a partial pull¶
async_gen_map returns a new AsyncGen; it does not iterate its input. The
mapped iterator requests one source result for each downstream anext, applies
the pure mapping only to Ok, and yields that result. A four-value source
consumed three times must therefore produce a three-entry pull trace.
Closing the iterator is part of the proof. It states that the consumer owns the active traversal and is responsible for ending partial consumption. The description remains replayable because the source is a callable that creates a new async iterator.
Common wrong turns:
- list-comprehending the output consumes every source value and hides the demand boundary;
- asserting only mapped output misses eager upstream work;
- omitting
acloseteaches unsafe partial-consumption habits; - returning one cached async generator object destroys replayability.
The proof establishes demand-driven evaluation for this composition. It does not establish bounded parallelism; Core 3 adds an explicit concurrency policy for that separate concern.
Backpressure: expose the bounded window¶
The worker increments its active counter immediately before the await and
decrements it immediately after. A scheduling yield allows several calls to
overlap without introducing real-time delays. maximum_active therefore
captures the widest observed window directly.
With six inputs and max_concurrent=3, the bounded combinator starts no fourth
worker until one of the first three releases capacity. In ordered mode it also
buffers completions by input index, so the resulting values retain input order
even if individual workers finish differently.
Common wrong turns:
- checking only output order says nothing about bounded fan-out;
- sleeping for fixed real durations makes the proof slow and timing-sensitive;
- asserting exact task scheduling order promises more than the policy;
- putting a semaphore inside every worker creates separate limits instead of one shared stream limit.
The policy is immutable description data. The semaphore, tasks, active counter,
and work all come into existence only when the shell drives the returned
AsyncGen.
Retry policy: keep repetition at the embedding boundary¶
Predict the calls before writing code:
| Document | Cleaning calls | Embedding attempts | Final position |
|---|---|---|---|
recovers |
1 | 2 | ok:recovers |
exhausts |
1 | 2 | err:MAX_RETRIES |
after |
1 | 1 | ok:after |
Cleaning happens before the resilient mapper, so an embedding retry cannot
re-enter clean_doc. The mapper owns one plan per chunk and repeats only that
plan. Exhaustion is emitted as a value, allowing the bounded stream to request
the next prepared chunk.
A defensible proof is:
def test_embedding_retry_review() -> None:
docs = [
RawDoc("recovers", "Recovers", "first", "demo"),
RawDoc("exhausts", "Exhausts", "second", "demo"),
RawDoc("after", "After", "third", "demo"),
]
cleaned: list[str] = []
attempts: dict[str, int] = {}
def tracked_clean(doc: RawDoc) -> CleanDoc:
cleaned.append(doc.doc_id)
return clean_doc(doc)
def scheduled_embedder(chunk: ChunkWithoutEmbedding) -> AsyncPlan[Chunk]:
async def run() -> Result[Chunk, ErrInfo]:
attempt = attempts.get(chunk.doc_id, 0) + 1
attempts[chunk.doc_id] = attempt
if chunk.doc_id == "recovers" and attempt == 1:
return Err(ErrInfo(code="TRANSIENT", msg="retry once"))
if chunk.doc_id == "exhausts":
return Err(ErrInfo(code="TRANSIENT", msg="still unavailable"))
return Ok(embed_chunk(chunk))
return run
resilient_embedder = resilient_mapper(
scheduled_embedder,
RetryPolicy(
max_attempts=2,
retriable_codes=frozenset({"TRANSIENT"}),
backoff_base_ms=0,
jitter_factor=0,
),
env=make_test_resilience_env(),
)
stream = async_rag_chunks(
async_gen_from_list(docs),
RagEnv(chunk_size=32),
tracked_clean,
resilient_embedder,
BackpressurePolicy(max_concurrent=1, ordered=True),
)
assert attempts == {}
async def collect() -> list[Result[Chunk, ErrInfo]]:
return [item async for item in stream()]
results = asyncio.run(collect())
labels = [
f"ok:{item.value.doc_id}"
if isinstance(item, Ok)
else f"err:{item.error.code}"
for item in results
]
assert labels == [
"ok:recovers",
"err:MAX_RETRIES",
"ok:after",
]
assert cleaned == ["recovers", "exhausts", "after"]
assert attempts == {"recovers": 2, "exhausts": 2, "after": 1}
assert isinstance(results[1], Err)
assert results[1].error.ctx is not None
assert results[1].error.ctx["attempts"] == 2
The recovered TRANSIENT is an intermediate interpreter observation, not a
stream output. The exhausted item is different: policy has no permitted attempt
left, so it produces MAX_RETRIES and preserves the last failure in its
context.
The last Ok proves continuation. Without it, a broken short-circuiting stream
could still satisfy the exhaustion assertions.
Common wrong turns:
- wrapping
async_rag_chunksin a retry loop repeats source, cleaning, and already successful embedding work; - using
max_attempts=3while expecting only three retries confuses total attempts with additional attempts; - returning the last
TRANSIENTafter exhaustion contradicts the shipped wrapper contract; - incrementing the cleaning trace inside the embedder stops proving the boundary; and
- treating
idempotent=Trueas enforcement ignores the external effect's real contract.
This proof establishes local repetition, bounded attempts, ordered output, and continuation for deterministic fakes. It does not establish that a remote embedding request is idempotent, that retries improve availability, or that a backoff value is operationally appropriate.
Architecturally, the composition remains:
one clean/chunk pass
↓
one bounded worker slot
↓
one resilient plan with up to two embedding attempts
↓
one visible Result position
Deterministic testing: defend the boundary of a fake timeout¶
The fake timeout uses >=, so reaching exactly 0.050 seconds crosses a
50 ms deadline. Each attempt gets a new timeout context. The clock itself keeps
its current value, but the after attempt records its deadline relative to that
current value and completes without advancing it.
One complete implementation is:
def test_embedding_timeout_review() -> None:
def run_once() -> tuple[list[str], list[str], float]:
clock = FakeClock()
attempted: list[str] = []
def timed_embedder(chunk: ChunkWithoutEmbedding) -> AsyncPlan[Chunk]:
async def run() -> Result[Chunk, ErrInfo]:
attempted.append(chunk.doc_id)
if chunk.doc_id == "deadline":
clock.advance_s(0.050)
return Ok(embed_chunk(chunk))
return run
resilient_embedder = resilient_mapper(
timed_embedder,
RetryPolicy(max_attempts=1),
TimeoutPolicy(timeout_ms=50),
env=make_test_resilience_env(clock=clock),
timeout_ctx=make_fake_timeout_ctx(clock),
)
stream = async_rag_chunks(
async_gen_from_list(
[
RawDoc("before", "Before", "first", "demo"),
RawDoc("deadline", "Deadline", "second", "demo"),
RawDoc("after", "After", "third", "demo"),
]
),
RagEnv(chunk_size=32),
clean_doc,
resilient_embedder,
BackpressurePolicy(max_concurrent=1, ordered=True),
)
async def collect() -> list[Result[Chunk, ErrInfo]]:
return [item async for item in stream()]
labels = [
f"ok:{item.value.doc_id}"
if isinstance(item, Ok)
else f"err:{item.error.code}"
for item in asyncio.run(collect())
]
return labels, attempted, clock.now_s()
first = run_once()
second = run_once()
assert first == second
assert first == (
["ok:before", "err:TIMEOUT", "ok:after"],
["before", "deadline", "after"],
0.050,
)
The deadline-crossing embedder computes an Ok, but FakeTimeout.__aexit__
observes the crossed deadline and raises before the wrapper can return that
value. The resilience interpreter translates the exception to TIMEOUT.
after remains visible because timeout is local to one resilient plan. Its
deadline begins at logical time 0.050 and the plan does not advance the clock.
The two required conclusions are:
The fake proves that the resilience policy translates a logically crossed per-attempt deadline into
Err(TIMEOUT)while preserving later stream work.The fake does not prove that a production task receives cancellation at 50 ms or that its resources are released correctly.
Moving clock outside run_once mutates the second execution's initial state.
Even if the labels happened to remain equal, the test would no longer be a
replay from equivalent inputs.
Common wrong turns:
- sharing the clock, attempt list, or stream description across runs;
- injecting
FakeClockbut forgetting to pass its timeout context; - asserting elapsed wall time in addition to logical time;
- interpreting context-exit checking as task interruption; and
- using this unit test as evidence for provider or resource cancellation.
This proof establishes repeatable policy translation, exact boundary behavior, and application continuation. A separate controlled integration test using the real timeout mechanism is needed for cancellation and cleanup.
Fairness: inspect a weighted prefix¶
With weights {0: 1, 1: 2}, the selector compares emitted-count divided by
weight and breaks equal ratios by lower stream index. Over six selections, the
tags are A, B, B, A, B, B: two positions for A and four for B. Both streams
are assumed to remain ready throughout the observation.
Common wrong turns:
- checking only final counts misses a starving prefix;
- adding sleeps changes readiness and tests the event loop instead of policy;
- leaving an infinite iterator open abandons both source iterators;
- claiming six values prove long-run proportionality overstates the example.
The small trace explains the selection rule. Property tests provide broader evidence across weights and prefix lengths. Rate limiting can then wrap the fair merge to cap total throughput without changing which ready stream owns each position.
Async adapters: preserve the synchronous core¶
lift_sync returns a function that captures arguments in an AsyncPlan.
Neither lifting nor applying that function executes the synchronous core. Each
plan call performs the work and catches exceptions at the adapter boundary.
For a positive and a negative input, build both plans first and assert zero
calls. Driving the positive plan produces its original Ok; driving the
negative plan increments the trace again and returns Err(ErrInfo(...)) with
code UNEXPECTED and the exception message.
Common wrong turns:
- invoking the core to create a
Resultbefore lifting loses deferral; - making the core
async defintroduces the async creep this adapter prevents; - wrapping the test in
try/exceptaccepts an escaping exception; - sending trivial work to an executor adds scheduling overhead without benefit.
The public lift_sync import is preserved in later snapshots even though Module
09 consolidates its implementation into plan.py. Learner code depends on the
stable capability, not its internal file location.
Service integrations: release a partial stream¶
The async context manager appends enter only when iteration begins. Each
anext advances the response generator once. Calling aclose on the outer
iterator unwinds async_gen_using, which exits the context manager and appends
exit.
For two requested values, the expected trace is therefore enter, pull:0,
pull:1, exit. No later pull is permitted because downstream demand ended.
Common wrong turns:
- opening the session when constructing the description performs an eager external effect;
- consuming to a list defeats the partial-demand proof;
- omitting
aclosedelegates correctness to garbage collection; - creating a fresh long-lived HTTP client per item confuses shell and stream ownership.
The fake records the resource protocol directly. Provider-specific status mapping and transport behavior remain the responsibility of focused adapter integration tests.
Async chunking: preserve values around errors¶
With max_units=1, each successful integer forms a singleton batch. If the
source is successes 1 and 2, the error, then successes 3 and 4, the output is
Ok([1]), Ok([2]), Err(failure), Ok([3]), Ok([4]).
The error is yielded through the existing Result channel. Because
flush_on_err=True, any non-empty buffer must be emitted before the error.
Afterward, normal chunk accumulation resumes; the stream does not short-circuit.
Common wrong turns:
- wrapping the error in
Ok([error])changes the element type and semantics; - stopping after the error loses valid later values;
- ignoring the partial buffer loses valid earlier values;
- using real time adds no evidence when
max_delay_ms=0.
The full sequence demonstrates order and completeness for this failure route. Separate property tests generalize those laws over input lists, batch sizes, and logical-time schedules.
Pipeline laws: generalize map composition¶
The left description applies str to each success and then applies len. The
right description applies their composition,
lambda value: len(str(value)), once. Both preserve the same stream order,
cardinality, and Result structure.
The empty generated list matters: both descriptions must produce an empty result without attempting either function. Signed and multi-digit integers exercise lengths that a single positive example would miss.
Common wrong turns:
- generating separate inputs for the two sides invalidates the comparison;
- extracting only success values hides differences in error propagation;
- using an effectful mapping function makes replay order observable and leaves the pure composition law's preconditions;
- testing one literal list provides an example, not broad evidence.
This local law supports refactoring adjacent pure maps into a composed map. Operational claims such as no duplicate external writes still require stable keys, idempotent adapters, deterministic schedules, and their own properties.
Cumulative lab: review the FuncPipe async indexing policy¶
Start with the value flow, not the scheduler. Filtering is the only event that
deliberately produces no stream position. A source failure is already a
Result, validation translates an exception into a Result, and the mapper
returns its embedding failure as a Result. Ordered coordination must therefore
preserve all three errors between the two successful chunks.
| Input event | Cleaner called? | Embedder called? | Output position |
|---|---|---|---|
| valid before | yes | yes | ok:before |
| filtered document | no | no | none |
source Err |
no | no | err:SOURCE |
| validation failure | yes | no | err:UNEXPECTED |
embedding Err |
yes | yes | err:EMBED |
| valid after | yes | yes | ok:after |
One complete proof is:
def test_async_indexing_policy_review() -> None:
source_failure = ErrInfo(code="SOURCE", msg="unreadable row")
async def source_items() -> AsyncIterator[Result[RawDoc, ErrInfo]]:
yield Ok(RawDoc("before", "Before", "valid", "keep"))
yield Ok(RawDoc("filtered", "Filtered", "skip me", "skip"))
yield Err(source_failure)
yield Ok(RawDoc("invalid", "Invalid", "reject", "keep"))
yield Ok(RawDoc("embed-fail", "Embed fail", "valid", "keep"))
yield Ok(RawDoc("after", "After", "still valid", "keep"))
cleaned: list[str] = []
def clean_or_reject(doc: RawDoc) -> CleanDoc:
cleaned.append(doc.doc_id)
if doc.doc_id == "invalid":
raise ValueError("document failed validation")
return clean_doc(doc)
active = 0
maximum_active = 0
embedded: list[str] = []
def embed_later(chunk: ChunkWithoutEmbedding) -> AsyncPlan[Chunk]:
async def run() -> Result[Chunk, ErrInfo]:
nonlocal active, maximum_active
embedded.append(chunk.doc_id)
active += 1
maximum_active = max(maximum_active, active)
await asyncio.sleep(0)
active -= 1
if chunk.doc_id == "embed-fail":
return Err(ErrInfo(code="EMBED", msg="provider rejected chunk"))
return Ok(embed_chunk(chunk))
return run
stream = async_rag_chunks(
source_items,
RagEnv(chunk_size=32),
clean_or_reject,
embed_later,
BackpressurePolicy(max_concurrent=2, ordered=True),
keep=lambda doc: doc.categories != "skip",
)
async def collect() -> list[Result[Chunk, ErrInfo]]:
return [item async for item in stream()]
results = asyncio.run(collect())
labels = [
f"ok:{item.value.doc_id}"
if isinstance(item, Ok)
else f"err:{item.error.code}"
for item in results
]
assert labels == [
"ok:before",
"err:SOURCE",
"err:UNEXPECTED",
"err:EMBED",
"ok:after",
]
assert maximum_active == 2
assert cleaned == ["before", "invalid", "embed-fail", "after"]
assert embedded == ["before", "embed-fail", "after"]
The three trace assertions divide ownership clearly. cleaned proves that the
filter acts before the synchronous core. embedded proves that source and
validation failures bypass scheduling. maximum_active proves that the
application actually exercises the declared concurrency ceiling, rather than
merely constructing a policy object.
The output assertion is the continuity proof. In ordered mode, every emitted
Result retains the source-relative position it acquired before or during
embedding. With ordered=False, successes and embedding failures may be
observed in completion order; reviewers would then need stable coordinates to
relate each result to its input instead of reading that relationship directly
from the sequence.
Common wrong turns:
- making the filtered document return an
Errchanges a selection rule into a validation failure; - catching errors around collection hides the typed failure channel;
- asserting only
maximum_active <= 2can pass even if no overlap occurs; - using a real embedding service turns a coordination proof into an integration test; and
- duplicating cleaning or chunking inside the fake embedder stops proving that the synchronous core remains authoritative.
This proof establishes deferral, filtering order, bounded overlap, ordered continuity, and typed failure preservation for this controlled application path. It does not establish provider rate limits, network cancellation, retry safety, durable writes, or semantic embedding quality.