From 1abba23df347ba8e8a00dab7ab76cf4ae45137ac Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:43:35 -0400 Subject: [PATCH] docs: worked example of the ranking dimension as a feed multiplier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ranking` dimension is the soft one — "still show this, but lower" — and integrators reinvented turning it into a feed score. Show it instead. Add examples/feed-ranking.mjs (runnable): it reads decision.ranking ({ effect: "normal" | "downrank", weight }) and turns it into a multiplier on the app's own feed score (base ** weight for a downrank). Two authors that led on base score get downranked by trusted spam reports and drop below a clean lower-base author -- without being hidden, since visibility is a separate dimension (capped at "restrict" on a feed). The multiplier lives in the example, not the engine: core ships no such numbers on purpose. Pinned by tests/feed-ranking.test.js (downrank never hides on a feed; a high-base downranked author reorders below a clean one; a clean author is untouched at x1). README headless section points to it. Addresses the "BitGate feeding a feed ranking" item of the Tessera feedback (docs/TODO.md) -- the last open BitGate item. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB --- README.md | 6 +++ docs/TODO.md | 11 ++++- examples/feed-ranking.mjs | 97 ++++++++++++++++++++++++++++++++++++++ tests/feed-ranking.test.js | 61 ++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 examples/feed-ranking.mjs create mode 100644 tests/feed-ranking.test.js diff --git a/README.md b/README.md index 31b11c5..2cd701f 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,12 @@ No bundler? `npm run build:standalone` produces `packages/standalone/dist/bitgat ESM file. Self-host it and `import { evaluateTarget, decodeLabels } from "./bitgate.js"`; everything in this section works from that single file. +The soft dimension, `ranking`, feeds an ordering rather than a gate: +`decision.ranking` is `{ effect: "normal" | "downrank", weight }`, which an app +turns into a multiplier on its own feed score — a reported author drops in the +feed without being hidden. [`examples/feed-ranking.mjs`](examples/feed-ranking.mjs) +is a runnable walkthrough. + ## Extending to a new application Write an adapter saying what your object is in governance terms, and a policy diff --git a/docs/TODO.md b/docs/TODO.md index 88b2031..4ba0c99 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -282,5 +282,12 @@ adoption the obvious choice. whole headless path. Verified dependency-free (no bare imports in the output) and end-to-end (a smoke test composes a decision from the entry). Documented in the README Packages table and the headless section; wired into `npm run build`. -- A worked example of BitGate feeding a feed ranking (the `ranking` dimension → - a score multiplier) would have saved us reinventing it in the feed recipe. +- **[DONE 2026-08-11]** A worked example of BitGate feeding a feed ranking. + **Shipped** `examples/feed-ranking.mjs` (runnable): it reads `decision.ranking` + (`{ effect: "normal" | "downrank", weight }`) and turns it into a score + multiplier (`base ** weight` for a downrank), so trusted spam reports push two + high-base authors below a clean lower-base one **without hiding them** — + visibility stays a separate dimension, capped at `restrict` on a feed. The + multiplier lives in the example, not the engine (core ships no such numbers). + Pinned by `tests/feed-ranking.test.js`; the README headless section points to + it. diff --git a/examples/feed-ranking.mjs b/examples/feed-ranking.mjs new file mode 100644 index 0000000..bb729b3 --- /dev/null +++ b/examples/feed-ranking.mjs @@ -0,0 +1,97 @@ +/** + * The `ranking` dimension as a feed score multiplier. + * + * Moderation is not only hide/allow. The fourth dimension, `ranking`, is the + * soft one: it says "still show this, but lower." A feed reads + * `decision.ranking` — `{ effect: "normal" | "downrank", weight }`, where + * `weight` grows with each downrank signal — and turns it into a multiplier on + * whatever score the feed already computed. Nothing is hidden; the order just + * reflects the governance signal. + * + * The multiplier is an application policy, so it lives here, not in the engine + * (the core ships no such numbers on purpose). Tune `base` to say how hard a + * downrank bites. + * + * node examples/feed-ranking.mjs + */ +import { SOCIAL_POLICY, createAuthorityState, createSnapshot, evaluateTarget } from "@bitgate/core"; + +const ROOT = "a1".repeat(32); +const NOW = 1_750_000_000; +const trusted = (i) => i.toString(16).padStart(2, "0").repeat(32); +const TRUSTED = Array.from({ length: 16 }, (_, i) => trusted(i + 1)); + +// A base feed score the app already computed (recency, affinity, whatever). +// Bob and Carol START ABOVE Alice — the point is that the ranking dimension +// reorders them below her without hiding anything. +// author -> { score, reports: [category, trustedReporterCount] } +const authors = { + alice: { pubkey: "d1".repeat(32), score: 0.70, reports: [] }, + bob: { pubkey: "d2".repeat(32), score: 0.95, reports: [["spam", 4]] }, + carol: { pubkey: "d3".repeat(32), score: 0.90, reports: [["spam", 8]] }, +}; + +const authority = createAuthorityState({ root: ROOT }); + +/** Build a snapshot carrying `count` trusted reports of `category` against pubkey. */ +function snapshotFor(pubkey, reports) { + return createSnapshot({ + authority, + trust: { contacts: new Set(TRUSTED) }, + reports: new Map( + reports.map(([category, count]) => [ + `user:${pubkey}`, + TRUSTED.slice(0, count).map((reporter) => ({ reporter, category, createdAt: NOW - 100 })), + ]), + ), + }); +} + +/** + * Application policy: turn a ranking decision into a score multiplier. A + * `normal` ranking is neutral (×1); a `downrank` bites harder the more signals + * back it (`weight`). Nothing here hides anything — visibility is a separate + * dimension. + */ +function rankingMultiplier(ranking, { base = 0.5 } = {}) { + if (!ranking || ranking.effect !== "downrank") return 1; + return base ** Math.max(1, ranking.weight); +} + +const ranked = Object.entries(authors) + .map(([name, author]) => { + const decision = evaluateTarget({ type: "user", pubkey: author.pubkey }, snapshotFor(author.pubkey, author.reports), { + surface: "feed", + policyProfile: "feed", + policy: SOCIAL_POLICY, + now: NOW, + }); + const multiplier = rankingMultiplier(decision.ranking); + return { + name, + base: author.score, + ranking: decision.ranking.effect, + weight: decision.ranking.weight, + multiplier, + final: author.score * multiplier, + visible: decision.visibility.effect, // still shown — ranking never hides + }; + }) + .sort((a, b) => b.final - a.final); + +console.log("Feed after applying the ranking dimension as a score multiplier:\n"); +console.log(" name base ranking weight ×mult final visible"); +for (const r of ranked) { + console.log( + ` ${r.name.padEnd(6)} ${r.base.toFixed(2)} ${r.ranking.padEnd(9)} ${String(r.weight).padStart(4)} ${r.multiplier + .toFixed(2) + .padStart(5)} ${r.final.toFixed(2)} ${r.visible}`, + ); +} +console.log(` +Bob and Carol led on base score, but their trusted spam reports downranked +them (×0.5) and both dropped below clean Alice — nothing was hidden (visibility +is a separate dimension, capped at "restrict" on a feed). weight is 1 here +because each crossed the downrank threshold once; a target downranked by several +independent signals accrues more weight and bites harder. That reordering is the +ranking dimension doing its job.`); diff --git a/tests/feed-ranking.test.js b/tests/feed-ranking.test.js new file mode 100644 index 0000000..24aa468 --- /dev/null +++ b/tests/feed-ranking.test.js @@ -0,0 +1,61 @@ +// Pins examples/feed-ranking.mjs: the `ranking` dimension, applied as a score +// multiplier, reorders a downranked author below a clean one without hiding it. +import { describe, expect, it } from "vitest"; + +import { SOCIAL_POLICY, createAuthorityState, createSnapshot, evaluateTarget } from "@bitgate/core"; + +const ROOT = "a1".repeat(32); +const NOW = 1_750_000_000; +const TRUSTED = Array.from({ length: 16 }, (_, i) => (i + 1).toString(16).padStart(2, "0").repeat(32)); +const authority = createAuthorityState({ root: ROOT }); + +const snapshotFor = (pubkey, category, count) => + createSnapshot({ + authority, + trust: { contacts: new Set(TRUSTED) }, + reports: count + ? new Map([ + [ + `user:${pubkey}`, + TRUSTED.slice(0, count).map((reporter) => ({ reporter, category, createdAt: NOW - 100 })), + ], + ]) + : new Map(), + }); + +const rankingOf = (pubkey, category, count) => + evaluateTarget({ type: "user", pubkey }, snapshotFor(pubkey, category, count), { + surface: "feed", + policyProfile: "feed", + policy: SOCIAL_POLICY, + now: NOW, + }); + +// The same multiplier the example documents. +const multiplier = (ranking, base = 0.5) => + ranking?.effect === "downrank" ? base ** Math.max(1, ranking.weight) : 1; + +describe("ranking dimension as a feed multiplier", () => { + it("downranks a reported author but never hides them on a feed", () => { + const decision = rankingOf("d2".repeat(32), "spam", 4); + expect(decision.ranking.effect).toBe("downrank"); + expect(decision.ranking.weight).toBeGreaterThanOrEqual(1); + // Ranking is the soft dimension: visibility stays out of hide/deny on a feed. + expect(["allow", "warn", "restrict"]).toContain(decision.visibility.effect); + }); + + it("reorders a high-base downranked author below a clean lower-base one", () => { + const clean = { base: 0.7, ranking: rankingOf("d1".repeat(32), "spam", 0).ranking }; + const reported = { base: 0.95, ranking: rankingOf("d2".repeat(32), "spam", 4).ranking }; + + const cleanFinal = clean.base * multiplier(clean.ranking); + const reportedFinal = reported.base * multiplier(reported.ranking); + + expect(reported.base).toBeGreaterThan(clean.base); // led on base score + expect(reportedFinal).toBeLessThan(cleanFinal); // ...but sinks after ranking + }); + + it("leaves a clean author's score untouched (×1)", () => { + expect(multiplier(rankingOf("d1".repeat(32), "spam", 0).ranking)).toBe(1); + }); +});