My Bluesky home feed kept returning me to the same runaway reply chain, the hellthread. It appeared between the posts I wanted no matter how often I scrolled past it. When I built my own feed generator, I removed that one thread at line 6 of queries.ts:

AND NOT exists((post)-[:ROOT]->(:Post
  {uri:'at://did:plc:wgaezxqi2spqm3mhrb5xvkzi/app.bsky.feed.post/3juzlwllznd24'}))

That URI identifies the thread’s root. Any post connected to it disappears from the result set. One line in the query was enough to remove the conversation for everyone using the feed.

With no moderation console, subscriber vote or explanatory notice, a policy affecting everyone who used the feed occupied one URI inside a WHERE clause. The small amount of code made the authority easy to overlook: this was still a moderation decision, made privately and applied to every subscriber. A ranked feed’s operative policy lives across queries, constants, merge order, pagination, and state, whatever its trust-and-safety documents may say.

BlueJ made those choices unusually easy to inspect. In April 2023, while Bluesky was still invite-only with roughly fifty or sixty thousand accounts, the AT Protocol supplied a firehose of public network events and let independent developers publish feed generators—capabilities Twitter had withdrawn from developers. I was working at Memgraph, and the network mapped naturally to a graph: people authored posts, followed people, and liked posts; every event changed the graph from which the next feed would be assembled. I wanted to make the ranking decisions myself.

A provisional model in the graph

Events arrived as repository commits over a WebSocket, encoded with CBOR and packed into CAR files. readCar and cborToLexRecord unpacked them before the subscription service translated each one into Cypher.

A like became an edge:

MERGE (person:Person {did: $authorDid})
MERGE (post:Post {uri: $postUri})
MERGE (person)-[:LIKE {weight: 1, uri: $uri}]->(post)

MERGE was necessary because the firehose could deliver a like before the post it named. The first event created a placeholder node; the post event enriched it later. Failed queries entered a retry queue drained every five seconds, with ten attempts before abandonment, so a transient database failure did not silently decide which events the graph remembered.

The schema also carried a provisional model of relationship strength: AUTHOR_OF edges have weight 0, LIKE has 1, and FOLLOW has 2. The production queries below do not use those edge weights directly, and the ratio was never a research result. Keeping it in the graph made the assumption visible and easy to revise instead of burying it inside an opaque score.

Chronology supplied most of the feed

The feed called home-plus runs three queries in parallel:

let queryResults = await parallelQueries(requesterDid, maxNodeId, {
  follow: { query: followQuery, limit: 300 },
  likedByFollow: { query: likedByFollowQuery, limit: 100 },
  community: { query: communityQuery, limit: 100 }
})

The input limits suggest a 300/100/100 ranked blend, but they do not determine the feed a reader sees. Nor does one decay formula govern all three sources.

The 300 posts from followed accounts form a chronological stream. The query returns 1 as score and orders by post.indexedAt DESC. Decay applies only to the two discovery streams, where likes fall by the fourth power of age in hours:

WITH(ceil(likes) / ceil(1 + (hour_age * hour_age * hour_age * hour_age)))
  as score, likes, hour_age, post, follow_person

At four hours the denominator is 257, including the leading 1. At twenty-four it is 331,777. Discovery expires quickly. All three streams reject posts older than five days.

The repository contains two alternative scoring experiments which never reached these queries. A native Memgraph procedure implements the Hacker News gravity formula. A separate application scorer uses a cosine curve over roughly twenty-four hours and weights the three sources 10x, 1.5x, and 1x. Neither is called by home-plus; the feed kept the fourth-power expression beside the selection logic, where the operative rule remained visible and easy to revise. It was a working baseline, not a benchmarked claim about optimal ranking.

Then weightedRoundRobin changes the mixture again. Despite the input limits, it takes one item from each stream per round and removes duplicates by node ID. For roughly the first hundred rounds, discovery is dense: follow, liked-by-follow, community. Once the smaller streams are exhausted, the remaining followed posts form a chronological tail.

A subscriber sees the result of that composition: one chronological stream, two aggressively decayed streams, a one-per-source merge, a deduplicator, and a hardcoded exclusion.

The graph moved when a like or follow arrived.

Refresh carried policy too

Refresh and pagination arrive as similar requests but express opposite expectations. Refresh means “show me what is new”; pagination means “keep the ordering stable while I continue.” BlueJ inferred intent from request shape. A limit of at least 10 with no cursor counted as a refresh, and the server recorded the highest graph node ID that requester had seen:

if (limit >= 10 && cursor === undefined) {
  didLastSeen[requesterDid] = {
    maxNodeId,
    timestamp: Date.now()
  }
}

That per-DID state expired after twelve hours. Cursors included the requester DID and rejected a mismatch with the request’s JWT; positions were capped at 600. None of those decisions looks like ranking code, but each one governs what appears at the top of the screen and what remains stable below it.

I could reason about the graph more easily because other contributors made it visible. Dominik Jambrović built the initial visualization, and Marko Bagarić extended it. Memgraph triggers sent graph changes through a native query module and a small Node service; Socket.io then pushed them into a force-directed React view. A new like drew an edge towards a post. A follow changed the layout’s equilibrium.

The visualization let us watch the graph change, but it did not show subscribers why the hellthread had vanished, how the weights altered their feeds, or whether any of those choices were fair.

Publishing queries.ts offers a useful but weak form of feed transparency: an interested reader can inspect the policy after the fact. A subscriber still receives neither an explanation when the policy changes their feed nor a voice in the policy itself. The code shows exactly where the choices reside; it does not make those choices accountable to the people whose feeds they shape.

Chris Chabot · June 2023