UniHive_RecSys / REPORT.md
Tirthanker's picture
Correction: v2.0 domain input still carries subreddit names (REPORT.md)
5ffc326 verified
|
Raw History Blame Contribute Delete
44.8 kB

Unihive recommendation engine: complete report

Correction (2026-10-07). Version 2.0 removes the community name and flair as direct inputs and from the embedding text. It still receives each post's domain field, which for Reddit text posts is self.<subreddit>: 24 of the 30 domain tokens known to both models are subreddit names. Community information therefore remains indirectly available to the 2.0 models for text posts. A follow-up release removes domain entirely. Until then, treat v2.0's community independence as partial.

This report covers everything built, tested and measured for the Unihive post recommender: from the raw Reddit telemetry to the shipped hybrid engine 2.0 and its backend adapter. It also explains every file of engine code in this branch.

Contents

  1. Summary
  2. Data
  3. Enriching the posts: image captions and text embeddings
  4. Features and label
  5. Phase 1: five RecBole models, ONNX export
  6. Phase 2: benchmark on standard metrics
  7. LLM tagging with a closed 114-tag taxonomy
  8. The first two-stage engine (tag match, then model) and why it failed
  9. Every alternative pipeline we tried
  10. The hybrid engine: design
  11. Building the engine and the training/serving bug we found
  12. Version 1.1: gentler age decay and topic-first slots
  13. Version 2.0: no community, no flair
  14. Backend compatibility
  15. Code walkthrough (every file of the engine)
  16. How to reproduce
  17. Limits and next steps
  18. Glossary of metrics

1. Summary

Item Result
Data 4 real users (P02, P06, P09, P25), 5 Reddit sessions, 1,794 post exposures, 1,677 distinct posts, plus 2 later P09 sessions (89 and 142 posts) for out-of-time checks
Label engaged = click / vote / save / join / reply / opened detail, or focal dwell β‰₯ 3 s
Shipped engine Hybrid engine 2.0 (models/hybrid_engine/): v2b for new users, blended toward a warm DeepFM as engaged history grows (w = engaged / (engaged + 10), capped at 0.7), gentle age decay, cold-start push, topic-first ordering (7 of every 10 slots go to topic matches)
What 2.0 changed Community (subreddit) and flair removed as direct inputs and from the text that the content vectors are built from (your instruction). The per-user community history rates stay. Correction: the domain input still carries subreddit names for text posts (see the note at the top).
Verified ONNX == PyTorch (≀ 2.4e-7); the engine rebuilds the training inputs from raw data (≀ 0.0021); engine scores == offline predictions (≀ 2.5e-4); 9 automated tests, including the backend's real reco.store on fakeredis
Quality (NDCG@10) Clearly above random in every setting. Example, validation period whole feeds: 0.65 vs 0.35 random. Below Reddit's own feed order, which knows far more (feed AUC 0.77–0.92 vs 0.60).
Backend backend_adapter.py maps Redis post meta, user state, burst-buffer events and interaction_events rows to engine inputs. build_pool_hybrid is a drop-in for reco.service.build_pool; with the same data it returns the same candidates as the original.
Biggest open item The backend stores CLIP-512 vectors only. The engine needs a Qwen3-Embedding-0.6B vector per post (one extra call in the tagging worker). Without it the engine still runs, at about 0.02 lower NDCG@10 on average.

2. Data

Source. Trumio Trace browser telemetry of real Reddit scrolling: post_impression, attention_update (visible / focal / idle ms), hovers, clicks, votes, saves, joins, replies and post_detail_summary (time on the opened post and its comments).

Interaction table (out/interactions_v2.csv, built by build_interactions.py). One row per (user, session, post exposure), 64 columns:

  • Context: rank in feed, page kind, view context, recommendation source, time in session.
  • Post: subreddit, post type, flair, title, body, image caption + OCR, author hash, score, comments, upvote ratio, age, is_ad, NSFW, spoiler, membership, link domain, language, awards.
  • Attention: visible / focal / active / idle ms, re-exposures, hover ms and count, clicked, opened detail, detail time on body and comments.
  • Actions: up/down vote, saved, joined, reposted, reply opened, media watch.
rows positives positive rate
train (first 70% of each user's timeline) 1,255 308 24.5%
validation (next 15%) 269 65 24.2%
test (last 15%) 270 44 16.3%

The split is chronological per user, so the model never trains on a user's future.

Out-of-time sessions (P09, recorded about two days after all training data):

  • Session 1 (P09_2026-10-07_04-51-26): 89 posts, 11 engaged.
  • Session 2 (P09_2026-10-07_05-10-53): 142 posts, 10 engaged; 59% of posts came from subreddits never seen in training.

Raw session events are not pushed (they stay local).


3. Enriching the posts: image captions and text embeddings

Image captions.

  • Model: Florence-2 (detailed caption + OCR), run as parallel instances on the GPU (caption_images.py).
  • Missing images were recovered through pullpush.io (pullpush_recover.py) and captioned in later rounds.
  • attach_captions.py joins the captions to the events. In the end, 391 posts have a caption.

Text embeddings.

  1. MiniLM-L12 (384-d) first, then Qwen3-Embedding-0.6B (1024-d, GPU, float16) for all 1,677 posts (embed_posts_minilm.py, embed_posts_qwen3.py).
  2. The first pass missed 137 posts that appear only in the event log. They were added and the embeddings rebuilt.
  3. Three vectors per post: text, caption, and fused (text + " Image: " + caption). The models use the fused vector.
  4. Qwen3 beat MiniLM by about 0.02 AUC, and captions added about 0.013 (both within noise; section 6).

Versions of the embedding text.

  • Engines 1.x: "title. subreddit. flair. body" (+ caption).
  • Engine 2.0: "title. body" (+ caption), from embed_posts_qwen3_nocomm.py β†’ out/post_emb_qwen3_0p6b_nocomm.npz.

Content components. PCA reduces the 1024-d vector to 16 components, each scaled to unit standard deviation (pca0–pca15). The fitted mean, components and scale are stored in meta.json, so the engine computes them for new posts: pca16(v) = ((v βˆ’ mean) @ components.T) / component_std.


4. Features and label

prep_recbole.py turns the interaction table into RecBole files. Rule: an input may only use information available before the user reacts. The row's own dwell, hover and clicks define the label and are never inputs.

Post inputs (known at exposure)

  • log_score, log_comments, log_age_h (missing age β†’ median, 121 h), title_len, body_len (log of character counts).
  • has_caption, is_ad, upvote_ratio (missing β†’ 1.0, plus an upvote_missing flag), log_awards, is_spoiler.
  • pca0–pca15.
  • Categories: post type, link domain (top 30, else "other"), language. In 1.x also subreddit and flair (removed in 2.0, section 13).

Causal history inputs. These summarise the user's earlier rows whose viewing had already ended (ts_j + visible_ms_j ≀ ts_i). R is the last 20 such rows. The constant 3 is a prior that keeps short histories from producing extreme rates.

Feature Formula / meaning
h_n log(1 + number of past interactions)
h_pos_rate (engaged in R + 3Β·global rate) / (|R| + 3)
h_mean_focal Ξ£ log(1 + focal s) over R / (|R| + 3)
h_hover Ξ£ log(1 + hover ms) over R / (|R| + 3)
h_click (clicks in R + 3Β·0.08) / (|R| + 3)
sub_seen, sub_rate how often this user saw / engaged with posts from the same community before (smoothed toward the user's own rate)
auth_seen, auth_rate the same for the same author
aff_recent cosine between this post's vector and the mean vector of recently engaged posts
aff_neg cosine with the mean vector of recently skipped posts
uemb0–uemb15 mean content components of recently engaged posts (the user's recent taste)

Context features also existed: feed rank, attention focus, session position, session time and feed speed (h_expo_rate). The warm model of the engine does not use them, because a recommender cannot know them before it ranks (section 9).

Switches in prep_recbole.py (environment variables). With none set, the output is byte-identical to the original; this was checked with cmp.

Switch Effect
SUFFIX write a variant dataset reco_ctr<SUFFIX> instead of the main one
LABEL_MODE dwell2000 / dwell3000 (default) / dwell5000 / engaged
EMB_NPZ, EMB_KEY which embedding file / vector to use
SHUFFLE=1 shuffle train/valid labels (sanity check: test AUC must fall to ~0.5)
LOUO=Pxx leave one user out entirely (new-user test)
MASK_IDS=1 replace user and post IDs with a constant
HIST_CAP=n simulate a user with only the last n interactions
STRICT_PAST=1 the serving rule: only interactions strictly before the request count (section 11)
META_DIR where standardisation and PCA are written and read (engine 2.0 uses package_meta_nc, so the original package_meta is never touched)

5. Phase 1: five RecBole models, ONNX export

Setup. RecBole 1.2.0 on PyTorch 2.11 with CUDA. It needed compatibility fixes:

  • .agg(len) β†’ .map(len) (pandas), via apply_recbole_patch.py;
  • NumPy alias shims;
  • torch.load(..., weights_only=False);
  • missing pydantic and kmeans_pytorch installed;
  • float fields fed as (value, 1) pairs;
  • alias_of_item_id for sequential models.

DIN and SASRecF crashed inside RecBole 1.2 (embedding index out of range) and were dropped.

Models and tuned settings (search on validation, then 5 seeds each):

Model Tuned settings Test AUC (mean of 5 seeds) Test logloss
LR weight_decay 0.1 0.694 0.502
DeepFM emb 16, dropout 0.3, wd 1e-3 0.739 1.418
Wide&Deep emb 16, dropout 0.3, wd 1e-3 0.732 1.014
DCN V2 emb 4, dropout 0.3, wd 1e-2 0.711 0.660
SASRec (next post) hidden 32, dropout 0.6, wd 1e-2 Hit@10 0.073 vs random 0.028 β€”

Export.

  • Every model was exported to ONNX and checked against PyTorch.
  • Each export comes with metadata (vocabularies, feature order, standardisation, PCA), an explainer Word document and a runnable example.
  • Pushed to the GitHub models branch, including the full PyTorch checkpoints and a retraining kit: datasets, tuned settings, continue_training.py and the data pipeline.

6. Phase 2: benchmark on standard metrics

All numbers are on the 270-row test split (44 positives), as the mean over 5 seeds. The AUC standard error is about 0.046, so gaps below about 0.13 AUC cannot be detected reliably.

Headline findings

  1. The models beat chance and popularity: test AUC 0.69–0.74 vs 0.50 random and 0.56 popularity. All four are significantly better than popularity.
  2. Reddit's own feed order is the strongest ranker per user: NDCG@10 0.66 vs 0.49 for the best model, GAUC 0.82. Engagement is dominated by feed position: rank 0–2 β†’ 44% engaged, rank 21+ β†’ 21%.
  3. The deep models were badly calibrated: raw logloss 1.39 (DeepFM) and 1.07 (Wide&Deep), worse than a constant (0.465). Platt scaling fitted on validation fixes it: 0.454–0.484 for all four.
  4. History features drive everything. Removing them takes the average AUC from 0.714 to 0.619. Feed context and embedding features each add about 0.02. Categories and IDs add about nothing: permutation importance of the category group is 0.001, and masking IDs slightly raises AUC.

Per-user ranking (test, macro over 4 users)

Method NDCG@10 Hit@10 MRR
DeepFM, 5-seed ensemble 0.491 1.00 0.69
DCN V2 0.465 1.00 0.68
LR 0.451 0.75 0.64
Reddit feed order 0.659 1.00 1.00
Popularity 0.328 1.00 0.47
Random 0.199 0.81 0.33

Other checks

  • Leave-one-user-out (new-user test): LR keeps 0.69 mean AUC; the deep models drop to about 0.65.
  • Shuffled labels give AUC 0.44–0.53: no leakage.
  • Adversarial validation separates train from test perfectly, through session-position features that grow along the timeline. Removing them costs only 0.013.
  • Slices: weakest for user P09 (AUC about 0.58) and r/AskPhysics (about 0.39). Weaker in the second half of the test period (interest drift).
  • Cost: every model scores a post in about 0.2 ms on CPU.

Details: benchmark_report/REPORT.md and its CSV tables.


7. LLM tagging with a closed 114-tag taxonomy

Tagger. Groq openai/gpt-oss-120b, temperature 0, JSON output, choosing only from the app's taxonomy: 13 broad interests, 114 child tags (backend/crud/taxonomy.py). Two API keys were rotated; they live only in a git-ignored .env and were never committed.

Results

  • 1,677 posts tagged, 1,997 tags, 1.6% invalid tags rejected, mean 1.19 tags per post, mean confidence 0.80.
  • 24.4% of posts got no tag (careers, college and off-topic content the taxonomy does not cover). 75 of 114 tags were ever used.

Reliability

  • Agreement with gpt-oss-20b: Jaccard 0.51. With qwen3-27b: 0.53. A re-run of the same model at temperature 0 reproduces the exact tag set only 47% of the time (same top tag 71%).
  • Broad domains are stable (kappa 0.76). 91% of posts carry their subreddit's majority domain.
  • Manual spot check: 36 of 40 acceptable.

8. The first two-stage engine (tag match, then model) and why it failed

Design.

  • Stage 1 scores how close a post's tags are to the user's picked tags, using 8 tag-match features:

    • exact overlap;
    • parent-domain overlap;
    • rarity-weighted overlap;
    • soft similarity through a 114Γ—114 tag-similarity matrix;
    • whether the post's top tag was picked;
    • number of matches;
    • number of tags;
    • mean confidence.

    A logistic regression combines them into tag closeness.

  • Stage 2 is a Wide&Deep model on post features + tag-match features: no user ID, no history, no feed position. It works for a brand-new user (test AUC 0.692).

  • Final step: order by tag closeness, then drop posts with model probability < 0.3 (or blend the two).

Result: below the model alone. Test NDCG@10: engine 0.29 and blend 0.37 vs the Stage-2 model alone 0.40. The reason: for these real users, posts matching their inferred interests were engaged with at the same rate as non-matching posts (20.2% vs 20.2%). For P09, engagement moved to posts the taxonomy cannot tag (37% engaged among untagged vs 3% among tagged).

Random-interest simulation. 2,000 synthetic users per interest type, test-period posts only:

  • Coherent interests got lists of median 5 posts; 5.7% got nothing.
  • Only 36% of the pool was ever recommended, because untagged posts can never appear.

9. Every alternative pipeline we tried

All were evaluated with the same protocol: per-user test pools, settings tuned on validation, test scored once, 25-post-page bootstrap. Simulated users drew random interests from the test-period pool.

Pipeline Idea Test NDCG@10 Verdict
Reddit feed order reference 0.659 needs Reddit's own ranking; not available to us
DeepFM warm (history + feed rank) warm CTR 0.491 uses feed rank, which a recommender cannot know before ranking
v2b: model + 0.1 Γ— tag closeness model first, small tag bonus 0.467 best cold-start option β†’ became the engine's new-user score
v5: 10% exploration slots exploration quota 0.447 small cost for variety
v5: 20% exploration + light diversity exploration quota 0.429 more variety, ILD 0.85
v2a: Stage-2 model ranks all model only 0.402
Two-tower retrieval + model ranking Google two-tower 0.390 retrieval drops good posts
v1 blend (25% tag, 75% model) first engine 0.373
Phoenix ranker, dwell head X Thunder + Phoenix 0.365
Two-tower, topics + history Google two-tower 0.359
v4: interest lane only multi-lane 0.356
Popularity baseline 0.328
Two-tower, topics only Google two-tower 0.309
v1 engine (tag order + filter) first engine 0.291
v2c: topic gate, then model gate first 0.282
v4: four-lane mixer + MMR multi-lane + diversity 0.274 most diverse (ILD 0.91), lowest accuracy
Phoenix full X Thunder + Phoenix 0.258
v1 tag closeness only Stage 1 only 0.248
Random baseline 0.192
v3: learned intent embedding predict the user's next-intent vector 0.180 at chance with 4 users of data

Hybrid experiments that shaped the final design

  • v6. Blending v2b with a warm DeepFM by history depth helped on validation (0.633 vs 0.547 for v2b) but not on test.
  • Fault probe.
    • Every real user in validation and test has β‰₯ 61 past interactions, so "new user" behaviour could only be studied by truncating histories (HIST_CAP).
    • Per-seed AUC of the warm model varied by up to 0.06.
    • A position-debiased warm model and a "clean" warm model (no IDs, no feed context, no session signals) were both better than v6's.
  • v7. Tuned warm variants on validation. The clean warm model became the shipped warm model.

10. The hybrid engine: design

Your directive: v2b for new users; gradually shift toward a warm DeepFM as meaningful history builds up, while keeping some v2b for freshness and exploration; weight decay as posts age; a cold-start push for new posts.

v2b    = Platt(cold model probability) + tag_bonus Β· rank01(tag closeness)            tag_bonus = 0.1
w      = 0                                   for a user with no engaged posts
       = min(w_max, engaged / (engaged + n0)) otherwise                               n0 = 10, w_max = 0.7
blend  = (1 βˆ’ w) Β· rank01(v2b) + w Β· rank01(warm DeepFM probability)
fresh  = floor + (1 βˆ’ floor) Β· 0.5 ^ (age_hours / half_life)                          1.1+: half_life 720 h, floor 0.85
push   = beta Β· [age_hours ≀ 24] / sqrt(1 + impressions)                              beta = 0.1
score  = blend Β· fresh + push
order  = in every block of 10 positions: the 7 best topic-matching posts, then the 3 best of any kind

Why each piece is there

  • rank01 (rank inside the request, scaled to (0, 1]) makes the two models' scores comparable, whatever their calibration.
  • w grows with engaged posts, not all impressions, because the warm model only learns from engagement. It never reaches 1, so v2b always keeps at least 30%.
  • Freshness is multiplicative and gentle. A week-old post keeps 98%, a month-old post 93%, and no post drops below 85%. The models already see post age, so a strong decay double-counts it (section 12).
  • The push is additive and fades with impressions, so a new post gets a few showings and then must earn its place.
  • Topic-first guarantees that the top of the feed reflects the picked interests. A topic match means the post shares a picked tag or its broad domain.
  • Exploration is not the engine's job. The backend's compose.pick_explore keeps reserving 15–20% out-of-interest slots.

11. Building the engine and the training/serving bug we found

Cold model (v2b's probability): the Stage-2 Wide&Deep, 5 seeds averaged inside one ONNX graph, with Platt calibration fitted on validation.

Warm model: DeepFM without user/post IDs, feed context (rank, recommendation source, page kind, view context, attention focus) or session signals (session position, session time, feed speed). It uses post features + 27 history features, 5 seeds in one graph.

The bug: a training/serving skew. The first parity check compared engine-built inputs with the training files. The warm history features differed by up to 6.46 standard deviations, and the scores by up to 0.9.

  • Cause: the training data counted impressions logged at the same timestamp with zero visibility as "history". A live request can never see those.
  • Fix: a new dataset switch STRICT_PAST=1 (only interactions strictly before the request), the same rule in the engine (ts < now and viewing ended), and a retrained warm model.
  • Effect: warm test AUC dropped from 0.727 to 0.671. The lower number is the honest one.
  • After the fix: features match within 0.003 and scores within 3e-4.

Engine 1.0 replay (NDCG@10)

Setting Validation, whole pool Test, whole pool
Shipped defaults 0.62 0.39
v2b only 0.51 0.48
w_max 0.3 0.55 0.48
No decay / push 0.66 0.40

The blend wins on validation and loses on test. With only 4 long-history users, these defaults are a starting point to re-tune on real logs.


12. Version 1.1: gentler age decay and topic-first slots

Your request: soften the age penalty, and make topic matches find the top posts.

  • Decay: half-life 168 h β†’ 720 h, floor 0.7 β†’ 0.85.
  • Topic-first: 7 of every 10 positions go to topic matches.

Results (NDCG@10, topic share of the top 10)

Feed 1.0 1.1
New session 1, one feed at session start 0.25 (10%) 0.41 (70%), engaged posts in the top 10: 3 β†’ 5
New session 1, 25-post pages 0.52 0.56
Validation, whole feeds / pages 0.65 / 0.60 0.69 / 0.64
Test, whole feeds / pages 0.45 / 0.55 0.32 / 0.46
New session 2: AUC / pages 0.58 / 0.34 0.60 / 0.38 (Reddit order AUC 0.77, random 0.50)

The topic rule helps when engagement follows the picked topics (new session 1, validation). It costs in the test period, where P09 had drifted to untagged posts. You chose to keep 1.1 as it was ("no improvement" round).


13. Version 2.0: no community, no flair

Correction (2026-10-07). Version 2.0 removes the community name and flair as direct inputs and from the embedding text. It still receives each post's domain field, which for Reddit text posts is self.<subreddit>: 24 of the 30 domain tokens known to both models are subreddit names. Community information therefore remains indirectly available to the 2.0 models for text posts. A follow-up release removes domain entirely. Until then, treat v2.0's community independence as partial.

What was wrong. Since the first RecBole dataset, every post metadata column had been an input, including subreddit and flair. The subreddit name was also inside the embedding text. You had never asked for this, and it cannot transfer: Unihive communities are not Reddit subreddits, so every backend community would be "unknown" to the 1.x models.

What changed

  1. Content vectors re-embedded from "title. body" (+ image caption) for all 1,677 posts (embed_posts_qwen3_nocomm.py).

  2. New datasets, built by prep_recbole.py with EMB_NPZ=post_emb_qwen3_0p6b_nocomm.npz META_DIR=package_meta_nc:

    • reco_ctr_nc;
    • reco_ctr_nc_noid_strict (also MASK_IDS=1 STRICT_PAST=1);
    • reco_engine_nc, the cold dataset: tag-match features + post features, tokens post type / domain / language only.

    All originals are untouched (checksums verified).

  3. Both models retrained (engine_v2/build_engine_v2.py), with the same tuned settings, 5 seeds, export and checks. Test AUC:

    • Cold: valid 0.777, test 0.602 (1.1: 0.692). Inputs: post type, domain, language + 35 floats.
    • Warm: valid 0.713, test 0.565 (1.1: 0.671). Per-seed test AUC 0.55–0.60.
  4. Kept: sub_seen / sub_rate, the user's own engagement rate with a community. They compare community ids inside one user's history and need no learned names. On the backend, community_id provides them.

  5. Guard test: test_no_community_or_flair_in_models fails if subreddit or flair is a model input, if the embedding text includes them, or if renaming them changes any score.

Where the AUC drop comes from (engine_v2/diag_engine_v2.py: cold model, same recipe, 5 seeds)

Variant Test AUC
A: 1.1 setup (community + flair as inputs and in the text) 0.692 (reproduces 1.1 exactly)
B: inputs removed, old text 0.624 (βˆ’0.07)
C: inputs kept, new text 0.653 (βˆ’0.04)
D: both removed = 2.0 0.602 (βˆ’0.09)

On this Reddit trace the community name genuinely predicted engagement. For example, P09 engaged with r/AskPhysics in a way the models picked up.

Verification of 2.0

  • ONNX == PyTorch: 2.4e-7.
  • Feature parity on 539 rows: 0.0021, 0 token mismatches.
  • Score parity: cold 1.1e-4, warm 2.5e-4.
  • 0.7 ms per candidate.

Ranking quality under backend conditions (NDCG@10; artifacts/backend_modes_report.json)

  • Full = every Reddit field, as in training (offline upper bound).
  • Unknown community names = every community gets a distinct name the models never saw, as Unihive communities will.
  • Backend data = also no flair, tags without confidences, one dwell value (hover 0, focal = visible), no upvote ratio or awards.
  • No Qwen3 = also no content vectors (today's backend).
Feed 1.1 full 1.1 unknown comm. 1.1 backend 1.1 backend, no Qwen3 2.0 full 2.0 backend 2.0 backend, no Qwen3 random
Validation, whole pool 0.685 0.673 0.574 0.625 0.650 0.579 0.604 0.350
Validation, 25-post pages 0.642 0.652 0.582 0.534 0.559 0.595 0.582 0.350
Test, whole pool 0.316 0.333 0.291 0.267 0.340 0.329 0.268 0.201
Test, 25-post pages 0.458 0.459 0.483 0.465 0.415 0.397 0.441 0.286
New session 1, start 0.413 0.354 0.290 0.225 0.255 0.159 0.155 0.119
New session 1, pages 0.559 0.487 0.396 0.359 0.468 0.486 0.362 0.282
New session 2, start 0.110 0.095 0.139 0.139 0.110 0.110 0.110 0.079
New session 2, pages 0.381 0.394 0.522 0.338 0.403 0.382 0.372 0.245
Mean 0.446 0.431 0.410 0.369 0.400 0.380 0.362 0.239

Reading the table

  • 2.0 gives identical scores with real or unknown values of the community field (checked: the "unknown community names" column equals "full" for 2.0, so it is not repeated). That test changed the community field only; it did not change the domain token, which still carries subreddit names for text posts (see the correction at the top).
  • Under backend conditions, 2.0 is 0.03 (with Qwen3) and 0.01 (without) below 1.1. The 1.1 figures are optimistic, because its content vectors still contain the Reddit subreddit name, which Unihive posts will not have.
  • Both are well above random everywhere.
  • Single feeds (one request) are noisy: a 10-position list with 10–11 engaged posts out of 89–142.

Re-check on your two later sessions (engine_v2/validate_two_sessions.py β†’ artifacts/validation_two_sessions.json; each engine with its own content vectors)

Session 1 (89 posts, 11 engaged) Session 2 (142 posts, 10 engaged)
NDCG@10 engaged in top 10 AUC NDCG@10 engaged in top 10 AUC
Engine 2.0 0.255 3 0.688 0.110 1 0.498
2.0 without content vectors 0.249 3 0.760 0.110 1 0.532
Engine 1.1 0.413 5 0.780 0.110 1 0.602
Reddit feed order 0.641 6 0.917 0.312 3 0.773
Random 0.124 1.1 0.50 0.070 0.6 0.50

25-post pages: 2.0 scored 0.468 and 0.403 (1.1: 0.559 and 0.381). 2.0 lost two engaged posts from the session-1 top 10. Its top two posts there were finance posts promoted by the topic-first rule, because "investing-and-markets" is one of P09's inferred interests.

New users (engine_v2/validate_new_users.py β†’ artifacts/new_user_report.json)

The same real feeds, ranked as if each user had just signed up: picked interests only, no history (the code checks that w = 0). NDCG@10:

Feed New user (interests only) New user, no interests Same user, full history Random
Validation, whole feeds 0.525 0.564 0.650 0.350
Validation, 25-post pages 0.525 0.568 0.559 0.350
Test, whole feeds 0.410 0.303 0.340 0.201
Test, 25-post pages 0.426 0.460 0.415 0.286
Session 1, one feed 0.221 0.445 0.255 0.119
Session 1, pages 0.341 0.527 0.468 0.282
Session 2, one feed 0.139 0.139 0.110 0.079
Session 2, pages 0.359 0.377 0.403 0.245
Mean 0.368 0.423 0.400 0.239

Reading the results

  • A brand-new user gets a useful feed from the first request: about 54% above random.
  • History adds on top (0.368 β†’ 0.400).
  • A user with no picked interests is still served, ranked on post quality and content.

These users never picked interests themselves; theirs were inferred from behaviour, and some guesses were wrong. That is why "no interests" scored highest. Real signup picks should match better. If they don't, lower topic_first_slots for new users (not changed in 2.0).


14. Backend compatibility

Checked against the real backend code (backend/reco/*, backend/api/schemas.py, migrations 004/005, db/trumio_schema_dump.sql):

  • Taxonomy: the backend's reco/vocab.py holds exactly the engine's 13 broad and 114 child slugs (compared slug by slug).
  • Post meta (store.posts_meta): tags (frozenset of slugs, no confidences), cat, created, likes, comments, views, author.
  • Rows: posts (community_id, post_type ∈ TEXT/LINK/MEDIA/NEWS/REPOST/QUESTION/POLL, title, body, url, counters) and post_reco (tag_slugs, CLIP-512 emb_fused).
  • User state (store.get_state): base_tags (the interests picked at onboarding, broad or child slugs), tag_weights, a 512-d embedding.
  • Events: the client's IMPRESSION / VIEW / LONG_VIEW / SKIP / CLICK / SHARE / SAVE / HIDE / NOT_INTERESTED. Likes and comments are written to interaction_events by crud/posts.py. The Redis burst buffer holds {type, post_id, dwell_ms, t, surface, context}.

Mapping (backend_adapter.py)

Engine input From the backend
interests state["base_tags"]; a broad pick ("ai-ml") expands to all of its child tags
score, comments, impressions likes, comments, views
age_hours (now βˆ’ created) / 3600
tags post_reco slugs, confidence 1.0
post_type TEXT/QUESTION/POLL β†’ text, LINK/NEWS β†’ link, REPOST β†’ crosspost, MEDIA β†’ image (or video, from the media kind)
domain parsed from the post url
embedding Qwen3 vector under reco:post:{id}:q3 (new). Missing β†’ neutral content
community posts.community_id, only for the user's per-community history rates
history burst buffer (read without draining) + interaction_events (with the post's community and author). Events on one post within 30 min = one interaction. engaged = LONG_VIEW / CLICK / SAVE / SHARE / LIKE / VOTE / COMMENT / REPLY / JOIN / POLL_VOTE or dwell β‰₯ 3 s, unless SKIP / HIDE / NOT_INTERESTED. ts_ms = received time βˆ’ dwell

build_pool_hybrid keeps the backend's retrieval exactly: graph.expand_weights, the tag indexes, seen_among, the own-post filter and compose.split_counts. Only the scoring is replaced. It writes the pool with store.set_pool and store.set_level, and returns [(post_id, score, "cat|tags")], the same shape as build_pool.

Tests (tests/test_backend_adapter.py, 7 tests)

  • Interests, post mapping and eventβ†’history mapping, including the 30-min visit merge, NOT_INTERESTED overriding dwell, the shown-time correction and future events being dropped.
  • rank_pool with backend shapes:
    • a new user with no Qwen3 vectors gets pure v2b and topic-first;
    • CLIP vectors are refused;
    • with Qwen3 + history the warm weight is exactly 0.6 at 15 engaged posts.
  • Qwen3 codec and embedding text.
  • Postgres + buffer merge, with a fake connection that returns HISTORY_SQL-shaped rows.
  • The backend's real reco.store on fakeredis (index_post, save_state, buf_push, posts_meta, set_pool, pool_all). It also checks that build_pool_hybrid returns the same candidates as the backend's own service.build_pool.

All pass. Not tested: HISTORY_SQL against a live Postgres (Docker was not running). It was checked against the schema dump.

Integration steps: see models/hybrid_engine/INTEGRATION.md.

  1. Store a Qwen3 vector in the tagging worker.
  2. Delegate build_pool to build_pool_hybrid.
  3. Add onnxruntime to the requirements.
  4. Shadow mode β†’ replay β†’ A/B test.

15. Code walkthrough (every file of the engine)

models/hybrid_engine/ (the shipped package)

__init__.py: exports HybridRecommender, FeatureBuilder, rank01.

features.py: rebuilds the exact training inputs from raw data.

  • clean(s) collapses whitespace.
  • token(v) maps a category value to its training token: 'none' for missing or empty, whitespace β†’ '_'.
  • unit(v) scales a vector to unit length.
  • EMB_DIM = 1024; content_vector(emb) returns a unit vector, or None when the embedding is missing, the wrong size (e.g. CLIP-512), all zeros or not finite.
  • rank01(x) gives ranks inside one request: lowest β†’ 1/n, highest β†’ 1.
  • FeatureBuilder(meta) loads the constants, PCA, taxonomy, tag similarity, Stage-1 weights and IDF from meta.json.
    • pca16(emb): the 16 content components.
    • z(name, raw): standardise with the training mean/std.
    • post_floats(post): the 11 post statistics + 16 components (all zeros when the content is unknown). Missing age β†’ median; missing upvote ratio β†’ 1.0 + flag.
    • post_tokens(post): category tokens. User/post IDs are always 'unk'; the domain falls back to 'other' outside the top 30; membership β†’ yes/no/unk. The models read only the tokens listed in their meta.json (2.0: post_type, domain, post_language, + member for warm).
    • tag_vector(tags): {slug: confidence} or a list β†’ a 114-d confidence vector (unknown slugs ignored).
    • interest_profile(picks): q (picked tags), r (their broad domains), ms (best similarity of each tag to any pick).
    • tag_match(P, q, r, ms): the 8 tag-match features (section 8).
    • tag_closeness(tm): Stage-1 logistic score.
    • history_floats(history, post, now_ms, post_X): the causal history features of section 4. J = interactions with ts < now whose viewing ended by now; R = the last 20. Content features use only past posts that have a vector. Returns the features and the number of engaged posts in J (which drives w).

engine.py: HybridRecommender.

  • __init__(artifacts_dir, **overrides) loads meta.json and two single-threaded CPU ONNX sessions. Overrides replace the defaults in meta["defaults"].

  • _tokens / _floats order a feature dict into the model's input vector. Unknown tokens β†’ index 0.

  • _prep_history sorts the history by time, checks each vector with content_vector, and precomputes pca16.

  • warm_weight(engaged) returns 0 for no engagement, else min(w_max, e / (e + n0)).

  • score(user, candidates, now_ms): one pass over the candidates.

    1. Content vector, post floats, tokens.
    2. Tag match β†’ tag closeness and the topic-match flag (shares a picked tag or its broad domain).
    3. Cold inputs = standardised post floats + standardised tag-match features.
    4. History features + engaged count β†’ warm inputs.
    5. Run the cold ONNX β†’ Platt β†’ v2b = p_cal + tag_bonus Β· rank01(closeness).
    6. Run the warm ONNX only if some w > 0.
    7. Blend the ranks, multiply by freshness, add the push.

    now_ms may be a list (one time per candidate) for replays. Returns one dict per candidate with every component.

  • order(scored): topic-first. While posts remain: take the best topic_first_slots topic matches, fill the block of topic_block with the best remaining posts, repeat. Too few matches β†’ the slots fall back to any post. Sets position.

  • rank = order(score(...)); recommend = the top k of rank.

backend_adapter.py: the bridge to the Unihive backend (section 14).

  • Event vocabularies: ENGAGED_EVENTS, NEGATIVE_EVENTS, CLICK_EVENTS. Constants: 3 s engagement threshold, 30-min visit gap, 2-min dwell cap.
  • POST_TYPE_MAP and MEDIA_KIND_MAP translate backend post types to the trained ones.
  • q3_key, encode_q3, decode_q3 store a unit Qwen3 vector as float16 in Redis. A wrong size is refused.
  • qwen3_text(title, body, caption): the exact embedding text of 2.0.
  • expand_interests(slugs, taxonomy): broad picks β†’ children; unknown slugs dropped; order kept.
  • post_from_meta(pid, meta, now_s, emb_q3, extra) and post_from_row(row, now_s, emb_q3): backend post β†’ engine candidate.
  • _norm_event accepts buffer dicts and interaction_events rows.
  • history_from_events(events, posts, now_s): merges visits, decides engaged/clicked, corrects the shown time, fills community/author/vector.
  • user_from_backend(state, history, taxonomy).
  • HISTORY_SQL reads interaction_events joined with posts (newest first, limited).
  • fetch_q3(r, pids) (one MGET), peek_buffer(r, uid) (LRANGE, no drain).
  • load_history(conn, r, uid, buffered, limit, now_s): Postgres rows + buffer β†’ history with vectors.
  • build_pool_hybrid(r, uid, state, level, *, engine, conn, ...): the drop-in build_pool.
  • rank_pool(engine, state, meta, history, now_s, q3, extra, size): returns the pool scores (strictly decreasing, engine order) and the explanation dicts.

example.py: a demo: the same candidates for a brand-new user and after 30 engaged posts.

tests/test_engine.py

  • test_smoke:
    • a new user gets pure v2b;
    • the weight grows (0.091 / 0.333 / 0.667 / 0.7 at 1 / 5 / 20 / 100 engaged posts) and is capped;
    • future events are ignored;
    • freshness is monotone and the push applies only to fresh posts;
    • topic-first fills the first 7 slots, slots=0 gives plain score order, and few matches fall back gracefully.
  • test_no_community_or_flair_in_models: the 2.0 guard (section 13).

tests/test_backend_adapter.py: section 14.

requirements.txt: numpy, onnxruntime.

artifacts/: section 13 and the package README.

models/training/engine_v2/ (how the engine was built and checked)

  • evalkit.py: shared protocol.

    • Loads the frozen v1 rows (user, split, label, inferred interest picks Q, post tag vectors P) and the offline predictions.
    • Platt on validation only.
    • user_metrics (NDCG@10, Hit@10, MAP@10, AUC, topic match per user), pooled_auc, calib_report.
  • engine_lib.py: taxonomy, tag embeddings and similarity, interest profiles (picked = 1.0, implied parent = 0.6), the 8 tag-match features, the hand-set Stage-1 rule.

  • build_hybrid_engine.py: engine 1.0 build.

    • Ens averages the seeds' sigmoid outputs inside one module.
    • export writes ONNX (opset 17, dynamic batch) with the input order and vocabularies.
    • check compares ONNX with PyTorch on the validation and test files.
    • Writes meta.json and build_report.json.
  • retrain_warm_strict.py: retrains the 1.x warm model on reco_ctr_noid_strict (the serving rule) and updates the artifacts.

  • build_engine_v2.py: engine 2.0 build (section 13). It reuses the 1.0 helpers and asserts that subreddit/flair are not inputs.

  • diag_engine_v2.py: the four-variant diagnosis of the 2.0 AUC drop.

  • verify_hybrid_engine.py: runs the engine's real code path on raw data:

    1. feature parity on every valid/test row;
    2. score parity against the offline ensembles;
    3. a replay (whole pools and 25-post pages);
    4. a cold-start vs long-history example.

    It detects the engine version from meta.json and uses the matching embeddings, datasets and offline scores.

  • sweep_hybrid_engine.py: knob sensitivity (w_max, decay/push) on validation and test.

  • validate_v11.py: 1.0 vs 1.1 variants on new session 1, test and validation.

  • validate_new_session.py and validate_session.py: rebuild per-post outcomes from raw Trace events with the training rules, tag new posts with Groq (cached), embed them with the version-matching text (cached), and rank as live requests with P09's earlier history.

  • validate_backend_modes.py: section 13's backend-conditions table, run once per engine version.

Data pipeline files (models/training/)

  • prep_recbole.py: section 4 (features, split, switches).
  • embed_posts_qwen3.py / embed_posts_qwen3_nocomm.py: the 1.x / 2.0 content vectors.
  • run_models.py, tune_and_final.py, continue_training.py, export_onnx.py, apply_recbole_patch.py: phase 1 training and export.
  • data_pipeline/: captions, pullpush recovery, interaction table.

16. How to reproduce

# engine tests (any machine): numpy + onnxruntime (+ redis, fakeredis for the backend store test)
cd models && python -m hybrid_engine.tests.test_engine && python -m hybrid_engine.tests.test_backend_adapter
UNIHIVE_BACKEND=/path/to/backend python -m hybrid_engine.tests.test_backend_adapter     # include the backend's real reco.store

# rebuild engine 2.0 from the data (GPU recommended; RecBole env from training/RETRAIN.md)
python embed_posts_qwen3_nocomm.py
EMB_NPZ=post_emb_qwen3_0p6b_nocomm.npz SUFFIX=_nc META_DIR=package_meta_nc python prep_recbole.py
EMB_NPZ=post_emb_qwen3_0p6b_nocomm.npz SUFFIX=_nc_noid_strict META_DIR=package_meta_nc MASK_IDS=1 STRICT_PAST=1 python prep_recbole.py
python engine_v2/build_engine_v2.py          # writes hybrid_engine/artifacts_v2 -> install as hybrid_engine/artifacts
python engine_v2/verify_hybrid_engine.py     # parity must stay near zero
python engine_v2/validate_backend_modes.py   # ranking quality under backend conditions

build_engine_v2.py needs the frozen v1 outputs (engine_v1_frozen/out_engine) and the reco_engine dataset from the earlier build. Both are in the local reco_data workspace; the main datasets are in models/training/.


17. Limits and next steps

Limits

  • Tiny data. 4 users, 44 positive test examples: an AUC standard error of about 0.05. Most differences between variants are within noise. Treat all defaults as starting points.
  • Reddit is not Unihive. Post types, popularity scales and the engagement mix will differ. Retraining on Unihive logs is the most important next step, and the pipeline supports it.
  • Feed position dominated engagement in the trace. Reddit's own order (which knows position and much more) stays far ahead.
  • Not yet available in the backend: Qwen3 vectors, tag confidences, and hover / focal dwell. The cost of each is measured in section 13.

Next steps

  1. Add the Qwen3 vector to the tagging worker (INTEGRATION step 1).
  2. Shadow-mode logging of engine vs current scores.
  3. Collect 2–4 weeks of logs, then re-tune w_max, n0 and decay/push, and retrain both models on Unihive data.
  4. Keep exploration in compose at 15–20%.
  5. Add tag confidences to post_reco (a small gain).

18. Glossary of metrics

  • AUC: probability that a random engaged post is scored above a random non-engaged one. 0.5 = chance.
  • GAUC: AUC per user, averaged.
  • NDCG@10: how close the top 10 is to the best possible ordering of the engaged posts. 1 = all engaged posts first, with position-discounted credit.
  • Hit@10: at least one engaged post in the top 10.
  • MRR: 1 / (position of the first engaged post).
  • Logloss / Brier / ECE: how good the probabilities themselves are (lower is better). ECE = calibration error.
  • Platt scaling: a one-feature logistic regression on the model's logit, fitted on validation, that repairs over-confidence.
  • Parity: the largest difference between two computations that should be identical (engine vs training file, ONNX vs PyTorch).
  • ILD: intra-list diversity, the mean pairwise distance of the posts in a list.