The Pool × Intersubjective
MeritRank, embedded.
How EPI's Pool made MeritRank its trust engine for a planetary gift economy — subjective, sybil-bounded, and running today, validated against a real attack.
The problem you already solved
01 Why we needed MeritRank
The Pool is building a gift economy: people offer what they can give, others receive. Identity is self-sovereign — a did:key, free to mint. Our own security audit flagged the obvious hole: a costless identity gives zero sybil resistance. Reputation built on it is farmable.
We had designed a trust ladder — you climb by giving successfully to people who are already trusted. But that was an intuition, not a guarantee. MeritRank is that intuition with theorems:
"Climb by giving to already-trusted people" is a random walk on the trust graph. MeritRank is exactly that — and its connectivity decay is precisely our defence against collusion rings.
The decisive idea: don't prevent identities — bound their benefit. No matter how many sybils an attacker mints, their gain stays under a constant. That was the third way we couldn't design ourselves.
The architecture
02 How we integrated it
Three pieces, all already latent in the Pool's design — MeritRank slotted in with almost no adaptation.
a. The feedback graph = our signed gift_events. Nodes are DIDs; edges are completed, signed exchanges; weights carry epoch decay. One completed gift writes two edges — both parties experienced it. This is a MeritRank feedback graph, unchanged:
-- db/migrate_trust.py (live in pool.db)
CREATE TABLE gift_events (
id TEXT PRIMARY KEY,
resource_id TEXT, -- the gift (null for a vouch)
src_did TEXT NOT NULL, -- edge source (feedback giver)
dst_did TEXT NOT NULL, -- edge target
weight REAL DEFAULT 1, -- feedback strength
context TEXT, -- gift | vouch
completed_at TEXT, -- ISO; epoch decay uses this
src_sig TEXT, dst_sig TEXT -- Ed25519 signatures
);
b. The engine — mcp/meritrank.py. An embedded, pure-Python-over-SQLite twin of your algorithm: Monte-Carlo personalized random walks with all three decays from the paper.
class MeritRank:
def __init__(self, graph,
alpha=0.85, # transitivity: continue-walk prob
beta=0.7, # connectivity: bridge-reliance penalty
num_walks=10_000): # epoch: 365-day weight half-life
transitivity decay → value falls with distance from the seed; connectivity decay → arrivals funnelled through one predecessor ("bridge") are discounted, so sybil regions hanging off a bridge are starved; epoch decay → old feedback fades (trust must fall, not only rise).
c. Seed-at-claim-time, exposed as an MCP tool. Reputation is computed from a seed's perspective. When someone claims a gift, the seed is the giver — the question becomes "does the giver's trust web reach this receiver?" Subjective by construction; intersubjective by design. It is the Pool's 5th MCP tool:
gift_events ──► MeritRank(seed = giver) ──► trust_score ──► claim decision
(feedback personalized walks subjective (+ safety, eligibility —
graph) + 3 decays reputation separate dimensions)
# MCP tool — pool_mcp.py
trust_score(seed_did, target_did?) → subjective MeritRank reputation
# with target: pair score at claim time (seed = giver)
# without: the seed's full ranked trust view
Deliberately embedded. The Pool is SQLite + Python with zero external services, so at our scale we reimplemented walks-with-decays in-process rather than run a separate service. This is an embedded twin, not a fork — and meritrank-rust is our documented production scale path.
The part you asked for
03 Integration semantics — how a score becomes a decision
You said the doc lacked "the concrete details of integration with the semantics of the existing system." Fair. This section and the next are those details: the exact decision pipeline, what writes which edges, how global tiers emerge from subjective walks — then a narrative run against the live engine.
The core problem: MeritRank scores are relative shares of a seed's reachable web — graph-size dependent, so absolute thresholds are meaningless. And in a gift economy the giver, not the platform, must keep final say. Both constraints shaped one pipeline:
claim_gate(giver, receiver, resource):
1 SURVIVAL BYPASS category ∈ {food, crisis-shelter} → ADMIT unconditionally.
Need is the qualification. Trust gates exploitation, never need.
2 TIER GATE (hard) receiver.tier < resource.min_tier_receive →
GATED — with the paths shown: eligibility VC, or vouches from tier≥2.
3 SUBJECTIVE REACH walk from seed=giver → a reach CLASS, structure first:
(advisory) STRONG live direct edge (hop 1), or strong support via ≥2
vertex-disjoint paths
KNOWN hop ≤ 2 with ≥2 independent predecessors, or moderate
support above the noise floor
TRACE web faintly reaches them (can only ever feed ADVISE)
NONE 0 walks arrive → "outside your web" — NOT auto-denied,
giver sees vouch options
required: consume→TRACE · presence→KNOWN · borrow→STRONG
3b MIRROR WALK presence stakes cut both ways: the receiver sees the same
view seeded at THEMSELVES — does their web reach the giver?
Their body, their risk, their explicit acknowledgment.
4 SAFETY (hard) bodily_exposure → giver holds live human-issued safety
clearance + explicit per-claim acknowledgment.
→ ADVISE class + paths ("reached via Amara + 2 independent others"),
percentile shown only as context. The giver decides.
Tiers gate; scores inform; safety overrides. No single number is trusted alone.
Why classes, not raw scores — and why percentile is only context. Raw shares are graph-size dependent: a fixed cutoff admits everyone in a 3-person web and no one in a 5,000-person one. Percentiles fix that but carry a subtler bug our parallel design pass caught: they make admission depend on the rest of the crowd — make three new close friends and yesterday's houseguest silently drops a band. So the decision input is hop distance + walk support + predecessor diversity, each with absolute meaning, and percentile is display context only. Three refinements from the adversarial pass harden it: a noise floor of 5/num_walks (below ~5 expected visits, Monte-Carlo shares are dust, not signal — treated as 0); scale-free structural anchors — hop distance, count of vertex-disjoint paths, saturated direct pair weight — with the rule that structure trumps statistics at d ≤ 2 (percentiles need a web of ≥ 12 to mean anything; a direct completed exchange needs no statistics); and determinism — walk RNG seeded per (giver, ISO-week), re-run with more walks near a threshold, so a boundary decision doesn't flicker between refreshes.
The mirror query — our adversarial pass's sharpest finding, adopted. Seeding only from the giver protects property better than bodies: on hosting, the claimer is often the vulnerable one — a guest in a stranger's home. So at claim time the Pool also runs the reverse walk, trust_score(seed=claimer, target=giver), and shows the claimer their own trust card on the giver before they sign; give-side tier and safety clearance are enforced at offer publication for bodily-exposure resources. Same engine, one extra cached walk — and the system's protection becomes symmetric.
What writes edges — only both-signed completions; weights by depth of exposure; nothing negative ever enters the graph:
| Lifecycle event | Edges written | Weight |
| Consume gift completed | mutual (2 edges) | 1.0 |
| Hosting completed | mutual, per stay (per-night is farmable) | 2.0 — deeper exposure |
| Borrow — two-phase | handover: receiver→giver ("they actually lent it") · return: giver→receiver | 2.0 · 3.0 — an honored return is the strongest reliability signal we have |
| Vouch | strictly one-way (being vouched for endorses nobody); revocable = signed tombstone; carries weight only from tier≥2 vouchers (sybil vouches are inert); ≤12 active per voucher | 0.5 |
| No-show / non-return / report | none — acts at tier & safety layer | the borrower forfeits the 3.0 a return would have earned; the walks feel that absence forever |
- Sign-complete-with-zero: a completion signature attests the fact; the endorsement weight is separable — either party may sign with weight 0 (counterparty isn't told). People will sign to be polite; without this, default weights quietly poison the graph with endorsements nobody meant.
- Anti-farming — concavity, not just a cap: per-pair weights aggregate geometrically (each repeat counts 0.6× the last, hard cap Σ ≤ 6.0), so infinite mutual gifting asymptotes fast; edges younger than 7 days count at half (no mint-then-exploit); at most one weight-bearing event per pair per category per week. Walks reward breadth of distinct counterparties, not depth of one pair.
- Why no negative edges: they invite retaliation loops and graph poisoning. Failures act as tier demotions (auditable, reversible, human-adjudicated) and safety flags — and a failed return forfeits the 1.5 the borrower would have earned, which the walks feel forever.
Global tiers emerge from subjective data, sybil-proof: promotion counts distinct tier≥2 counterparties with both-signed exchanges — anchored recursion: trust flows downhill from a humanly-anchored core, and sybils never qualify because no tier≥2 member has exchanged with them. Tier 1→2: ≥K distinct tier≥2 counterparties (K=3–4, calibrated in warm mode) + account age ≥30–60 days + no upheld disputes — one enthusiastic friend can't promote you; several independent trusted humans is a real social footprint. Time is the one resource sybil factories can't parallelize. Tier 2→3: a proven return of a borrowed item + ≥5 distinct + ≥180 days + reachability from the regional core (so a closed clique can't self-promote to car-borrowing). Demotions: upheld dispute −1 · 12-month dormancy decays tiers like epoch decays edges · safety violation → 0, flagged, permanent.
Cold start: a genesis ceremony seeds founding members (physically present, mutual vouches) at tier 2 — the first walkable web — as public, expiring credentials (18 months, re-earned by normal rules: no permanent aristocracy). NGO/caseworker issuers get credential status from day one, rate-capped and public. Warm mode runs until measured conditions (not a calendar date): enough members, enough completions, and a shadow-gating audit showing the would-be denial rate is low and stewards agree with it. One floor is permanent: consume-level gifts to strangers stay open forever — the periphery must remain giftable or the graph stops growing and the gift economy calcifies into a gated club.
Narrative, with the engine running
04 The couch story — four claims, real numbers
A small community, real dates (epoch decay applies), scored by the live engine (mcp/scenario_couch.py, deterministic seed). Cast: three founders (genesis tier 2) · Maya, an active giver · Lena, newer — coat from Amara, cuttings to Kim, bike help for Nyx · Rio, peripheral · Jonas, brand new, zero history · "Spam", an attacker with one real exchange and a 20-sybil fake ring at 3× weights.
Scene 1 — Lena claims Maya's couch. They have never met.
tier gate: lena tier 2 ≥ 2 ✓ (promoted: amara + kim + nyx = 3 distinct tier≥2)
reach: hop 2 via 3 independent paths (amara · kim · nyx) → STRONG
context: share 0.0731, 88th percentile of maya's web — displayed, not decisive
safety: bodily exposure → maya must explicitly acknowledge
→ ADVISE STRONG — maya decides.
Two hops of real gifting (Maya↔Amara↔Lena, Maya↔Kim↔Lena, fresh Nyx edge) put a stranger at the 88th percentile of Maya's subjective web. That is the whole thesis working: does the giver's trust web reach this person? — yes, measurably.
Scene 2 — Jonas, zero history, claims the same couch.
tier gate: jonas tier 0 < 2 → GATED — paths shown: caseworker credential, or tier≥2 vouches
Scene 3 — Jonas claims a crisis shelter bed.
→ ADMIT — survival category: receiving is never trust-gated. Need is the qualification.
Same person, same graph, opposite outcome — because the gate protects against exploitation, never against being in need.
Scene 4 — Spam (1 real exchange + 20 sybils at 3× weight) claims Nyx's cargo bike.
tier gate: spam tier 1 < 2 → GATED (his only real counterparty is tier 1 — sybil ring counts for nothing)
context: from nyx's seed — spam=0.0447 · all 20 sybils together=0.0878 · honest lena=0.1004
The instructive detail we're not hiding: in Maya's raw view, Spam's score (0.0493) actually edges above peripheral-honest Rio (0.0622 vs 0.0493 — close), because fresh 3×-weighted ring edges trap walks. The tier gate is what stops him — he can't convert score into access without three real tier≥2 humans completing exchanges with him. That's the defense-in-depth argument in one row of numbers: layered checks, because no single number — not even yours — should be trusted alone.
Maya's full subjective view (what trust_score(maya) returns today):
| identity | score | relation |
| nyx | 0.1805 | direct, fresh + genesis web |
| amara | 0.1333 | direct |
| kim | 0.1140 | direct |
| lena | 0.0764 | never met — 2 hops, 3 paths |
| rio | 0.0622 | direct (hosted), single path |
| spam | 0.0493 | attacker — score inflated by ring, caught by tier gate |
| all 20 sybils combined | 0.0988 | ≈ one honest acquaintance, split 20 ways |
| jonas | 0 — unreachable | no path exists |
Validation
05 The proof — a real sybil attack
Not a claim — a simulation, deterministic, 7/7 checks pass (mcp/test_meritrank.py). An attacker earns 2 legitimate gifts, then mints N fake accounts with dense fake mutual gifting. What happens to the attacker+sybil region's total reputation, seen from an honest seed:
| Fake accounts minted | Attacker+sybil region score | Strongest single sybil |
| 0 | 0.126 | 0.000 |
| 10 | 0.469 | 0.034 |
| 50 | 0.482 | 0.007 |
| 200 | 0.485 | 0.002 |
Growing the sybil ring from 50 → 200 fake accounts changes the region's total reputation by 0.6%. Minting identities is useless — exactly the property the paper promises.
- Every honest neighbour outranks every sybil (~30× at N=200); a stranger with no path scores zero.
- Epoch decay verified: a 2-year-old identical gifting history scores ~4× lower than fresh (0.06 vs 0.24).
- Honest caveat, measured not hidden: under a 5×-edge-weight amplification attack the region converges to a bounded constant, measured c ≈ 3.9× the attacker's earned score. Bounded is the theorem's promise — not elimination. Your Rust implementation's fuller segment-based connectivity decay tightens that constant; ours uses a documented single-predecessor bridge-reliance approximation.
For you specifically
06 Honest engineering notes
- Connectivity decay is an approximation — and we know exactly how it's dodged. We penalise by the share of arrivals through a node's single most-used predecessor. Our own adversarial pass found the dodge: split the bridge across 2–3 controlled predecessors and the penalty fades. Engine v0.2 hardens it to the concentration (HHI) of the whole predecessor distribution; the paper's full segment-based decay, as in
meritrank-rust, remains stricter still. Meanwhile the K-distinct-tier≥2 promotion rule is the layer that doesn't share the weakness.
- Engine v0.2 (queued, from the same pass): walk diagnostics per target — visits, distinct predecessors, minimum hop — so reach classes are computed from structure, not just shares; per-seed caching (24h, invalidated on any edge touching the seed); a latency budget — if a walk misses ~2s, answer ADVISE "score pending" rather than block a gift on infrastructure.
- Reimplemented, not forked — with full credit. The embedded engine exists for zero-dependency deployment, not to compete with your library.
meritrank-rust (MIT) remains the production path; this is the twin that runs before we need a service.
- Same semantics. Feedback graph in, personalized subjective scores out, three decays, bounded sybil gain. A graph scored by our engine and by yours should agree in ranking; we'd welcome a conformance cross-check.
- Stress-tested twice. Beyond the sybil simulation, the full semantics ran through an independent adversarial design pass (fresh context, same problem, no sight of our answers). Where it disagreed — percentiles' crowd-dependence, the one-sided seed, the bridge-splitting dodge — we adopted its position. Convergences (positive-only graph, tiers ANDed with reach, giver sovereignty) we kept with more confidence.
Where trust math stops
07 Boundaries we keep sovereign
MeritRank measures reliability. It does not — and no algorithm can — measure two things we hold outside it:
- Reliability ≠ bodily safety. "Returns a borrowed car" is not "safe to host a vulnerable person overnight." Hosting keeps a separate human safety clearance dimension.
- Survival is never trust-gated. Receiving food or crisis shelter is never conditioned on reputation. Need is the qualification. Trust gates against exploitation, not against being in need.
- Cold-start. An empty graph scores nobody; vouching and issuer-signed verifiable credentials bootstrap the first trust before the walks have anything to walk.
- Survival receiving is sealed by default. A signed ledger of who-received-crisis-help is sensitive by construction. Those completions are steward-escrowed and pseudonymous on-ledger; the recipient may unseal later to convert them into tier history. Need-based receiving must not cost legibility.
- The equity valve. Trust webs replicate existing social capital — marginalized newcomers score NONE structurally, not accidentally. So denial rates are reviewed monthly by region and account age, and the standing rule is: if denials concentrate on newcomers, the thresholds are wrong, not the newcomers.
The bigger picture
08 An open hand
Intersubjective builds decentralized trust infrastructure. EPI builds a planetary gift economy. It is the same worldview from two directions: trust as intersubjective and earned, not granted by a central authority.
Concretely, two ways this could grow together:
- The Open Pool Protocol — our standard for any agent to contribute resources — could speak MeritRank trust natively.
meritrank-rust becomes the Pool's production trust service the moment the gift graph outgrows an embedded engine.
This page is the introduction. The rest is a conversation. 🌊