Skip to main content

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:

candidatesobjects (getsizeof)objects (peak RSS)poolreduction
10,00072.9 MB130.7 MB4.3 MB17.0x / 30.5x
50,000364.6 MB655.8 MB21.4 MB17.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 objects747 MB19.8 MB
from frames~019.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.