Book Slate Recommendation
Recommend a ranked slate of books for each reader that balances relevance, diversity, and freshness. Produces an ordered, explainable set of picks per reader.
What this template is for
Every consumer-facing platform with a “row of K things” surface — a streaming homepage, an e-commerce cross-sell carousel, a news feed, a learning-platform course slate — faces the same decision: pick K items per user that are individually relevant, collectively diverse, and explainable enough to satisfy the platform’s business rules. Getting the composition right at the top-of-row (hero) position matters most, because that is where engagement concentrates. Doing this by post-processing a ranked list rarely respects the diversity, freshness, and exposure floors that editorial, curation, and compliance teams need on every slate.
This template composes the slate as a single optimization decision rather than a filtering pass, so relevance, diversity, freshness, exposure, and explainability are all enforced jointly. It chains RelationalAI’s reasoners — knowledge-graph path walks and graph analytics feeding a constraint-programming optimizer — through one shared ontology, so every recommended item carries a decomposable, graph-grounded explanation without leaving the model.
Who this is for
- Recommender-systems engineers building the constrained slate-composition stage of a ranking funnel.
- Editorial and curation teams who need diversity, freshness, and exposure floors enforced on every slate.
- Compliance and transparency teams who want knowledge-graph-grounded, decomposable explanations for each recommended item.
- Operations researchers exploring multi-reasoner pipelines on RelationalAI.
- Assumed knowledge: comfortable reading Python; the recommender-systems, graph, and optimization terms are explained as they come up. As an advanced multi-reasoner template it goes faster if you have followed a single-reasoner template first, but no deep RelationalAI experience is required to run it.
What you’ll build
- A heterogeneous knowledge graph over
User,Book,Author, andSubjectwith typed edges (read,written_by,about,similar_to), plus a unifiedItem.connected_tosuper-edge the path walker traverses. - A
Candidateconcept generated by bounded knowledge-graph walks (the PyRel paths library), with per-(user, candidate)typed-evidence counts (path_count_via_author,path_count_via_subject,path_count_via_kg_walk, summed intopath_count_total). - A
Book.triangle_countstructural-embeddedness measure from graph analysis over the similarity graph, used to pin the hero slot to a structurally central book. - A prescriptive constraint-programming model on MiniZinc that assigns each candidate a slot and enforces ten rules (cardinality, slot uniqueness, already-read exclusion, author uniqueness, subject-concentration cap, freshness floor, originals-exposure floor, cold-start cap, hero pin, explanation-path floor) under a position-weighted engagement-decay objective.
- Customer-readable result tables: the chosen slate ordered by slot, the per-user subject distribution, and per-pick explanation-path support.
What’s included
- Model: a three-stage pipeline (knowledge-graph path walks, then graph analysis, then prescriptive optimization) on a single shared ontology, wired to the bundled CSVs.
- Runner:
book_slate_recommendation.py— a single Python script that runs end-to-end against a Snowflake-connected RAI account. - Runbook:
runbook.md— a paste-testable walkthrough that reproduces the template step by step with the RAI skills; as important a reference as the script itself. - Sample data: a deterministic Open Library (CC0 public domain) slice of roughly 59 books, 52 authors, and 12 subjects, with synthetic users and read events layered on top. See Sample data below.
- Outputs: the formulation, the solve-result block, the chosen slate (with per-book
path_count_totalandtriangle_count), the per-user subject distribution, per-pick explanation-path support, and diagnostic candidate-set and structural-embeddedness tables.
Prerequisites
Access
- A Snowflake account with the RelationalAI Native App installed.
- A Snowflake user with permissions to access the RelationalAI Native App.
Tools
- Python 3.10 or newer.
- RelationalAI Python SDK (
relationalai == 1.1.0).
Quickstart
-
Download the template and extract it:
Terminal window curl -O https://docs.relational.ai/templates/zips/v1/book_slate_recommendation.zipunzip book_slate_recommendation.zipcd book_slate_recommendation -
Create a virtual environment and activate it:
Terminal window python -m venv .venvsource .venv/bin/activatepython -m pip install --upgrade pip -
Install dependencies:
Terminal window python -m pip install . -
Configure (prompts for Snowflake account, role, and profile name):
Terminal window rai init -
Run the template end-to-end:
Terminal window python book_slate_recommendation.py -
Expected output (a few lines confirm a successful run). Solver build strings and exact wall times vary, but
status: OPTIMALand the chosen slate are stable for the bundled slice:Solve result:• status: OPTIMAL• solve time: a few seconds on the bundled slice• num_points: 1• solver: MiniZincFinal slate per user (K = 3, slot 1 = top of row):user_id slot book_id path_count_total triangle_count1 1 ... ... ...1 2 ... ... ...1 3 ... ... ......To pull a larger Open Library slice, see Scale up / productionize below.
Template structure
Short annotated tree of the template folder:
book_slate_recommendation/├── book_slate_recommendation.py # Main script (paths walk + graph + prescriptive)├── runbook.md # paste-testable walkthrough (RAI skills)├── pyproject.toml # dependencies├── README.md # this file└── data/ ├── fetch_open_library_slice.py # CC0 Open Library slice fetcher (--size sm|md|lg) ├── users.csv # 25 synthetic readers ├── books.csv # ~59 books (title, age_days, in_house flag) ├── authors.csv # ~52 authors ├── subjects.csv # 12 subjects ├── read.csv # 150 read events (user, book, rating) ├── book_author.csv # book-to-author edges ├── book_subject.csv # book-to-subject edges └── book_similar.csv # derived book-to-book similarity edgesStart here: run python book_slate_recommendation.py for the full three-stage pipeline end to end, or follow runbook.md to reproduce it step by step with the RAI skills.
Sample data
The bibliographic data is a deterministic slice of Open Library, released under CC0 (public domain), pulled by data/fetch_open_library_slice.py. Synthetic users and read events, plus the derived similar_to edges, are generated deterministically by the fetch script, so reruns are reproducible. The bundled sm slice carries roughly 59 books, 52 authors, and 12 subjects with 25 users and 150 read events.
users.csv(25 rows) — synthetic readers (id,name).books.csv(59 rows) — catalog items withtitle,age_days(recency), and anin_house0/1 flag (1 = an in-house / originals item, 0 = third party).authors.csv(52 rows) — book authors (id,name).subjects.csv(12 rows) — topical categories (id,name).read.csv(150 rows) — read events (user_id,book_id,rating); the user’s read history and the already-read exclusion source.book_author.csv(71 rows) — book-to-author edges.book_subject.csv(128 rows) — book-to-subject edges.book_similar.csv(400 rows) — book-to-book similarity edges, derived from shared-author or shared-subject overlap. In production-recommender terms this is the retrieval artifact: swap it for a learned embedding’s nearest neighbors, a behavioral co-engagement matrix, or a third-party recommendation API. The model treats it as input; the upstream choice is independent of the slate-stage logic.
Model overview
One shared ontology threads all three stages. The path-walk stage derives candidates and their evidence, the graph stage adds a structural measure, and the prescriptive stage reads both to compose the slate.
- Key entities:
User— a reader;Book— a catalog title (anItemsub-concept);AuthorandSubject— a book’s authors and topics;Slot— one of the K ordered recommendation positions in a user’s slate; the derivedCandidate— a (user, unread book) pair scored by its knowledge-graph explanation paths. - Primary identifiers: integer
idonUser,Book,Author,Subject;posonSlot; composite(user_id, book_id)onCandidate. - Important invariants:
Book.in_houseis a 0/1 flag;Book.age_daysis non-negative; similarity edges are unique on(src_book_id, dst_book_id); every foreign key resolves; slot decisions take values in1..K+1, where slot 1 is the hero position and slot K+1 is the unpicked sentinel.
The typed edges (read, written_by, about, similar_to) carry the graph structure; the unified Item.connected_to super-edge is their symmetric union, so the path walker can traverse one 2-arity relationship across the heterogeneous graph. Each Candidate carries typed evidence counts (path_count_via_author, path_count_via_subject, path_count_via_kg_walk, summed into path_count_total) and a slot decision.
For the full concept and property definitions, see book_slate_recommendation.py; runbook.md builds them step by step with the RAI skills.
How it works
The pipeline runs in three stages over the shared ontology. Its architectural centerpiece is the bounded knowledge-graph walk: without the paths library there are no candidates and no optimization variables.
Stage 1 — Knowledge-graph path walks (candidate generation). The path walker traverses Item.connected_to (the symmetric union of read, written_by, about, and similar_to) from each user up to MAX_HOPS = 2 hops. With a Book endpoint, the candidate set is the union of length-1 walks (User -> read_Book, later excluded because already read) and length-2 walks (User -> read_Book -> similar_Book). Each reachable (user, book) becomes a Candidate. The per-(user, candidate) typed counts — shared-author and shared-subject joins against the user’s read history, plus the count of walker-enumerated paths — feed the explanation floor, the cold-start cap, and the objective. Each count is defined for every candidate so a candidate with no matches still gets a value, and path_count_total sums the three.
Stage 2 — Graph analysis (hero-slot embeddedness). A Graph is built from Book.similar_to, and triangle count returns the per-book count of similarity triangles each book participates in, stored as Book.triangle_count. Triangle count is graph-derived: it depends on the topology of the similarity graph, not on a per-book scalar that could be supplied externally. Stage 3 uses it to pin the hero slot to a densely embedded book.
Stage 3 — Prescriptive constraint program (slot assignment). Each candidate’s slot is a decision in 1..K+1, where slots 1..K are slate positions (1 = hero, K = bottom of row) and slot K+1 is the unpicked sentinel. The K+1 encoding lets the position weight (K + 1 - slot) be zero for unpicked candidates, so no auxiliary picked/unpicked indicator is needed. The model enforces ten constraints — cardinality (exactly K picks per user), slot uniqueness, already-read exclusion, author uniqueness, subject-concentration cap, freshness floor, originals-exposure floor, cold-start cap, hero pin, and explanation-path floor — under a position-weighted engagement-decay objective. The objective weights each pick by (K + 1 - slot) * path_count_total, largest at slot 1, so the solver places the highest-path-support candidates at the top of the row — the canonical position-decay engagement model. After the solve, the runner verifies every constraint, requires an OPTIMAL status, and prints the chosen slate, subject distribution, and per-pick explanation support.
See book_slate_recommendation.py for the implementation and runbook.md for the skill-driven reproduction.
Where this fits in production. Production recommender systems run a multi-stage funnel: catalog, then retrieval, then pre-ranking, then ranking, then slate optimization. This template implements the final slate-optimization stage — where business rules, diversity, exposure floors, and explainability constraints land — with the upstream graph and path-walk signals in the same declarative model. The per-typed evidence counts the runner emits for each pick (paths_via_author, paths_via_subject, paths_via_kg_walk) are a decomposable, graph-grounded justification suitable for transparency workflows.
Why constraint programming (MiniZinc), not mixed-integer programming
The model is pure integer with multi-valued integer decisions. MiniZinc’s constraint-programming propagators handle this shape natively without linear-programming relaxation overhead, and the per-pair count caps used for slot, author, and subject uniqueness map directly to MiniZinc’s global-cardinality-constraint (GCC) propagators. Slot-equality reification (for the hero pin) and the conditional domain rule (already-read implies slot K+1) are also native to constraint programming. The same prescriptive layer would run on HiGHS if you later add a float-coefficient scalar to the objective (see Extend the model), so the formulation extends without restructuring.
Customize this template
Focus on the first changes most users will make.
Use your own data
- Replace the CSVs in
data/with your own, keeping the column names listed in Sample data above. - The runner runs pre-solve validation before installing any model rules: it checks for unique keys, no null required columns, resolving foreign keys, non-negative
age_days, and anin_housevalue in{0, 1}. Each failing check raises a focused error naming the offending rows. - The
book_similar.csvsimilarity edges are the retrieval artifact. Swap the bundled shared-author-or-shared-subject recipe for any retrieval source (learned-embedding nearest neighbors, a behavioral co-engagement matrix, a third-party recommendation API); the rest of the pipeline is unchanged. - For Snowflake-backed runs, swap the
read_csv(...)calls formodel.data(snowflake_table)calls.
Tune parameters
The constraint dials are top-of-file constants in book_slate_recommendation.py:
- Slate size —
SLATE_SIZE_K(default3), the K of “row of K things”. - Walk depth —
MAX_HOPS(default2); bumping to 3 or more saturates fast on a heterogeneous graph. - Diversity and exposure —
MAX_PER_SUBJECT(default2),FRESHNESS_FLOOR(default1),FRESH_WINDOW_DAYS(default365 * 30),ORIGINALS_FLOOR(default1),COLD_START_CAP(default2). - Explainability —
EXPLANATION_FLOOR(default8),WEAK_EXPLANATION_THRESHOLD(default2),HERO_EMBEDDEDNESS_THRESHOLD(default4).
See Troubleshooting for the joint-feasibility relationships between these dials. The explainability and threshold defaults are tuned to the bundled sm slice’s path_count_total distribution (range 2 to 12); rescale them when you change the data size.
Extend the model
- Add a per-book popularity scalar to the objective. Declare a
Book.cf_scoreFloat (from collaborative filtering, learned-embedding affinity, or a third-party score), populate it from your retrieval stage, and rewrite the objective assum((K+1-slot) * (PATH_WEIGHT * path_count_total + CF_WEIGHT * cf_score)). HiGHS handles the float-coefficient blend natively; the structural graph contribution (triangle_count, hero pin) stays in place because it constrains the slate rather than scoring it. - Enumerate alternative slates.
solve("minizinc", solution_limit=N)returns N alternative slates for A/B exposure or human-in-the-loop editorial review. - Surface natural-language explanations. Pipe each pick’s per-typed counts (and, optionally, materialized top-N paths) into a language-model call to render explanations grounded in graph facts.
- Retarget to another domain. Replace
BookwithProduct,readwithpurchased, andsimilar_towith co-purchase for a “frequently bought together” slate with category coverage and brand exclusivity; or replaceBookwithCourse, add prerequisite edges, and run a career-navigation slate with prerequisite respect.
Scale up / productionize
-
Pull a larger data slice. Fetch a bigger Open Library slice for a more realistic instance:
Terminal window python data/fetch_open_library_slice.py --size md # ~250 bookspython data/fetch_open_library_slice.py --size lg # ~600 booksThe fetch script is idempotent and caches API responses under
data/_cache/, so reruns are reproducible and offline after the first pull. After changing size, rerunpython book_slate_recommendation.py. Two things to retune at larger sizes: theEXPLANATION_FLOORandWEAK_EXPLANATION_THRESHOLDconstants (larger slices produce much higher walk counts, so these thresholds become trivially satisfied unless rescaled), and the MiniZinctime_limit_sec(default180, comfortable atsm; raise it forlg). -
Replace CSVs with live data. Point the loaders at Snowflake tables so the pipeline reads current read history and retrieval edges from your warehouse.
Troubleshooting
Solve returns INFEASIBLE / the final slate is empty
The runner enforces ten constraints and requires an OPTIMAL status. If the data is too sparse or too clustered, the joint constraints can be infeasible. The dominant causes on customer slices are:
- Too few candidates after the already-read exclusion. Each user needs at least
SLATE_SIZE_Kcandidates in their 2-hop reach after already-read books are excluded. Recommendable candidates come from length-2read_Book -> similar_Bookwalks, so a user whose reads have nosimilar_toneighbors will be infeasible. Inspect the candidate-set diagnostic printed at the end of the runner. - Disjoint floors.
FRESHNESS_FLOORandORIGINALS_FLOORare independent. A user with no candidate that is both fresh and in-house must spendFRESHNESS_FLOOR + ORIGINALS_FLOORdistinct picks, not the maximum of the two. - Author uniqueness versus slate size. Author uniqueness caps at one pick per
(user, author), so each user needs at leastSLATE_SIZE_Kdistinct authors in reach. - Cold-start cap and explanation floor.
EXPLANATION_FLOORis position-weighted. With at mostCOLD_START_CAPweakly-explained picks, the floor must still be reachable; inspect the per-user path-count distribution first. - Missing edges. Every picked book must have at least one
Book.about(Subject)and oneBook.written_by(Author)row, or the diversity and uniqueness caps silently exempt it. - Hero pin. Each user needs at least one candidate whose book has
triangle_count >= HERO_EMBEDDEDNESS_THRESHOLD, or there is no eligible book for slot 1. Inspect the structural-embeddedness diagnostic table, then either lowerHERO_EMBEDDEDNESS_THRESHOLDor densify the similarity input.
The runner runs a Python-level pre-solve assertion just before problem.solve(...) that materializes the candidate set, anti-joins against the read history, and raises a ValueError naming the affected users per shortfall, with a strategy block keyed by which condition fired. It also prints an unread-candidate-count table before solving.
Slow or timing out at --size lg
The default time_limit_sec=180 is sized for the bundled sm slice. At lg, raise it and watch the MiniZinc propagation progress in the solver log. If the solve still times out, drop SLATE_SIZE_K or relax EXPLANATION_FLOOR so propagation can prune harder.
Open Library fetch script slow or rate-limited
The fetcher caches every API response under data/_cache/ keyed by URL, so subsequent runs are offline. On a fresh --size lg pull the script makes a few hundred requests at the Open Library default rate. If your network blocks Open Library, point the fetcher at a snapshot of the https://openlibrary.org/data/ol_dump_latest.txt.gz bulk download.
Learn more
Core concepts
- Multi-reasoner workflows — chained reasoner patterns and ontology enrichment across stages.
- PyRel v1 query language —
model.where(...), aggregates, and result selection.
Reasoner reference
- Paths library — bounded knowledge-graph walks with
.repeat(...)andall_paths(). - Graph reasoner — building a
Graph, node-concept binding, and structural metrics such as triangle count. - Prescriptive reasoner — the
ProblemAPI, integer decisions, constraints, and objectives on MiniZinc and HiGHS.
Support
- File issues at the RelationalAI templates repository.
References
- Open Library Developer API (CC0) — https://openlibrary.org/dev/docs/api. Bibliographic catalog used by the bundled slice;
data/fetch_open_library_slice.pypulls deterministic cuts.