Skip to content

Engineering rationale and current design

This is the explanation of the current system for an engineer who wants to own it. It complements the repository walkthrough and the technical reference, rather than replacing the behavioral spec. The source audit for this revision used commit 2d2907e on 2026-09-25. Links name modules and symbols rather than frozen line numbers so they remain useful as code moves.

Recorded rationale below refers to a reason written in a repository design or specification. Implementation inference explains consequences of the code; it does not claim to reconstruct an author's private reasoning. A test link identifies evidence to inspect, not a claim that that test was executed during this documentation revision. Existing drafts remain drafts.

Read in order for the system model, or jump to decisions, training, publication, serving, ANN, costs, or limitations. The companion implementation workflow explains how to make changes.

1. Concepts that must remain separate

The primary unit is a Commerce Scope, the triple (data_source_id, tracking_id, catalog_id). SKU-1 in two Catalogs is two different Items. Two data sources can legitimately reuse the same Tracking ID. A city name is neither a global identity nor an authorization boundary. Scope is therefore carried into configuration, source validation, database keys, profile keys, and lookup methods. Adding it only to the HTTP route would leave internal and background paths unsafe.

Concept Owns or means Does not mean
Recommendation Strategy Product intent and allowed evidence order A numerical algorithm or a UI row
Recommendation Set One bounded ordered result for a strategy/anchor/variation The entire trained generation
Recommendation Snapshot Immutable published generation with sets and companion artifacts A live view of the merchant database
Serving head Which generation a scope currently serves A mutable model being trained in place
Swimlane Ordered accumulation across configured strategy/geography steps A weighted blend of scores across strategies
Shopper Profile Retention-bounded, rebuildable personal projection Aggregate batch-training input or authentication proof
ANN artifact Snapshot-owned representation, eligible index, mapping, checksum, optional query weights An independently updated vector database
Training evidence Versioned aggregate measurements about a run Raw interaction history or business outcome attribution

There are now eleven trained strategies: the six original strategies plus five category strategies. For You is a serving strategy. The two recent-history strategies are Swimlane candidate sources. Geographic levels are variations of evidence, not four copies of each algorithm. The authoritative trained registry is STRATEGY_DEFINITIONS in domain.py; serving-only identifiers live there and in swimlane.py.

The distinction between eligibility, evidence, and authorization matters. Eligibility comes from the Catalog at the snapshot cutoff. Evidence determines candidate relationships and rank. Authorization governs access to property/Shopper context. Strong evidence does not make an ineligible Item returnable, and knowledge of a Product ID does not authorize Shopper state access. An Item made unavailable in the merchant source after publication remains governed by the old snapshot until a new generation is published; serving has no live inventory refresh path.

2. Decision map

The original choices and rejected alternatives are recorded in design decisions D-01 through D-17. That document describes the baseline, so read newer capabilities through current source and their dedicated contracts. The following table includes the cost, not just the benefit, of each choice.

Choice Pressure it addresses Cost or alternative deliberately left open Basis
Separate API and worker Batch CPU/I/O cannot own request latency or caller lifetime Two processes and database coordination Recorded D-01/D-02
PostgreSQL control store Durable idempotency, claims, leases, publication transactions Database becomes a shared availability and throughput dependency Recorded D-03/D-11
Configured canonical SQL Merchant schemas vary while algorithms need stable rows Operators must certify query ordering, consistency, and source impact Recorded D-04/D-05
Stream/reduce without raw staging Preserve source ownership and privacy boundary Reprocessing requires another source read; no service-owned raw replay archive Recorded D-04/D-06
Whole-group exclusion Pair work grows quadratically with context width Discards useful pair evidence in oversized contexts Recorded D-07
Regularized cosine and tier-first fallback Normalize popularity and distinguish evidence strengths More explicit policy; scores are not comparable across tiers Recorded D-08
Sparse metadata baseline Cold-item relationships without a remote model service Depends on metadata quality and lexical/structured similarity Recorded D-09
Immutable generation and atomic head Keep a complete older answer during rebuild or failure Storage amplification, staleness, cleanup, publication protocol Recorded D-12/D-14
Bounded JSON set payload Serve one ordered list with few indexed reads Individual candidate analytics/updates are less relational Recorded D-13
Polars as a second aggregate engine Compare aggregate execution without rewriting source or serving Duplicate engine implementations and a parity obligation Inference; implemented by workstore_factory.py
Snapshot-owned ANN Extend retrieval while keeping generation coherence Native memory, cold loading, representation validation Recorded ANN lifecycle contract
Export a NumPy query tower Keep PyTorch training outside serving Exported architecture/version must exactly match inference code Inference from two_tower_torch.py and ann.py
Fail-safe telemetry seam Exporter failures must not determine product outcomes Monitoring loss can coexist with successful requests Recorded operations contract

These are contextual decisions, not universal recommendations. A separate queue, distributed reduction, or external vector service could become appropriate if measured requirements exceed the current design. Such a change must preserve scope, privacy, and publication semantics first.

3. Source consistency is more than a timestamp

The worker fixes the evidence window and opens a source session through SourceAdapter.open. source.py supplies the canonical row types, streaming protocol, SQLAlchemy implementation, validation, and consistency token. Reviewed configuration supplies SQL; runtime scope/time values are bound parameters. API callers cannot submit arbitrary SQL.

A cutoff answers which event times are eligible. A consistent transaction answers which database state all queries observe. They are different questions: a backdated purchase inserted between the views and purchases queries could pass the same cutoff but create a mixed-state run. The source session and token exist to prevent that class of inconsistency. An engine being reachable through SQLAlchemy does not by itself certify its streaming, isolation, or ordering behavior.

Ordering is an algorithmic precondition. Catalog rows arrive in Product ID order; interactions arrive grouped by session or channel-scoped Order and ordered within the group. The reducer can then finish one group permanently when the next begins. Unordered input would require retaining many open groups or sorting raw records, both of which change the memory/privacy contract. Online and offline Orders with the same source ID remain distinct through PurchaseRow.group_id.

The entire Catalog is materialized for eligibility, metadata, anchors, and feature companions. Large interaction streams are reduced incrementally. Consequently, “streaming” does not mean constant process memory independent of Catalog size. A long source transaction also has a cost to the source system; acceptable plans, indexes, and transaction duration need deployment evidence.

Read source contract tests and raw-staging checks before modifying this boundary.

4. Why the training pipeline looks this way

One transient group, then identity-free aggregates

groups.py separates the view/purchase adapter from GroupAccumulator. The accumulator owns distinct membership, row count, latest timestamp, geography consistency, and the oversized flag. It drops membership immediately when the distinct Item bound is exceeded, but continues counting rows and checking group state. finish() emits a GroupSummary without the source Order/Session identifier.

ingestion.py owns derived buffering. Popularity counts each view and exact purchase quantity; pair evidence counts distinct membership once per context. Repeated visits to one Item can increase Most Viewed without strengthening a pair multiple times inside the same session. A group of m distinct Items implies m(m-1)/2 undirected pairs; this is why the default 100-view/200-purchase membership bounds are semantically consequential.

Oversized groups contribute no pair/support/category-pair evidence but retain popularity contributions. Keeping the first 100 Items instead would make evidence depend on stream order. Tests in group accumulation and group limits protect these semantics.

Two different backend selections

aggregation_backend chooses the complete work store. DuckDB is the default; Polars performs derived aggregation/scoring through its own temporary Parquet path. cooccurrence_backend chooses only the counting implementation inside the DuckDB work-store path. It can select native counting experiments without replacing source validation, popularity policy, or serving.

create_work_store rejects a nondefault co-occurrence override combined with Polars. This makes the distinction explicit rather than silently ignoring a requested backend. Both engines share the group reducer, so they must agree on quantities, exclusions, temporal partitioning, category constraints, geography, and final ranks. See Polars parity tests and end-to-end tests.

The default native writer can overlap derived work with serial source ingestion using bounded work submission. Parallelizing the source stream itself would require a new consistent-partition and group-ownership contract. Faster pair multiplication cannot fix a slow source cursor, expensive metadata construction, or publication bottleneck; measure phases separately.

Scores, selection, and fallbacks solve different problems

For pair support p and per-Item supports a and b, regularized cosine is p / sqrt(a*b) * p / (p + shrinkage). The first term discounts globally common Items; the second shrinks weak relationships. A minimum support threshold rejects weak pairs before ranking. These scores describe aggregate association, not purchase probability or causal influence.

Metadata Similar Items builds sparse category, brand, price, and text features, normalizes them, and uses bounded sparse cosine products. Popularity uses half-life decay; Trending measures recent momentum against a longer baseline. The formulas and parameter selection are in cooccurrence.py, content.py, and popularity.py.

The fallback chain traverses evidence tiers before comparing scores within a tier. A popularity score of 100 does not outrank a co-purchase score of 0.7 merely because 100 is numerically larger. Each emitted Item retains provenance. Deduplication, anchor exclusion, eligibility filtering, and stable Product ID tie-breaking happen at this boundary. See fallbacks.py.

Category-constrained strategies apply known-category equality/inequality before bounded behavioral top-k selection. Filtering a previously truncated global list could miss the best qualifying neighbors. Missing categories are not considered a shared category. These strategies do not widen to unrelated fallback evidence merely to fill a list.

Holdout evaluation and final publication use different evidence

The orchestration in run.py fixes a 28-day holdout and selects parameters from explicit grids. Whole behavioral contexts are assigned using their latest event time so an Order or session cannot straddle behavioral train/holdout membership. Popularity has its daily temporal aggregates. Selected parameters are then applied to full-cutoff evidence for the published generation.

Offline Recall/NDCG and coverage are proxy measurements. They do not establish business uplift. Catalog metadata is loaded at the run's source snapshot; a chronological interaction split alone does not establish historically versioned metadata at each past interaction. ANN representation quality and the learned retrieval objective need separate evaluation; existing batch metrics must not be relabeled as proof of those capabilities.

5. Publication is the consistency boundary

sequenceDiagram
    participant W as Worker
    participant R as Snapshot repository
    participant DB as Control database
    participant S as Serving
    W->>R: sets, features, optional ANN, manifest, run ownership
    R->>DB: create invisible building snapshot
    loop bounded batches
        R->>DB: stage sets and candidate features
    end
    S->>DB: read previous available head
    R->>DB: lock and check run/snapshot ownership
    Note over R,DB: One activation transaction
    R->>DB: insert ANN, mark available, replace head, mark run succeeded
    S->>DB: read newly published generation

The building state allows chunked writes without exposing a partial Catalog. The final transaction activates the Snapshot and records successful run state together. An exception during staging or activation discards building data and leaves the prior head available. Crashes can leave abandoned building state for recovery; an exception cleanup handler is not proof that code runs after a hard process termination.

In storage.py, follow publish, _stage_recommendation_sets, _stage_candidate_features, _lock_publication_run, _activate_snapshot, and _complete_publication_run. The worker passes both the active-run requirement and expected owner. A heartbeat alone is insufficient: a paused worker could resume after another worker reclaimed the run. Publication must check ownership at the durable boundary.

The manifest is compared with the entire RecommendationStrategy enum, currently eleven values. The worker publication path additionally checks observed strategy presence, requiring anchored strategies when eligible Items exist. This is not a repository-level proof that every possible anchor has every required row; generation and its tests also carry that obligation. Some legacy exception strings still say “six-strategy”; the enum comparison, not that wording, controls behavior.

Candidate feature companions now publish from the same Catalog read, including eligibility, category, and brand for eligible and ineligible Items. They are staged before activation and removed with failed/retained snapshots. This closes the former feature-population gap. It does not imply the Shopper projection already computes category/brand affinities; those are separate inputs to the reranker. See candidate storage tests.

The optional ANN artifact is validated before building the Snapshot and inserted in the activation transaction. A large ANN payload therefore still contributes to that transaction's size and latency; the transaction is not always just a tiny pointer update. Immutable does not mean retained forever: retention preserves the current head and removes older complete generations.

6. Serving is source-free, not computation-free

Path Reads Computes at request time Fallback boundary
Ordinary strategy GET Published head and bounded set Validation, slice, stale metadata Explicit missing/empty/incomplete states
Personalized lane Published candidates/features plus authorized profile Bounded rerank and concentration limits Ordinary lane
For You without ANN Bounded global union plus profile/features Personalized rerank Best Sellers
ANN Similar Items / For You Selected generation's validated artifact and query inputs Query representation and eligible vector search Existing snapshot path
Contextual Swimlane POST One head's variations, optionally authorized typed recents Ordered accumulation, recent-seed fusion, history fill Later configured steps, then history

serving.py contains application decisions and typed failures. SnapshotLaneLoader and ForYouCandidateLoader distinguish candidate sources from ordinary fallbacks. AuthorizedPersonalization and UnavailablePersonalization make unavailable runtime behavior explicit. api.py owns transport, authentication plumbing, status mapping, and response projection. This is a useful seam because HTTP errors should not be the internal representation of ranking decisions.

An empty, valid set means insufficient evidence. A missing head means no successful generation. A missing required global lane or invalid payload means incomplete/corrupt serving state. Those states should not collapse to an empty success response. Staleness uses generation time plus the freshness policy and does not trigger synchronous training.

Snapshot consistency is per operation, not an implicit distributed transaction over every helper. The contextual resolver explicitly uses get_head_variations for a single-head read. The current For You loader fetches its global union and Best Sellers fallback through separate repository calls; do not assume these calls share a transaction during concurrent publication. This is a remaining consistency concern to test, not evidence that a mixed response was observed here.

7. Geography, recent history, and Swimlanes

GeoKey normalizes source-local names using NFKC, case folding, and whitespace collapse; city requires region and country. This differs deliberately from NFC identity normalization for opaque scope/Product identifiers. Normalization reconciles encodings, not geographic aliases. A same-name city in a different parent remains distinct. No network geocoder is involved.

Groups contribute to their available hierarchy and global aggregates. Missing geography contributes globally only; conflicting geography inside a group is rejected. Local publication requires ten qualifying contexts, with behavioral minimum pair support at least ten. Local sets do not borrow metadata/global candidates. Sparse local absence means the configured chain may continue; it does not mean the global strategy failed. The ten-context threshold is not a ten-person anonymity claim.

The current local recipes produce co-view/co-purchase and category-constrained anchored sets. Configuration can express other strategy/level combinations, but that does not manufacture an unpublished geographic variation. In particular, do not assume a local Similar Items, Trending, or global-popularity strategy set exists merely because those identifiers parse.

resolve_swimlane in swimlane_service.py compiles each step's required lookup keys, reads them under one published head, and passes lazy candidate providers to compose_swimlane. The accumulator owns quota, deduplication, anchor exclusion, and step-specific purchase exclusion. This separates selection policy from storage and avoids repeatedly deciding geography/seed meaning during consumption.

For quota four, if the city step supplies [A, B] and the region step [B, C, D], the result is [A, B, C, D]; B consumes no second slot. A higher-scored regional Item cannot jump ahead of A. History is consulted only after all configured steps leave room. It uses newest-first authorized recent views and snapshot feature eligibility. Missing features do not authorize a history Item.

Recent-view and recent-purchase seeds remain distinct. The resolver uses at most ten seeds, loads published Also Viewed/FBT neighborhoods, keeps only matching co-view/co-purchase evidence, and fuses scores with reciprocal seed-position weights. A popularity fallback inside FBT is therefore not silently reinterpreted as a co-purchase relationship. Purchase exclusion is a configured step filter, not a universal ban on replenishment.

The contextual endpoint authenticates a property bearer before parsing the body. Shopper context is needed only for profile access. Location selects aggregate evidence; it is not identity proof. Responses omit local exact scores/support and use private, no-store. The endpoint is POST because context belongs in a body rather than routinely logged URLs; it is still a read-only operation. See Swimlane tests, resolver tests, and geographic publication tests.

8. Embedded ANN and the two-tower option

Retrieval, representation, and relevance are separate

ANN answers which vectors are near a query efficiently. Representation decides what proximity means. Neither alone establishes recommendation quality. The metadata baseline builds deterministic truncated-SVD projections from the existing sparse feature matrix, normalizes finite float32 vectors, and constructs a CPU Faiss HNSW index over eligible Items. The representation may retain vectors for other Items as query inputs, but return eligibility comes from the indexed generation.

The implementation in ann.py uses HNSW connectivity 32, construction search 160, and default query search 512. These are implementation settings, not proven optimal values. Small eligible indexes (at most 2,000 Items) use exact search; ANN underfill also falls back to exact search. This improves quota recovery after exclusions at the cost of a possible Catalog-wide vector scan. Exact search is chunked, but allocates a score array proportional to eligible Catalog size.

Artifact validation checks representation version, shape, finite values, mapping/index consistency, and checksum. The SHA3-512 digest detects mismatched/corrupt content; it is not a signature proving who produced it. Artifact ownership, access control, and snapshot scope remain separate concerns.

What the learned option actually learns

two_tower_torch.py trains two independent two-layer MLPs on metadata projections. Positive pairs come from already bounded aggregate co-purchase and co-view candidates; purchase candidates receive twice the selection weight. The implementation selects at most 10,000 pairs, caps dimensions at 128, runs three epochs in batches of 128, and uses in-batch contrastive cross-entropy with AdamW. It chooses MPS when available and CPU otherwise.

This is aggregate Item-relationship training, not a model trained on raw Shopper sequences. The pair selection score chooses examples; the training loss does not thereby become a continuously weighted purchase-value objective. Fixed seeds make controlled repetition easier, but do not prove bitwise equivalence across native libraries/devices. Co-purchases can represent complements, so learned proximity needs separate scrutiny before being presented as substitution quality.

The worker exports Item vectors, original projected input vectors, and query-tower parameters. Serving computes the small query network using NumPy, then searches the immutable Faiss index. PyTorch is an optional training dependency. Faiss is a base package dependency but is imported lazily on opted-in build/load paths; “default off” is not the same as “absent from the wheel's dependency graph.” Inspect pyproject.toml for the actual packaging boundary.

Why the index belongs to the Snapshot

Independent online index updates could mix eligibility, representation, and Item-ID mappings from different Catalog generations. Snapshot ownership makes one generation the retrieval authority. Acknowledged Shopper interactions can change the query between Training Runs; they do not mutate the index. For You excludes recent/negative Items from ANN candidates and can discover beyond the old bounded global union. Metadata and learned results have distinct provenance and low confidence; distance is not a calibrated probability.

The API assembles AnnSnapshotRetriever with background loading. It retains at most two loaded generation entries by default and admits at most one pending load. A cold/missing/invalid artifact returns the ordinary path while warming. The cache is shared within that retriever, not two generations per Commerce Scope, and eviction follows insertion order rather than access-based LRU. Failures are remembered for that generation until eviction or restart. Those details matter for a multi-scope deployment's hit rate and recovery behavior.

Inspect ANN snapshot tests, two-tower tests, and ANN experiments. Index recall, representation preservation, personalized relevance, full-request latency, and native memory residency require different evidence.

9. Personalization has its own consistency and privacy lifecycle

The batch worker reduces merchant interaction streams without retaining raw rows. The optional personalization API instead accepts purpose-authorized, bounded interactions into a retention-bound ledger. These are distinct ingress contracts; the ledger is not permission to copy merchant source history into the control database.

An interaction transaction commits its ordered ledger entry, idempotency record, lifecycle barrier, and profile projection before an acknowledgement is issued. Exact retries replay the acknowledgement; different content under the same interaction identity or a wrong next sequence conflicts. A causal token requires observation of at least the acknowledged projection version within a bounded wait. It is neither authentication nor a promise to wait indefinitely.

Opt-out/deletion barriers prevent use immediately while bounded cleanup removes retained state. Rollback of read/write admission must not disable deletion and expiry reconciliation. The worker calls personalization maintenance even when it finds no Training Run; an idle training queue is not permission to stop the worker. Reusing the worker avoids a separate cleanup process, but ties maintenance cadence to worker availability and long-running work. That operational consequence is an inference from worker.py, not a measured deletion SLO.

Snapshot category/brand features enable concentration controls. The current interaction projector does not populate category/brand affinity maps from Item interactions, so feature publication alone does not complete every possible personalization signal. The full contract and release gates are in Shopper personalization.

10. Resource and operational costs

Resource Current control What the control does not establish
Raw context membership Configured distinct-Item bounds; clear on oversize Total Catalog/derived-pair memory
Derived buffering Batch-key limits and bounded work submission A process-wide RSS ceiling
DuckDB scratch Engine memory/spill settings, per-run cleanup Identical behavior in the Polars engine
Polars execution Derived-file budget and engine-specific checks; native thread pool DuckDB-style spill guarantees or a resize from pipeline.threads
Result payload At most 100 entries per set Constant total Snapshot size
Geographic output Current 50,000 local-set cap Complete publication of every supported local partition
Swimlane At most 32 steps, quota 1–100, bounded seeds/history Every configured step having available evidence
ANN residency Two cached entries and one pending load by default; payload budget Total native/Python peak RSS or fleet-wide residency
Admission Process-local token buckets/concurrency leases when injected Distributed capacity enforcement

With E eligible Items, the global registry emits eight anchored sets per Item and three global sets, including explicit empty outcomes: roughly 8E + 3 set rows before geographic variations. Each row can hold up to 100 entries. Retaining multiple generations multiplies storage again; candidate features and optional ANN add per-generation costs. Bounded per-row work is not a claim that a full run has constant memory or trivial storage.

The pipeline materializes Catalog features, candidate maps, and resulting sets in addition to work-store scratch. For vectors alone, N * d * 4 bytes describes one float32 matrix, before the native index, mappings, temporary copies, and (for two towers) exported input vectors. Concurrent old/new generations and loading can increase peak memory beyond a single artifact's byte budget.

Operationally, separate these questions: Is the source slow? Is reduction CPU-bound? Is scratch exhausted? Is publication slow? Is serving waiting on the control database? Is an ANN generation warming? A single training-duration number cannot distinguish them. The typed observability seam and operations contract expose bounded phase/outcome evidence.

Quality drift compares same-scope successful runs with compatible model/config/evidence versions, using bounded history and robust baselines. Assessment is diagnostic after publication. A warning does not reject a completed generation or establish conversion degradation. Exporter failure also must not change product behavior, though it can remove the evidence operators need.

11. Why there are both generated sources and a simulation harness

The generated source provides deterministic relational contents, a logical identity, manifests, digests, and engine materialization receipts. It is suited to reproducible source-to-serving oracles and controlled scale experiments. The simulation source is mutable and run-isolated; a storefront and bounded visitor runner create activity over logical time and observe training checkpoints. One is not a replacement for the other.

Independent simulation behavior does not follow recommendations. Feedback behavior can follow them and therefore changes its own future training evidence. A successful feedback scenario proves that a loop operates; it does not prove independent relevance or causal uplift. A source receipt proves the materialization matches its manifest, not that the service meets production latency.

Read synthetic generation, generated-source operations, and simulation contracts. These guides also explain append lineage, atomic replacement, reproducible random streams, and checkpoint attribution in more detail.

12. Known limits and design drift

These observations bound the claims in this guide. They do not authorize changing runtime behavior or silently revising product intent.

Observation at the audited revision Consequence and next verification boundary
_build_geographic_sets returns when 50,000 sets have accumulated Later local results are omitted; the draft design's fail-on-budget-exhaustion proposal is not implemented. Evaluate coverage and explicit failure semantics before relying on full geographic coverage.
Geographic budgets are constants/implementation controls rather than the draft's full configurable partition/pair/publication policy Do not advertise the draft's complete resource contract as shipped.
For You candidates and ordinary fallback use separate head reads Concurrency testing should establish required behavior across a head swap; only contextual variation lookup explicitly pins the combined lookup.
Candidate features now publish, but profile category/brand affinities are not derived Concentration metadata exists; affinity behavior is a separate implementation boundary.
Repository publication checks strategy presence, not a Cartesian proof over every anchor Pipeline generation and integration coverage remain part of completeness assurance.
ANN opt-in has bounded artifacts and tests, not broad deployment qualification Measure representation, relevance, memory, cold load, underfill, and request latency separately.
Ordinary training/GET APIs have no general built-in caller authentication Deployment/network integration owns that boundary; contextual/personalization authorization is separate.
SQLite/local tests are part of the normal loop They cannot establish PostgreSQL lock races, production availability, or business outcomes.

The geographic design remains a draft with explicit reconciliation notes. No new ADR is manufactured here: an explanatory reconstruction must not pretend that an inferred reason was an approved historical decision. Future decisions should record the actual alternatives, constraints, and evidence at the time they are made.