CactusBrain Personalize

Ranking that learns from behaviour, entirely on the device. Interactions, preference vectors and scoring stay inside the app sandbox — there is no recommendation service to run and no behavioural history to hold. Version 0.1.0.

Personalize

What it does

  • Learns a preference vector per user from the items they interact with, weighted by what the interaction was.
  • Keeps three time horizons. A long-term profile, a recent one that decays on a half-life, and optional per-session aggregates, blended by .hybrid ranking.
  • Filters on metadata before scoring, so business rules like “nothing paywalled” are applied to candidates rather than patched onto results.
  • Diversifies. An optional MMR pass stops a feed collapsing into twenty variations of the last thing that was tapped.
You supply the embeddings

Personalize ranks vectors; it does not generate them. Pair it with CactusBrain Text for on-device embeddings, or pass vectors from an encoder you already use. It has no runtime dependency on either, and no network calls of its own.

Installation

Add to your project

iOS (Swift Package Manager)

// Package.swift
dependencies: [
  .package(url: "https://github.com/cactusbrain/cactusbrain-swift", from: "0.1.0")
]

// Then add the product to your target:
.product(name: "CactusBrainPersonalize", package: "cactusbrain-swift")

Requires iOS 17 and Xcode 26. Personalize is pure Swift with no binary dependency, so it does not pull the native runtime in.

Android (Gradle)

// build.gradle.kts
dependencies {
    implementation("ai.cactusbrain:cactusbrain-personalize:0.1.0")
}

Requires JDK 21 and minSdk 24. The module uses java.time and java.nio.file, so enable core library desugaring if you support API levels below 26.

Setup

Create a store

import CactusBrainPersonalize

// The learned profile is a file in your app's own container. Application
// Support is the right home: it is backed up and not purgeable, so a user who
// restores a device keeps what the app learned about them.
let directory = URL.applicationSupportDirectory.appending(
    path: "personalize",
    directoryHint: .isDirectory
)

let personalize = try CactusBrainPersonalize(storageDirectory: directory)
import ai.cactusbrain.personalize.CactusBrainPersonalize
import ai.cactusbrain.personalize.PersonalizeColdStartPolicy
import ai.cactusbrain.personalize.PersonalizeConfiguration
import ai.cactusbrain.personalize.PersonalizeEvent
import ai.cactusbrain.personalize.PersonalizeFilter
import ai.cactusbrain.personalize.PersonalizeItem
import ai.cactusbrain.personalize.PersonalizeRankingMode
import ai.cactusbrain.personalize.PersonalizeValue

val personalize = CactusBrainPersonalize(
    storageDirectory = context.filesDir.toPath().resolve("personalize"),
)

// addAll writes the snapshot once. Adding a catalogue with repeated add()
// calls rewrites it once per item, which is the slow way to start up.
personalize.addAll(articles.map { PersonalizeItem(it.id, it.embedding) })

The initializer is throwing on both platforms: it reads any existing snapshot on the way up, and a snapshot it cannot parse is an error rather than a silent fresh start. A user losing their history should not be something only they find out about.

Behaviour

Observe interactions

// Weights are built in: .liked counts for 1.0, .viewed for 0.1, and the
// negative events subtract, so a dismissal is signal rather than silence.
try await personalize.observe(user: accountID, item: "article-104", event: .liked)
try await personalize.observe(user: accountID, item: "article-233", event: .dismissed)

// A session groups one sitting. It lets a burst of interest steer the next
// screen without permanently reshaping who the reader is.
try await personalize.observe(
    user: accountID,
    item: "article-104",
    event: .opened,
    session: sessionID
)

The user identifier is yours and never leaves the device, so it can be an account ID or a per-install UUID — Personalize never transmits it, and nothing on our side could correlate it.

Events of your own

// An event of your own needs a weight before it can be observed. Both SDKs
// store it under "custom:<name>", so a profile written on one platform ranks
// the same on the other.
val configuration = PersonalizeConfiguration(
    eventWeights = PersonalizeConfiguration.defaultEventWeights() +
        (PersonalizeEvent.customKey("added-to-basket") to 0.9f),
)

personalize.observe(user = accountId, itemId = "sku-104", customEvent = "added-to-basket")

The eleven built-in events cover reading and browsing; a basket, a booking or a re-order does not fit any of them. Give the event a weight in the configuration first — observing an event with no weight is an error rather than a silent zero, because a zero is an interaction the developer believes was recorded and was not. On iOS the same event is .custom("added-to-basket").

Ranking

Rank candidates

let ranked = try await personalize.rank(
    user: accountID,
    candidates: todaysCandidateIDs,
    limit: 20,
    mode: .hybrid(session: sessionID),
    filter: .not(.equals("paywalled", .boolean(true))),
    coldStart: .preserveCandidateOrder,
    diversity: .maximalMarginalRelevance(relevanceWeight: 0.7, candidatePoolSize: 60)
)

for result in ranked {
    print(result.itemID, result.score)
}
personalize.observe(user = accountId, itemId = "article-104", event = PersonalizeEvent.LIKED)

val ranked = personalize.rank(
    user = accountId,
    candidates = todaysCandidateIds,
    limit = 20,
    filter = PersonalizeFilter.Not(PersonalizeFilter.Equals("paywalled", PersonalizeValue.BooleanValue(true))),
    coldStart = PersonalizeColdStartPolicy.PreserveCandidateOrder,
    mode = PersonalizeRankingMode.HYBRID,
    session = sessionId,
)

Scoring is cosine similarity over the candidates you pass, so cost is linear in candidate count rather than catalogue size. Shortlist first if your catalogue is large.

Filters run before scoring on both platforms: equality, existence, and/or/not, and the numeric comparisons lessThan, lessThanOrEqual, greaterThan, greaterThanOrEqual and between (inclusive at both ends). An item whose field is missing, or holds a string where a number was expected, fails the predicate: patchy metadata narrows the results, not the request.

// A new reader has no profile. Ranking them against nothing throws by
// default, which is the honest outcome — decide what to show instead.
let profile = await personalize.profile(for: accountID)

if !profile.hasLongTermProfile {
    showEditorialOrder()
}

coldStart takes .error (the default), .preserveCandidateOrder, .popular with a fallback ordering, or .seedEmbedding to start from a declared interest rather than from nothing. A popular ordering is a suggestion, not a way in: items outside the candidate list you passed are still not returned.

Both platforms

Explain a ranking

// Why an item ranked where it did, split across the three profiles that
// contributed. Useful in a debug build, and in the conversation with whoever
// asks why the feed changed.
let detailed = try await personalize.rankDetailed(
    user: accountID,
    candidates: todaysCandidateIDs,
    limit: 20,
    mode: .hybrid(session: sessionID)
)

for result in detailed {
    print(result.itemID, result.score.total, result.score.longTerm ?? 0, result.score.recent ?? 0)
}
val detailed = personalize.rankDetailed(
    user = accountId,
    candidates = todaysCandidateIds,
    limit = 20,
    mode = PersonalizeRankingMode.HYBRID,
    session = sessionId,
)

// A null contribution means that profile took no part; 0f means it took part
// and matched nothing. The distinction is the interesting one.
detailed.forEach { println("${it.itemId} ${it.score.total} ${it.score.longTerm} ${it.score.recent}") }

rankDetailed returns the same order and the same totals as rank, with the three contributions that produced each total kept rather than collapsed. Without it, “why is this at the top” is unanswerable from outside.

Both platforms

Measure it

Scoring a ranking against known-relevant items — hit rate, precision, recall, MRR, nDCG, catalogue coverage and intra-list diversity — is the difference between tuning weights and guessing at them. On iOS this is the separate CactusBrainPersonalizeEvaluation product; on Android it ships inside cactusbrain-personalize.

import CactusBrainPersonalizeEvaluation

// Measure a ranking change against held-out interactions before shipping it,
// rather than shipping it and reading the retention chart three weeks later.
let metrics = try PersonalizeRankingEvaluator.evaluate(heldOutQueries, at: 10)

print(metrics.hitRate, metrics.meanReciprocalRank)
print(metrics.normalizedDiscountedCumulativeGain)
import ai.cactusbrain.personalize.PersonalizeEvaluationQuery
import ai.cactusbrain.personalize.PersonalizeRankingEvaluator

val metrics = PersonalizeRankingEvaluator.evaluate(heldOutQueries, cutoff = 10)

println(metrics.hitRate)
println(metrics.normalizedDiscountedCumulativeGain)
Storage

How state is stored

State is two files inside the directory you supply. The catalogue and the learned profiles live in personalize.json, written through a temporary file that is atomically moved into place — a process killed mid-write leaves the previous snapshot intact rather than a truncated one. Interactions are appended to personalize.log, one line of JSON each. The format is versioned and shared between the two SDKs, and pinned by fixtures both of them test against.

The split exists because a tap should not cost a catalogue rewrite. Anobserve changes one preference vector; rewriting the snapshot to record it rewrote every item alongside it, so the cost of registering a tap grew with the size of the catalogue it was ranking. Appending costs roughly ninety bytes regardless. The log is folded back into the snapshot when it reaches interactionLogLimit entries (512 by default), and by any call that changes the catalogue.

The snapshot is not encrypted

It is plain JSON in your application’s private container, protected by the same sandbox and file-protection class as the rest of your app data — not by an additional layer of our own. If your threat model needs more than that, place the directory inside an encrypted container, or set a stricter FileProtectionType on it. We would rather document that than imply an encryption guarantee we do not implement.

Catalogue changes still rewrite the whole snapshot, so bulk loading through addAll (Android) or add(items:) (iOS) is meaningfully cheaper than a loop of single add calls, and either is all-or-nothing: a rejected item leaves the store as it was.

Snapshot version 3 is a one-way upgrade

A store opened by 0.1.0 cannot be reopened by an older SDK. Versions 1 and 2 are migrated forward on first open, but the version is bumped rather than left alone, because a reader that predates the log would open the snapshot, ignore a file it knows nothing about, and silently discard every interaction in it. Failing loudly is the better of the two.

Honest limits

Known limitations

  • Ranking is brute force over the candidates supplied. There is no approximate nearest-neighbour index, so shortlist before ranking if you have tens of thousands of items.
  • Removing an item drops it from future candidates but does not unmix the interactions it contributed to a profile. Aggregates cannot be un-added.
  • Profiles are per device. There is no sync, which is the same property that keeps behavioural data off our servers.
  • An interaction recorded but not yet compacted lives only in the log. The log is durable and replayed on the next open, but a crash partway through an append loses that one entry — the partial line is dropped and the file repaired rather than carried forward as damage.
  • The API surfaces match: rankDetailed, custom event names, all four cold-start policies and the numeric filters exist on both platforms. Naming follows each language — .popular(ids) against PersonalizeColdStartPolicy.Popular(ids) — but the behaviour and the persisted keys are the same.