Recommender

The Recommender package is a collaborative filtering engine for Canvas that turns member ratings into personalised product recommendations. It implements two complementary algorithms — item-based co-occurrence and Slope One weighted prediction — on top of the CakePHP 5 database layer. Rate products, query recommendations, and predict ratings with no machine-learning infrastructure required.

explanation

Core Concepts

The engine is built around two database tables and a small set of service classes. Understanding how they relate makes it straightforward to integrate recommendations into any Canvas application.

  • Ratings — Stored in vogoo_ratings. A rating is a float in [0.0, 1.0] assigned by a member to a product within a category. The special value -1.0 marks explicit disinterest.
  • Derived item pairs — Stored in vogoo_links. Each directed pair has an independent liked_count for members who liked both items, slope_count for members who genuinely rated both items, and diff_slope for the sum of rating(item_id2) minus rating(item_id1) over those genuine ratings. Rows remain while either count is positive.
  • Categories — All operations are scoped to a category. A default category is set in config and used when none is supplied explicitly. This allows a single table pair to serve multiple product catalogues.
  • RecommendationConfig — A value object holding all tunable constants. Injected via the Canvas DI container; no global configuration needed.
  • RecommendationEngine — Handles rating CRUD. Optionally maintains vogoo_links incrementally on every write.
  • ItemRecommender — Item-based CF and Slope One recommendations for both logged-in members and anonymous visitors.
  • UserSimilarity — Member similarity scoring and neighbour-based recommendations.
  • VisitorContext — In-memory rating store for anonymous visitors, serialisable to session.
  • Statistics — Catalogue-level counts and rankings.

Installation

Install via Composer:

composer require quellabs/recommender

Then publish the configuration file:

sculpt recommender:init

Edit config/recommender.php to tune the engine constants, then create the tables:

sculpt recommender:init-db

If you have existing ratings data in the current schema, populate vogoo_links with a full rebuild:

sculpt recommender:rebuild-links

Pause rating writes during a rebuild. It computes both pair measures regardless of direct_links and direct_slope, stages each category before replacing its rows in one transaction, and clears rows for categories whose ratings have been deleted. Use --category=N to rebuild one category, including an empty one.

Upgrading from the legacy cnt schema

Back up both vogoo_ratings and vogoo_links, pause rating writes, run packages/recommender/migrations/2026-10-independent-pair-counts.sql, and then run sculpt recommender:rebuild-links before resuming writes. The old cnt value combines the two algorithms and cannot be split reliably; the migration clears derived rows but preserves ratings. For rollback, stop writes, restore both table backups together, and return to the previous package version. Do not use recommender:init-db --force for an upgrade because it deletes ratings.

Quick Start

With Canvas (autowired)

RecommendationEngine and ItemRecommender are resolved automatically by the Canvas DI container — their constructors depend only on Connection (provided by quellabs/canvas-database) and RecommendationConfig (provided by this package), so no manual wiring is required:

use Quellabs\Recommender\RecommendationEngine;
use Quellabs\Recommender\ItemRecommender;

class ShopController {

    public function __construct(
        private RecommendationEngine $engine,
        private ItemRecommender $recommender,
    ) {}

    public function product(int $memberId, int $productId): Response {
        // Record a view
        $this->engine->automaticRating($memberId, $productId, purchase: false);

        // Fetch recommendations
        $recommended = $this->recommender->memberGetRecommendedItems($memberId, limit: 8);

        return $this->render('product', compact('recommended'));
    }
}

Standalone

use Cake\Database\Connection;
use Cake\Database\Driver\Mysql;
use Quellabs\Recommender\Config\RecommendationConfig;
use Quellabs\Recommender\RecommendationEngine;
use Quellabs\Recommender\ItemRecommender;

$connection = new Connection([
    'driver'   => Mysql::class,
    'host'     => 'localhost',
    'username' => 'root',
    'password' => '',
    'database' => 'mydb',
]);

$config      = new RecommendationConfig();
$engine      = new RecommendationEngine($connection, $config);
$recommender = new ItemRecommender($connection, $config);

Configuration

All options with their defaults:

// config/recommender.php
return [
    // Default category used when no category is passed to engine methods
    'category' => 1,

    // Minimum number of common ratings before similarity is considered reliable
    'threshold_nr_common_ratings' => 30,

    // Multiplier used in the similarity confidence calculation
    'threshold_mult' => 2,

    // Minimum rating for an item to count as "liked" in link calculations
    'threshold_rating' => 0.66,

    // Cost factor used in the member similarity spread calculation
    'cost' => 5.0,

    // Sentinel value stored to mark "not interested" (fixed at -1.0)
    'not_interested' => -1.0,

    // Maintain liked_count incrementally.
    'direct_links' => false,

    // Maintain slope_count and diff_slope incrementally.
    'direct_slope' => true,
];

Database credentials are read from config/database.php, which is shared with other Canvas packages. The Sculpt provider lists both files as config sources in composer.json.

direct_links and direct_slope are independent switches that control which columns of vogoo_links are maintained in real time:

FlagPowersCounts
direct_linksgetLinkedItems(), memberGetRecommendedItems()Co-occurrence — liked pairs only
direct_slopegetSlopeItems(), memberPredict(), memberPredictAll()All rated pairs

These flags control incremental writes only; a rebuild always computes both measures. When both are false, derived rows stay unchanged until a rebuild. Rebuild once before enabling either flag on an existing ratings store.

Database Schema

vogoo_ratings

ColumnTypeDescription
member_idINT UNSIGNEDYour application's user ID
product_idINT UNSIGNEDYour application's product/item ID
categoryINT UNSIGNEDCategory grouping (default: 1)
ratingFLOAT0.0–1.0, or -1.0 for "not interested"
tsDATETIMELast updated timestamp

vogoo_links

ColumnTypeDescription
item_id1INT UNSIGNEDFirst item in the pair
item_id2INT UNSIGNEDSecond item in the pair
categoryINT UNSIGNEDCategory grouping
liked_countINT UNSIGNEDMembers who rated both items at or above the like threshold
slope_countINT UNSIGNEDMembers who genuinely rated both items
diff_slopeFLOATSum of rating(item_id2) minus rating(item_id1) over genuine rating pairs
Note: The primary key on vogoo_links is (item_id1, item_id2, category). Category and reverse-item indexes support recommendation lookups. Statistics::numLinks() counts directed rows with either measure.

RecommendationEngine

Handles all rating reads and writes. When direct_links or direct_slope is enabled, write operations also update vogoo_links incrementally.

setRating() accepts finite values from 0.0 to 1.0 or exactly -1.0 for disinterest; invalid ratings or negative IDs return false. Invalid configuration and visitor ratings throw InvalidArgumentException. Categories must be nonnegative.

Recording Ratings

// Explicit rating — must be in [0.0, 1.0]
$engine->setRating($memberId, $productId, 0.8);

// Implicit rating from a purchase (1.0) or a click (0.7, or +0.01 if already rated)
$engine->automaticRating($memberId, $productId, purchase: true);
$engine->automaticRating($memberId, $productId, purchase: false);

// Mark as "not interested"
$engine->setNotInterested($memberId, $productId);

Reading Ratings

// Single rating — returns ['rating' => float, 'ts' => string] or []
$engine->getRating($memberId, $productId);

// Include "not interested" entries
$engine->getRating($memberId, $productId, notInterested: true);

// All ratings for a member, sorted by rating descending
$engine->memberRatings($memberId, orderByRating: true, ascending: false);

// Member statistics
$engine->memberNumRatings($memberId);
$engine->memberAverageRating($memberId);

// Product statistics
$engine->productNumRatings($productId);
$engine->productAverageRating($productId);
$engine->productRatings($productId);

Deleting Ratings

$engine->deleteRating($memberId, $productId);
$engine->deleteMember($memberId);    // removes all ratings for this member
$engine->deleteProduct($productId);  // removes all ratings for this product
Note: When direct_links or direct_slope is enabled, deleteMember() and deleteProduct() iterate over each rating individually to keep vogoo_links consistent. For large datasets, disable incremental updates and run a full rebuild instead.

ItemRecommender

Provides item-based CF and Slope One recommendations for both logged-in members and anonymous visitors.

Item-based CF

// Products that co-occur most frequently with a given product
$recommender->getLinkedItems($productId);
$recommender->getLinkedItems($productId, limit: 10);

// Recommended unrated products for a member, ordered by weighted co-occurrence
$recommender->memberGetRecommendedItems($memberId);
$recommender->memberGetRecommendedItems($memberId, limit: 8);

// Restrict to a specific set of product IDs (e.g. current stock)
$recommender->memberGetRecommendedItems($memberId, filter: $availableIds, limit: 8);

// "Why we recommend this" — member's rated products linked to the given product
$recommender->memberGetReasons($memberId, $productId);

Detailed Results and Cold Start

The existing item-based methods continue to return product IDs. The opt-in detailed methods return RecommendationResult objects with itemId, score, strategy, and contributingItemIds. With enough genuine rating history, item_links scores sum liked_count × (source rating − like threshold). For shorter histories, top_rated scores are average genuine ratings. Scores should be compared within the same strategy.

$results = $recommender->memberRecommendationsDetailed($memberId, filter: $availableIds, limit: 8);
// $results[0]->itemId, ->score, ->strategy, ->contributingItemIds

The detailed methods accept minHistory (default 1) and minRatings (default 2). The fallback excludes already rated and explicitly rejected items, respects categories and allowed-item filters, and requires at least minRatings genuine ratings per candidate. Allowed-item filters apply before SQL limits; lists over 500 IDs use a temporary indexed table loaded in batches.

Slope One

// Predicted rating for a single product
$predicted = $recommender->memberPredict($memberId, $productId);  // float|null

// All unrated products with predicted ratings, sorted best-first
$predictions = $recommender->memberPredictAll($memberId);
// [['product_id' => 42, 'rating' => 0.87], ...]

// Items ranked by average slope one diff relative to a product
$similar = $recommender->getSlopeItems($productId, minLinks: 5, limit: 10);
// [['product_id' => 17, 'diff' => 0.12], ...]

Slope One uses slope_count and diff_slope only. Disinterest does not contribute to predictions. memberPredict() estimates a single item even if the member has rated it; memberPredictAll() returns only unrated items.

Anonymous Visitors

For visitors without an account, hold ratings in a VisitorContext and pass it to the visitor methods. Persist the context across requests via session serialisation.

// Create and populate a visitor context
$visitor = new VisitorContext($config);
$visitor->setRating($productId, 0.9);
$visitor->setNotInterested($otherProductId);

// Item-based CF for a visitor
$recommended = $recommender->visitorGetRecommendedItems($visitor, limit: 8);
$reasons     = $recommender->visitorGetReasons($visitor, $productId);

// Slope One for a visitor
$predicted     = $recommender->visitorPredict($visitor, $productId);  // float|null
$allPredicted  = $recommender->visitorPredictAll($visitor, limit: 8);

// Detailed recommendations include score, strategy, and contributing item IDs
$visitorResults = $recommender->visitorRecommendationsDetailed($visitor, limit: 8);

UserSimilarity

Computes member-to-member similarity scores using a weighted mean-squared-error algorithm. Similarity ranges from 0 (no overlap or completely different taste) to 100 (identical).

$similarity = $userSimilarity->memberSimilarity($memberId1, $memberId2); // int 0–100

// Neighbours sorted by similarity, filtered by minimum score
$neighbours = $userSimilarity->getNeighbours($memberId, minSimilarity: 10, limit: 20);
// [['member_id' => 7, 'similarity' => 84], ...]

// Recommendations based on what similar members have liked
$recommended = $userSimilarity->memberGetRecommendedItems($memberId, minSimilarity: 20, limit: 8);
Performance: getNeighbours() aggregates candidate overlaps in one query, and recommendation ratings are read in batches of 500 neighbours. Zero-similarity neighbours are excluded even when minSimilarity: 0 is requested.

VisitorContext

An in-memory rating store for anonymous visitors. All operations are category-scoped. Serialise to session to persist across requests.

$visitor = new VisitorContext($config);

$visitor->setRating($productId, 0.9);
$visitor->setRating($productId, 0.5, category: 2);  // explicit category
$visitor->setNotInterested($productId);
$visitor->removeRating($productId);

$visitor->getRatings();                // all ratings in default category
$visitor->getRatedProductIds();        // product IDs only
$visitor->isEmpty();                   // true when no ratings exist

Statistics

$stats->numMembers();          // distinct members with at least one rating
$stats->members();             // array of member IDs
$stats->numProducts();         // distinct rated products
$stats->numRatings();          // total genuine ratings
$stats->numLinks();            // directed pairs with liked or Slope One data

// Most-rated products: [['product_id' => int, 'num_ratings' => int], ...]
$stats->mostRatedProducts(limit: 10);

// Top-rated products: [['product_id' => int, 'avg_rating' => float], ...]
$stats->topRatedProducts(limit: 10, minRatings: 5);

Multi-category Support

Every method accepts an optional ?int $category parameter. When omitted it falls back to the default set in RecommendationConfig. To serve multiple independent catalogues, either pass the category explicitly on each call, or instantiate separate config objects per catalogue:

$booksConfig  = new RecommendationConfig(category: 1);
$moviesConfig = new RecommendationConfig(category: 2);

$booksEngine  = new RecommendationEngine($connection, $booksConfig);
$moviesEngine = new RecommendationEngine($connection, $moviesConfig);

Sculpt CLI Commands

CommandDescription
sculpt recommender:initPublish config/recommender.php to the project root
sculpt recommender:init-dbCreate vogoo_ratings and vogoo_links tables
sculpt recommender:init-db --forceDrop and recreate existing tables (all data will be lost)
sculpt recommender:rebuild-linksRebuild both measures from all ratings and clear stale categories
sculpt recommender:rebuild-links --category=NRebuild a single category only

Ratings Reference

ValueMeaningSet via
1.0Purchased / maximum interestautomaticRating(purchase: true)
0.7Clicked — first viewautomaticRating(purchase: false)
0.7 + 0.01 per clickIncremental interest from repeat clicksautomaticRating(purchase: false)
0.0 – 1.0Explicit ratingsetRating()
-1.0Not interestedsetNotInterested()