Candidate pool
A scope's candidates are held as columns (CandidatePool), not as
list[NormalizedCandidate]. The Stellantis and BMW normalizers emit those
columns directly, so no object exists between the source frame and the pool.
Mercedes validates per row and assembles the pool from the objects — see
Construction.
NormalizedCandidate remains the boundary type, and the boundary is the
recommendation: strategies.py calls materialize for the handful of
candidates a round selects, and that is what services/ai_ordering receives.
Why
At national scope the candidates are the largest thing an ordering run holds. One
Pydantic model per candidate plus one per attribute, for 50,000 candidates of 18
attributes, measures 656 MB resident. A Lambda invocation was killed at
Max Memory Used: 8192 MB of 8192.
What it costs
The same candidates both ways — ids, attributes, option codes, feature ids and metadata included:
| candidates | objects (getsizeof) | objects (peak RSS) | pool | reduction |
|---|---|---|---|---|
| 10,000 | 72.9 MB | 130.7 MB | 4.3 MB | 17.0x / 30.5x |
| 50,000 | 364.6 MB | 655.8 MB | 21.4 MB | 17.0x / 30.6x |
Two instruments, because they answer different questions. A recursive
sys.getsizeof is what a test can assert. Peak RSS is what the Lambda runs out
of, and it is higher because getsizeof sees neither allocator overhead nor
Pydantic's internals. Plan against that column. Both scale linearly.
estimated_size() reports frame buffers. It excludes the category map the
encoded columns index into, which polars shares across columns and which is
~1.6 MB for a 50,000-candidate pool. Sizing that map per column overstates it
several times over: Series.cat.get_categories() returns the shared map, so it
reports every category in the pool for a column holding one distinct value.
Shape
One long frame per thing a candidate has many of — attributes, options,
features — against scalars, which carries one row per candidate and its
metadata. Long form is what makes the reduction: the repeated ids and codes
dictionary-encode.
Encoding is applied where values repeat and skipped where they do not. A column
with one row per candidate is all-distinct, so encoding it costs indices on top
of a map holding every value. id on scalars is therefore unencoded, and
metadata shares that frame rather than carrying a second copy of the ids —
holding the id map once instead of per frame is worth about 2x on its own.
Option codes have their own frame rather than joining the attributes under
OPTION_CODE. An OPTION_CODE restriction matches a candidate's option codes,
while a candidate may separately carry an attribute of that type which it must
not match. Folding them together conflates two different questions.
attribute_pairs() returns text and encoded_attribute_pairs() returns the
encoded frame. Only consumers that join against frames loaded from the warehouse
need the text form — a Categorical-to-Utf8 join raises rather than coercing —
so pool-internal work is not decoded to be iterated.
Construction
An OEM normalizer renames its source frame to the canonical column set — id,
attributes, feature_ids, model_year, plus one column per metadata key — and
calls build_pool, which explodes the nested attribute column into the long
frames. normalizers/columnar.py takes no column-name parameters: reconciling
source shape is the normalizer's job, and by the time build_pool sees a frame
the names are ours.
For 50,000 candidates of 18 attributes, building the same pool both ways:
| Python allocated (peak) | frames held | |
|---|---|---|
| from objects | 747 MB | 19.8 MB |
| from frames | ~0 | 19.8 MB |
tracemalloc measures Python allocations, which is exactly what the object graph
is. The frames themselves are Rust-side and it does not see them, so read the
first column as "the object graph" and the second from estimated_size.
from_candidates stays, for the sources that validate per row: Mercedes reads
model_id off each row, parses six JSON-encoded fields, and sums attribute
prices into total_price.
Sharing one pool across scopes
The take-rate bulk path normalizes once per model_id and hands every dealer
scope of that model the same pool, paired with the ids that scope draws from it
(SharedCandidates). subset runs on first access to candidate_pool, so a
scope that is built but never scored costs its id list and nothing more.
A scored scope of 5,000 candidates holds a 1.98 MB subset pool alongside the
3.37 MB Utf8 frame get_candidate_features_df already cached, against one
per-model object graph removed. services/ai_ordering builds one scope per
invocation, so only a multi-scope caller sees the per-scope figure at all.