Module 02 Exercise Answers¶
These answers show defensible review routes. Keep your own names when they express the same ownership and behavior more clearly.
Answer 1: Classify the API inputs¶
| Value | Classification | Owner | Why |
|---|---|---|---|
docs |
run input | caller | changes every invocation |
RagEnv |
policy data | configuration | chunk and sample sizes |
RulesConfig |
policy data | configuration | inspectable keep predicate |
CleanConfig |
policy data | configuration | ordered cleaning rule names |
| cleaner | pure capability | composition root | behavior constructed from cleaning policy |
| embedder | pure capability | composition root | replaceable deterministic behavior |
| taps | observation effects | composition root | callbacks mutate external observation state |
Putting a reader in RagConfig would mix a live effect capability with comparable
policy. Putting chunk size in dependencies would hide a domain policy among
functions. A useful grouping explains ownership; it does not merely reduce the
parameter count.
The common wrong turn is classifying frozen dataclasses as “pure” and callables as “impure.” A callable embedder may be pure, while a frozen object can reference mutable state. Review behavior and ownership.
This table proves a coherent API model on paper. It does not prove the implementations obey their contracts. The Module 01 compatibility and tap-neutrality tests supply behavioral evidence.
Answer 2: Add a cleaning policy¶
Add one pure function and registry entry:
def casefold_text(text: str) -> str:
return text.casefold()
RULES: dict[str, TextRule] = {
# existing rules
"casefold": casefold_text,
}
Keep DEFAULT_CLEAN_CONFIG unchanged. Then test direct behavior and composition:
assert casefold_text("Straße") == "strasse"
cfg = CleanConfig(("strip", "casefold"))
assert clean_abstract(" Straße ", cfg) == "strasse"
The existing boundary_rag_config registry check will accept the new name and still
reject unknown names. Keep the numeric laws in the same test group:
| Input | Required outcome |
|---|---|
{"chunk_size": 4, "clean_rules": ["strip", "casefold"]} |
Ok with the ordered tuple |
{"chunk_size": True} |
Err mentioning bool |
{"chunk_size": 0} or {"chunk_size": -1} |
Err requiring a positive integer |
{"clean_rules": ["strip", "unknown"]} |
Err before dependency selection |
boundary_app_config should split CLI rule syntax and delegate the resulting names
to boundary_rag_config. Do not add a second allowlist or construct
CleanConfig directly. A CLI test with an unknown name should return Err before
any input path is opened.
Common wrong turns:
- replacing
"lower"in the default changes Module 01 behavior; - storing
str.casefolddirectly in serialized config loses a durable external name; - converting rule names to a set destroys order;
- catching
KeyErrorduring cleaning delays boundary validation.
The tests prove deterministic rule behavior, order, and boundary selection. They do not prove casefolding is the correct retrieval policy for every language. The architecture keeps that choice in data so application owners can decide.
Answer 3: Compare rule representations¶
function_rule = rule_and(
category_startswith("cs."),
abstract_min_len(4),
)
predicate_data = All(
(
StartsWith("categories", "cs."),
LenGt("abstract", 3),
)
)
parsed_rule = parse_rule(
'd.categories.startswith("cs.") and len(d.abstract) >= 4'
)
LenGt(..., 3) matches “at least four”; using LenGt(..., 4) would require five.
Test all three against the same accepted, wrong-category, and short-abstract
documents.
| Property | Function | Predicate data | Guarded text |
|---|---|---|---|
| execute directly | yes | through interpreter | after parsing |
| structural equality | no useful value equality | yes | source strings only |
| inspect policy fields | closure introspection only | yes | AST after parsing |
| easy serialization | no | yes with a codec | yes |
| untrusted-input surface | avoid | codec validation | strict parser and whitelist |
The common wrong turn is calling the guarded string “safe Python.” It is a tiny interpreted language, not arbitrary Python. Another is assuming all three representations must exist in every application.
Agreement on fixtures proves equivalent behavior for those cases. It does not prove the languages remain equivalent after adding features. The simplest representation that satisfies inspection and boundary needs is usually best.
Answer 4: Prove boundary short-circuiting¶
Use a reader plus recording core capabilities:
events: list[str] = []
class FailingReader:
def read_docs(self, path: str):
events.append("read")
return Err("source unavailable")
def forbidden_cleaner(doc):
events.append("clean")
raise AssertionError("core must not run")
def forbidden_embedder(chunk):
events.append("embed")
raise AssertionError("core must not run")
Build RagCoreDeps with those functions and call full_rag_api_path. Assert the
result equals the original Err and events == ["read"]. A successful reader test
should assert read precedes cleaning and embedding.
Also record whether functions passed to result_map and result_and_then execute.
For Err, they must not.
The common wrong turn is wrapping the entire route in try/except Exception and
returning one generic error. That hides programmer failures from the test and erases
the expected boundary distinction.
This proves synchronous short-circuiting and preservation of the failure value. It does not prove retry, resource closure, logging, or asynchronous cancellation. Those are later policy layers around the same boundary.
Now use test_module_02_filesystem_boundary.py as the shape for concrete adapter
evidence. Its cases divide by observable state:
| Case | Assertion that matters |
|---|---|
| valid CSV | decoded JSONL equals serialized full_rag_api_docs chunks in order |
| header only | Ok has zero totals and output is an empty file |
| malformed row | load Err and no output path |
| missing input | load Err and no output path |
| unavailable output parent | core event occurs, then write Err, with no output |
The write-failure case wraps the module's full_rag_api call with a recording
function and still uses the real reader and writer. This makes “after core work” an
observed event rather than an inference from the implementation.
The fake proof and filesystem proof are complementary. A fake precisely proves
short-circuit control flow. Temporary files prove open, CSV parsing, JSON
serialization, and output-state behavior. Neither proves retries, atomic
replacement, or cleanup after a partial write.
Answer 5: Add neutral stage observation¶
Store bounded identifiers and counts:
events: list[tuple[str, int]] = []
sample_ids: list[str] = []
def observe_docs(values: tuple[RawDoc, ...]) -> None:
events.append(("docs", len(values)))
remaining = max(0, 5 - len(sample_ids))
sample_ids.extend(doc.doc_id for doc in values[:remaining])
taps = RagTaps(
docs=observe_docs,
cleaned=lambda values: events.append(("cleaned", len(values))),
chunks=lambda values: events.append(("chunks", len(values))),
)
Run the same replayable documents with and without taps. Assert complete tuple
equality of (chunks, observations), exact event order, and len(sample_ids) <= 5.
The chunk tap count is pre-deduplication; compare it with final
observations.total_chunks rather than assuming equality.
Common wrong turns:
- retaining whole
RawDocvalues when IDs answer the question; - filtering inside a tap;
- appending every observed value to an unbounded list;
- describing a callback that may raise as “non-intrusive.”
The test proves neutrality for the fixture and bounds the local ID sample. It does not prove identifiers are non-sensitive, callbacks are thread-safe, or external metrics storage is bounded. Taps remain explicit effect dependencies outside the pure RAG transformations.
Final self-review¶
If a solution proves only final chunk counts, inspect text, metadata, embeddings, and order. If it proves only pure values, add boundary failure and observation evidence. Module 02 is complete only when policy values and capabilities make the same Module 01 application easier to configure and review without changing its default meaning.
Run both evidence owners from the repository root: