manicule

how it works

a feed usually ranks by your history, everything you ever clicked. this one ranks by your taste. pick a few posts and the page averages them. that average is all it knows about you, and it lives in the link

manicule.pyrank()same math in rank.js

two averages and a subtraction

tullie murrell's hn for-you feed, cosine to the mean of your favorites

a post is an arrow

a small language model reads each post's headline and the first 400 characters of its blurb and writes down 384 numbers. that runs once, when the page is built. the model never sees you

think of the 384 numbers as an arrow. posts about the same thing point the same way. to compare two posts you measure the angle between their arrows, and the cosine of that angle is the number you see all over this site. +1 is the same direction, 0 is a right angle, below 0 is opposite

+0.82 a small angle 0.00 a right angle −0.91 turned around
three pairs of posts: nearly the same, unrelated, opposite
against the top post on your sourcesourcecos
loading

the three nearest and the three farthest

on this feed almost every pair lands between +0.27 and +0.75. so +0.62 is close and +0.40 is far

your taste is an average

pick a post and the page keeps its arrow. pick another and it averages the two. that average is your taste. no profile, no history, one arrow that moves a little every time you pick

an average can only point one way, and you are allowed to like more than one thing. so when a pick lands far from everything you have picked so far, it starts a second average instead of dragging the first off its subject. up to three. each post is scored against the one it is closest to

the average four picks about one thing it swings one odd pick so it starts a second taste instead
four picks about one thing and their average sits among them. one pick about something else swings it toward nothing. so that pick starts its own

cos(post, picked) is a post's angle to your average. the feed is sorted by it, closest first

what you pickedsourcecos to the average
loading

each pick measured against the average of all your picks. picks about one subject all score high. one pick about something else drags the average off and every number drops

what a taste is about is not stored either. the page reads it off today's feed each time: the nearest label from a short list, and the words its nearest headlines share

    pick nothing and there is no average. the feed stays newest first

    the second hand

    what you pass on is averaged too, into a second arrow, and a post's closeness to that one comes off its score

    λ is how much comes off. at 0.25, the default, a pass counts a quarter of what a pick does. at 1 a pass cancels a pick. one post is worked at every setting further down, with a ruler

    does it workevaluate()

    to test a ranker you need a right answer it cannot see. the source a post came from is a good one. the vector does not carry it, and posts from one source tend to share a subject and a voice. so take one source, pick two of its posts, drop the rest back into the pile of …, and see where they end up. lower is better. 1st means the rest of that source came straight to the top

    picksthe rankershared wordsnewest firstshuffled
    loading

    where the rest of that source lands, the median

    one row, worked

    the feed only prints the result. this is the arithmetic behind it, for the top post you have not marked yet

    post
    loading

    the solid part is the score. the dashed part is what the pass took off

    the same row, other λ

    λscorerankwhat a pass does

    0.25

    move this and the row moves on the feed too. λ travels in the link with the rest of your taste

    the listingon github

    the function as it is on main right now. the notes in the margin belong to the line beside them

    manicule.py · rank()loading

    where your data lives

    your taste
    in the link and in this browser. the averages themselves, as int8 and base64, plus the ids of the rows you marked. #v=…. nothing is sent anywhere
    the vectors
    made when the page is built, by fastembed's bge-small model, on the build machine. they download as vectors.bin next to posts.json, 270 × 384 int8, and rank.js does the math in your browser
    the cli
    manicule.py rank runs on your machine. give it an opml file and a folder of notes and it writes a markdown page. the only network traffic is fetching the sources
    refresh
    once a day, by a github action. the page is rebuilt from the sources. nothing of yours goes into it