Skip to content
These docs are for v1 of RelationalAI. Click here to view the v2 docs.

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.

Goals
PrioritizePredict
Reasoning types
GraphPrescriptive
Experience level
Advanced
Browse files

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, and Subject with typed edges (read, written_by, about, similar_to), plus a unified Item.connected_to super-edge the path walker traverses.
  • A Candidate concept 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 into path_count_total).
  • A Book.triangle_count structural-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_total and triangle_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

  1. Download the template and extract it:

    Terminal window
    curl -O https://docs.relational.ai/templates/zips/v1/book_slate_recommendation.zip
    unzip book_slate_recommendation.zip
    cd book_slate_recommendation
  2. Create a virtual environment and activate it:

    Terminal window
    python -m venv .venv
    source .venv/bin/activate
    python -m pip install --upgrade pip
  3. Install dependencies:

    Terminal window
    python -m pip install .
  4. Configure (prompts for Snowflake account, role, and profile name):

    Terminal window
    rai init
  5. Run the template end-to-end:

    Terminal window
    python book_slate_recommendation.py
  6. Expected output (a few lines confirm a successful run). Solver build strings and exact wall times vary, but status: OPTIMAL and 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: MiniZinc
    Final slate per user (K = 3, slot 1 = top of row):
    user_id slot book_id path_count_total triangle_count
    1 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 edges

Start 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 with title, age_days (recency), and an in_house 0/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 (an Item sub-concept); Author and Subject — a book’s authors and topics; Slot — one of the K ordered recommendation positions in a user’s slate; the derived Candidate — a (user, unread book) pair scored by its knowledge-graph explanation paths.
  • Primary identifiers: integer id on User, Book, Author, Subject; pos on Slot; composite (user_id, book_id) on Candidate.
  • Important invariants: Book.in_house is a 0/1 flag; Book.age_days is non-negative; similarity edges are unique on (src_book_id, dst_book_id); every foreign key resolves; slot decisions take values in 1..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 an in_house value in {0, 1}. Each failing check raises a focused error naming the offending rows.
  • The book_similar.csv similarity 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 for model.data(snowflake_table) calls.

Tune parameters

The constraint dials are top-of-file constants in book_slate_recommendation.py:

  • Slate size — SLATE_SIZE_K (default 3), the K of “row of K things”.
  • Walk depth — MAX_HOPS (default 2); bumping to 3 or more saturates fast on a heterogeneous graph.
  • Diversity and exposure — MAX_PER_SUBJECT (default 2), FRESHNESS_FLOOR (default 1), FRESH_WINDOW_DAYS (default 365 * 30), ORIGINALS_FLOOR (default 1), COLD_START_CAP (default 2).
  • Explainability — EXPLANATION_FLOOR (default 8), WEAK_EXPLANATION_THRESHOLD (default 2), HERO_EMBEDDEDNESS_THRESHOLD (default 4).

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_score Float (from collaborative filtering, learned-embedding affinity, or a third-party score), populate it from your retrieval stage, and rewrite the objective as sum((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 Book with Product, read with purchased, and similar_to with co-purchase for a “frequently bought together” slate with category coverage and brand exclusivity; or replace Book with Course, 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 books
    python data/fetch_open_library_slice.py --size lg # ~600 books

    The fetch script is idempotent and caches API responses under data/_cache/, so reruns are reproducible and offline after the first pull. After changing size, rerun python book_slate_recommendation.py. Two things to retune at larger sizes: the EXPLANATION_FLOOR and WEAK_EXPLANATION_THRESHOLD constants (larger slices produce much higher walk counts, so these thresholds become trivially satisfied unless rescaled), and the MiniZinc time_limit_sec (default 180, comfortable at sm; raise it for lg).

  • 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:

  1. Too few candidates after the already-read exclusion. Each user needs at least SLATE_SIZE_K candidates in their 2-hop reach after already-read books are excluded. Recommendable candidates come from length-2 read_Book -> similar_Book walks, so a user whose reads have no similar_to neighbors will be infeasible. Inspect the candidate-set diagnostic printed at the end of the runner.
  2. Disjoint floors. FRESHNESS_FLOOR and ORIGINALS_FLOOR are independent. A user with no candidate that is both fresh and in-house must spend FRESHNESS_FLOOR + ORIGINALS_FLOOR distinct picks, not the maximum of the two.
  3. Author uniqueness versus slate size. Author uniqueness caps at one pick per (user, author), so each user needs at least SLATE_SIZE_K distinct authors in reach.
  4. Cold-start cap and explanation floor. EXPLANATION_FLOOR is position-weighted. With at most COLD_START_CAP weakly-explained picks, the floor must still be reachable; inspect the per-user path-count distribution first.
  5. Missing edges. Every picked book must have at least one Book.about(Subject) and one Book.written_by(Author) row, or the diversity and uniqueness caps silently exempt it.
  6. 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 lower HERO_EMBEDDEDNESS_THRESHOLD or 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

Reasoner reference

  • Paths library — bounded knowledge-graph walks with .repeat(...) and all_paths().
  • Graph reasoner — building a Graph, node-concept binding, and structural metrics such as triangle count.
  • Prescriptive reasoner — the Problem API, 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.py pulls deterministic cuts.