feedrank

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 11 Imported by: 0

README

Fluxa-PostTopics (FluxRank)

ci Go Reference

FluxRank is the open-source feed-ranking algorithm behind Fluxa's "For You" timeline — a transparent, deterministic alternative to black-box recommendation systems.

日本語: Fluxaの「おすすめ」フィードを並べているアルゴリズム本体です。機械学習でも ブラックボックスでもなく、誰でも読める1つの数式と公開された重みで動きます。同じ 入力からは常に同じフィードが再現され、全ての投稿に「なぜ表示されたか」の内訳が付きます。

Why open

  • Transparent — one scrutable formula; every weight lives in a versioned config, not in code.
  • Deterministic — pure functions, no I/O, no clock, seeded randomness only. Identical inputs always produce identical feeds, so the algorithm is auditable and golden-testable.
  • Explainable — every ranked post carries Reasons (followed_author, topic:music, mentions_you, …) and a full per-term score Breakdown, enough to render a "why am I seeing this?" panel.
  • Privacy-respecting — no dwell-time tracking, no DM signals, no follower-count worship. Earned reach comes only through log-damped engagement.

See ALGORITHM.md for the full specification.

Install

go get github.com/Fluxa-org/Fluxa-PostTopics

Pure Go, standard library only, go >= 1.22.

Quick start

import feedrank "github.com/Fluxa-org/Fluxa-PostTopics"

// Map hashtags (EN/JA aliases included) onto your topic taxonomy.
topics := feedrank.PostTopics("新曲できた! #音楽", nil, canonical, feedrank.DefaultAliases(), 3)

user := feedrank.UserContext{
    UserID:    "viewer",
    Interests: []string{"music"},
    Follows:   map[string]bool{"alice": true},
}
candidates := []feedrank.Candidate{{
    Post: feedrank.Post{
        ID: "post-1", AuthorID: "alice", CreatedAt: createdAt,
        Likes: 12, Replies: 3, Topics: topics,
    },
    Source: feedrank.SourceInNetwork,
}}

page := feedrank.Rank(feedrank.DefaultConfig(), user, candidates, time.Now(), 20)
for _, r := range page {
    fmt.Println(r.ID, r.Score, r.Reasons) // e.g. post-1 0.32 [followed_author topic:music]
}

The caller owns all I/O: gather candidates from your stores, map them into Candidates, and render the returned page. Rank never touches a database.

The formula

score = freshness × (0.35·engagement + 0.30·affinity + 0.20·topic + 0.15·social_proof + 0.10·search_trend) × modifiers
Term Meaning
engagement log10(1 + likes + 2·reposts + 2.5·bookmarks + 4·replies + views/100), saturating — conversation beats passive likes, virality is damped
affinity your history with the author (+ follow bonus)
topic Jaccard overlap between your interests and the post's topics
social_proof how many accounts you follow engaged with it
search_trend what the whole network is searching right now (aggregate topic heat via TrendingTopics); your own recent searches join the topic term via SearchInterests
freshness exp(-age/8h) decay

Modifiers: seen ×0.1 · not-interested topic ×0.2 · stranger-reply ×0.3 · moderation labels (stackable) · prolific-author damp sqrt(threshold/count) · "show more like this" ×1.5 · mentions-you ×2 · language mismatch ×0.5.

Page rules: max 2 posts per author, ~50 % in-network quota, no 3-in-a-row same topic, and every 10th slot reserved for exploration (seeded, stable within the hour) so new authors get discovered.

Feed profiles

One engine, several published weight sets — algorithmic choice in the spirit of Bluesky's feed marketplace:

Profile Character
for-you balanced default
discover engagement/recency-forward, aggressive exploration
quiet-posters surfaces followed accounts that post rarely
small-community for young/low-volume instances: 30-day window, gentle decay, strict anti-flooding

Low volume is a first-class case: when fresh posts can't fill a page the ranker readmits older ones instead of serving an empty feed, and with few posts it degrades gracefully toward a recency feed.

cfg := feedrank.BuiltinProfiles()["discover"]
cfg, err := feedrank.ConfigFromJSON(raw) // or define your own (unknown fields rejected)

Topics, hashtags, mentions, and search — i18n included

Ranking never consumes raw hashtags. PostTopics maps them onto a canonical taxonomy — ~350 built-in aliases across ten language groups (en, ja, ko, zh, es, hi, vi, fr, de, pt: #音楽→music, #맛집→food, #fútbol→sports) — and unmapped tags contribute nothing, so tag spam cannot game the feed. Extraction is Unicode-aware, so hashtags in any language extract.

  • ExtractMentions pulls @handles (Unicode) so callers can set the mentions-you boost.
  • MapQuery turns free-text search queries into topics: feed a user's own searches back as SearchInterests, and aggregate everyone's searches into TrendingTopics so the feed reflects what the network is looking for (aggregate counts only — individual queries never reach the ranker).
  • Post Language + viewer Languages down-rank posts the viewer can't read; unknown languages are never penalized.

Testing

~35 table-driven tests, randomized invariant tests, fuzzing, and benchmarks; 98 % coverage, race-clean. Ranking 600 candidates into a 50-post page takes ~0.4 ms on an M1 Pro — your database is the bottleneck, not this.

go test -race -cover ./...
go test -run='^$' -fuzz=FuzzRank -fuzztime=30s .
go test -run='^$' -bench=. -benchmem .

What is deliberately absent

Dwell time, profile-visit tracking, DM signals, follower counts, and ML prediction. The pipeline is shaped so a learned scorer could replace the formula later without touching sourcing, filters, or page rules — but v1 stays fully auditable.

License

MIT

Documentation

Overview

Package feedrank implements FluxRank, Fluxa's transparent feed-ranking algorithm. Pure and stdlib-only: no I/O, no clock, seeded randomness only, so identical inputs always produce identical feeds. See ALGORITHM.md for the full specification.

Example

Example shows the full adapter flow: map hashtags to topics, build candidates, rank, and read the explainable output.

package main

import (
	"fmt"
	"time"

	feedrank "github.com/Fluxa-org/Fluxa-PostTopics"
)

func main() {
	now := time.Date(2026, 7, 21, 12, 0, 0, 0, time.UTC)
	canonical := map[string]bool{"music": true, "food": true}

	topics := feedrank.PostTopics(
		"新曲できた! #音楽", nil, canonical, feedrank.DefaultAliases(), 3)

	user := feedrank.UserContext{
		UserID:    "viewer",
		Interests: []string{"music"},
		Follows:   map[string]bool{"alice": true},
	}
	candidates := []feedrank.Candidate{
		{
			Post: feedrank.Post{
				ID: "post-song", AuthorID: "alice",
				CreatedAt: now.Add(-2 * time.Hour),
				Likes:     12, Replies: 3, Topics: topics,
			},
			Source: feedrank.SourceInNetwork,
		},
		{
			Post: feedrank.Post{
				ID: "post-viral", AuthorID: "stranger",
				CreatedAt: now.Add(-20 * time.Hour),
				Likes:     900, Reposts: 200,
			},
			Source: feedrank.SourceTrending,
		},
	}

	page := feedrank.Rank(feedrank.DefaultConfig(), user, candidates, now, 2)
	for _, r := range page {
		fmt.Printf("%s reasons=%v\n", r.ID, r.Reasons)
	}
}
Output:
post-song reasons=[followed_author topic:music]
post-viral reasons=[trending]

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func BuiltinProfiles

func BuiltinProfiles() map[string]Config

BuiltinProfiles returns a fresh copy of the named profiles.

func DefaultAliases

func DefaultAliases() map[string]string

DefaultAliases maps common hashtags in ten language groups (en, ja, ko, zh, es, hi, vi, fr, de, pt) onto the canonical 30-tag taxonomy. Instances extend or replace it freely.

func ExtractHashtags

func ExtractHashtags(text string) []string

ExtractHashtags returns lowercased hashtag bodies in order of first appearance, deduplicated. Supports CJK and other Unicode tags.

func ExtractMentions added in v0.2.0

func ExtractMentions(text string) []string

ExtractMentions returns lowercased @-handles in order of first appearance, deduplicated. Handle syntax is platform-specific; callers should re-validate against their own username rules before use.

func MapQuery added in v0.2.0

func MapQuery(query string, canonical map[string]bool, aliases map[string]string, max int) []string

MapQuery maps a free-text search query onto canonical topics: the query is lowercased, split on non-letter/digit runs, and each token is mapped like a hashtag. Unsegmented CJK compounds match only when a token equals an alias. Use it to turn per-user search history into SearchInterests and aggregate search logs into TrendingTopics.

func MapTopics

func MapTopics(raw []string, canonical map[string]bool, aliases map[string]string, max int) []string

MapTopics maps raw tags onto the canonical taxonomy via identity or alias; unmapped tags are dropped. Deduplicated, order-preserving, capped at max (0 = no cap).

func PostTopics

func PostTopics(text string, authorInterests []string, canonical map[string]bool, aliases map[string]string, max int) []string

PostTopics maps a post's hashtags onto the taxonomy, falling back to the author's profile interests when no hashtag maps.

Types

type Breakdown

type Breakdown struct {
	Engagement  float64 `json:"engagement"`
	Affinity    float64 `json:"affinity"`
	Topic       float64 `json:"topic"`
	SocialProof float64 `json:"social_proof"`
	Trending    float64 `json:"trending"`
	Core        float64 `json:"core"`
	Freshness   float64 `json:"freshness"`
	Penalty     float64 `json:"penalty"`
	Score       float64 `json:"score"`
}

Breakdown decomposes a score: Score = Freshness × Core × Penalty. Penalty is the combined modifier and can exceed 1 when boosts apply.

type Candidate

type Candidate struct {
	Post
	Source Source
	// EngagedFollows counts followed accounts that engaged with the post.
	EngagedFollows int
	// AuthorPostsLast24h is the author's posting rate; 0 = unknown.
	AuthorPostsLast24h int
	MentionsViewer     bool
}

Candidate is a post plus sourcing metadata.

type Config

type Config struct {
	Version string `json:"version"`

	Weights    Weights           `json:"weights"`
	Engagement EngagementWeights `json:"engagement_weights"`
	Penalties  Penalties         `json:"penalties"`

	EngagementCap     float64 `json:"engagement_cap"`
	FreshnessTauHours float64 `json:"freshness_tau_hours"`
	FreshnessFloor    float64 `json:"freshness_floor"`
	MaxAgeHours       float64 `json:"max_age_hours"`

	FollowBonus           float64 `json:"follow_bonus"`
	SocialProofSaturation int     `json:"social_proof_saturation"`

	AuthorCapPerPage     int     `json:"author_cap_per_page"`
	InNetworkTargetRatio float64 `json:"in_network_target_ratio"`
	TopicRunCap          int     `json:"topic_run_cap"`      // 0 disables
	ExploreSlotEvery     int     `json:"explore_slot_every"` // 0 disables

	MoreLikeThisBoost     float64            `json:"more_like_this_boost"` // >= 1
	MentionBoost          float64            `json:"mention_boost"`        // >= 1
	ProlificDampThreshold int                `json:"prolific_damp_threshold"`
	LabelPenalties        map[string]float64 `json:"label_penalties"`
	// LanguageMismatchPenalty multiplies posts in a language the viewer does
	// not read, in [0,1]; 1 disables language matching.
	LanguageMismatchPenalty float64 `json:"language_mismatch_penalty"`
	// RelaxMaxAgeWhenSparse readmits age-expired posts when fresh candidates
	// cannot fill a page, so small communities never see an empty feed.
	RelaxMaxAgeWhenSparse bool `json:"relax_max_age_when_sparse"`
}

Config holds every tunable of the algorithm. Load with ConfigFromJSON and log Version with each ranking pass.

func ConfigFromJSON

func ConfigFromJSON(data []byte) (Config, error)

ConfigFromJSON overlays a JSON document onto DefaultConfig, rejecting unknown fields so typos fail loudly.

func DefaultConfig

func DefaultConfig() Config

func DiscoverConfig

func DiscoverConfig() Config

DiscoverConfig is the out-of-network exploration feed: engagement- and recency-forward, low in-network quota, aggressive exploration.

func QuietPostersConfig

func QuietPostersConfig() Config

QuietPostersConfig surfaces followed accounts that post rarely: affinity-forward, long freshness window, harsh prolific damp, no explore.

func SmallCommunityConfig added in v0.3.0

func SmallCommunityConfig() Config

SmallCommunityConfig tunes the feed for young or low-volume instances (roughly < 20 posts/hour): a 30-day window, gentle 48 h freshness decay so the page isn't dominated by the last few hours, a stricter prolific damp (one hyperactive account cannot flood a small feed), and more exploration.

func (Config) Validate

func (c Config) Validate() error

type EngagementWeights

type EngagementWeights struct {
	Like     float64 `json:"like"`
	Repost   float64 `json:"repost"`
	Bookmark float64 `json:"bookmark"`
	Reply    float64 `json:"reply"`
	View     float64 `json:"view"`
}

type Penalties

type Penalties struct {
	Seen               float64 `json:"seen"`
	NotInterestedTopic float64 `json:"not_interested_topic"`
	ReplyToNonFollowed float64 `json:"reply_to_non_followed"`
}

Penalties are multiplicative down-ranks in [0,1].

type Post

type Post struct {
	ID        string
	AuthorID  string
	CreatedAt time.Time

	Likes     int64
	Reposts   int64
	Bookmarks int64
	Replies   int64
	Views     int64

	// Topics are canonical taxonomy tags; see PostTopics.
	Topics []string
	// Labels are moderation labels matched against Config.LabelPenalties.
	Labels []string
	// Language is the post's lowercase primary language tag ("ja", "en");
	// "" = unknown (never penalized).
	Language string

	IsReply         bool
	ReplyToAuthorID string
}

Post is the ranking view of a post.

type Ranked

type Ranked struct {
	Candidate
	Score     float64
	Reasons   []string
	Breakdown Breakdown
}

Ranked is a scored candidate in its final page position.

func Rank

func Rank(cfg Config, user UserContext, candidates []Candidate, now time.Time, pageSize int) []Ranked

Rank runs the full pipeline and returns at most pageSize posts in display order. Identical inputs (including now) yield identical output.

type Source

type Source string

Source tags where a candidate came from.

const (
	SourceInNetwork   Source = "in_network"
	SourceSocialProof Source = "social_proof"
	SourceTopic       Source = "topic"
	SourceTrending    Source = "trending"
	SourceExplore     Source = "explore"
)

type UserContext

type UserContext struct {
	UserID               string
	Interests            []string
	Follows              map[string]bool
	AuthorAffinity       map[string]float64 // 0..1 per author
	Seen                 map[string]bool
	NotInterestedTopics  map[string]bool
	NotInterestedAuthors map[string]bool
	MoreInterestedTopics map[string]bool
	// Languages is the set of languages the viewer reads; empty disables
	// language matching.
	Languages map[string]bool
	// SearchInterests are topics derived from the viewer's own recent
	// searches (see MapQuery); they join Interests in the topic term.
	SearchInterests []string
	// TrendingTopics is the network-wide search heat per topic in [0,1],
	// aggregated by the caller from all users' search queries.
	TrendingTopics map[string]float64
}

UserContext holds the viewer's signals; zero values mean "no signal".

type Weights

type Weights struct {
	Engagement  float64 `json:"engagement"`
	Affinity    float64 `json:"affinity"`
	Topic       float64 `json:"topic"`
	SocialProof float64 `json:"social_proof"`
	// Trending weights the network-wide search-heat term (UserContext.TrendingTopics).
	Trending float64 `json:"trending"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL