/* * Een virtuele gemeenschap opbouwen, groot genoeg om `npm run health` iets te laten meten. * * De seed is zeven mensen en twee correspondenties: precies genoeg om de app te bekijken en met de * hand na te rekenen, en veel te weinig om te zien of een meting doet wat ze belooft. Een mediaan * over zeven leden is geen mediaan, en een Gini over twee correspondenties is een toevalligheid. * Dit vult de database met een gemeenschap van duizenden, opgebouwd uit expliciete verdelingen in * plaats van uit toeval — zodat je van elk cijfer in het rapport kunt narekenen waar het vandaan * komt, en kunt zien of een knop hier de meting daar laat bewegen zoals verwacht. * * Wat dit NIET is: een uitspraak over hoe mensen zich werkelijk gedragen. De getallen hieronder zijn * geen bevindingen maar knoppen. Ze staan er om te kunnen vragen "als de gemeenschap zó in elkaar * zit, ziet het rapport dat dan?" — en dat is de enige vraag die dit bestand kan beantwoorden. * * npm run db:generate # 2000 leden, een half jaar geschiedenis * npm run db:generate -- --users 8000 --days 365 * npm run db:generate -- --seed 7 # ander toeval, zelfde model * npm run db:generate -- --desire-sigma 1.6 # veel schevere aandacht * npm run db:generate -- --reply-rate 0.15 # een stugge gemeenschap * npm run db:generate -- --radius-share 0.5 # de helft stelt een zoekstraal in * npm run db:generate -- --list # alle knoppen met hun standaardwaarde * * Daarna: npm run health * * Dit WIST de hele database en zet er iets verzonnens voor in de plaats. Het weigert te draaien * tegen iets anders dan localhost; wil je dat echt, dan moet --force erbij, en dan is het je eigen * beslissing. Draai het nooit tegen productie: daar staan brieven van mensen in. * * ---------- Waar deze getallen vandaan komen ---------- * * De eerste versie van dit bestand had knoppen die ik zelf had bedacht. Dat is erger dan het klinkt: * het rapport las ze terug en presenteerde ze als bevinding — "leeftijd bepaalt het bereik, gender * veel minder" was niets anders dan de leeftijdsverdeling en de gendermix die hier ingesteld staan. * Een simulatie kan alleen teruggeven wat erin zit, dus horen de getallen ergens vandaan te komen. * * Wat nu op iets is gebaseerd, met de bron erbij: * * gendermix 40/56/4 Pew (2023): van de singles die actief zoeken gebruikt 50% van de mannen * en 37% van de vrouwen een datingapp — een verhouding van ongeveer 57/43. * Niet de 75/25 die voor Tinder rondgaat: dat is een swipe-app-cijfer uit * marketingbronnen, en dit is geen swipe-app. * leeftijdsverdeling Pew (2023): ooit gebruikt naar leeftijd — 53% onder de 30, 37% van 30-49, * 20% van 50-64, 13% van 65+. Vandaar een bredere staart dan eerst. * begeerlijkheid Bruch & Newman, Science Advances (2018), 4 steden, ~187k gebruikers: er is * een uitgesproken rangorde, en die hangt met leeftijd samen — bij vrouwen * daalt hij continu van 18 tot 60, bij mannen piekt hij rond 50. Dat is de * reden dat DESIRE_AGE bestaat; eerder stond begeerlijkheid los van leeftijd * en van gender, en dan kán het rapport geen verschil vinden. * antwoordkans 0,22 Bruch & Newman: de gemiddelde antwoordkans van vrouwen op een eerste * bericht is minder dan 20%. Dat is de juiste vergelijking, want daar gaat * het net als hier om kóud contact. De ~50% uit onderzoek naar mobiele * datingapps (Bhattacharya e.a., 2016, 2M gesprekken) geldt ná een match — * twee mensen die elkaar al leuk vonden. Die stap bestaat hier niet. * Iets boven de 20% omdat een brief van 300 tekens meer moeite is dan "hoi", * en langere, persoonlijke berichten halen aantoonbaar hogere antwoordkansen. * Let op: dit is de geneigdheid van iemand die nog meedoet. Wat het rapport * méét ligt lager (rond de 7%), want een deel van de post komt aan bij * accounts die intussen stil gevallen zijn — en die antwoorden nooit. Dat * verschil is geen fout maar het effect van de levensduur hierboven. * leeftijdsvoorkeur Hitsch/Hortaçsu/Ariely en vervolgonderzoek: mannen zoeken jonger en laten * die voorkeur niet los naarmate ze ouder worden; CBS (2014) meet bij paren * die elkaar online ontmoetten een gemiddeld leeftijdsverschil van 3,2 jaar. * Vandaar dat --age-skew niet meer op 0 staat. * oriëntatie CBS (2024, Veiligheidsmonitor, 182.000 deelnemers): 2% van de mannen en * 1% van de vrouwen is homoseksueel, en bi-plus is met 11% veruit de * grootste groep. "1 op de 10 is homo" is een misverstand: dat getal komt * van Kinsey (1948), ging over "minstens drie jaar overwegend homoseksueel * tussen 16 en 55" en kwam uit een steekproef met veel gedetineerden en * sekswerkers. Voor bi-plus klopt 1 op de 10 wél ongeveer. * Daar bovenop: Pew (2023) vindt dat lhb'ers bijna twee keer zo vaak een * datingapp gebruiken (51% tegen 28%), dus op een app liggen de aandelen * hoger dan in de bevolking — vandaar 3% en 17% in plaats van 1,5% en 11%. * * Belangrijk onderscheid: identiteit is niet hetzelfde als filterinstelling. * Veel bi-plus mensen zetten toch één gender in hun filter. Daarom gaat * --bi-share over wie zich zo voelt en --bi-both over wie het ook instelt. * levensduur 30 dagen Datingapps hebben de hoogste uitval van alle app-categorieën: ongeveer 26% * is er na dag 1 nog, 12% na een week. Die cijfers gaan over installaties en * niet over mensen die een brief van 300 tekens schreven, dus 30 dagen * gemiddeld is nog steeds optimistisch — maar de 75 die hier eerst stond was * een fantasie. * * Wat NIET op iets is gebaseerd, en dus een aanname blijft: volhardendheid, de vermoeidheidsfactor, * het aandeel dat een zoekstraal instelt, en alles rond vervelend gedrag. Daar is geen publiek cijfer * voor dat over een briefwisselingsapp gaat. Lees ze als "wat als", nooit als "zo is het". * * En het belangrijkste voorbehoud: al het bovenstaande komt van swipe-apps en profielsites. Dit is * een app waarin je minimaal 300 tekens met de hand schrijft, waar geen match-stap is en waar het * filter wederzijds werkt. Elk cijfer hierboven is dus hooguit de goede orde van grootte. * * Reproduceerbaar: dezelfde --seed en dezelfde knoppen geven dezelfde gemeenschap, tot op de brief. * Er wordt nergens Math.random() gebruikt — anders is "het cijfer bewoog" niet te onderscheiden van * "het toeval bewoog". */ import { pool, query } from "../server/db.js"; import { FREE_ACCOUNTS, SESSION_TTL_DAYS } from "../src/constants.js"; /* ---------- De knoppen ---------- */ const KNOBS = { users: [2000, "aantal leden dat er nu nog is"], days: [180, "hoeveel dagen geschiedenis"], seed: [1, "toevalszaad; zelfde zaad = zelfde gemeenschap"], growth: [1.8, "groeikromme van de aanmeldingen: 1 = gelijkmatig, hoger = de meesten kwamen recent"], lifetime: [30, "hoe lang iemand gemiddeld meedoet, in dagen (exponentieel); daarna wordt het stil"], "openers-per-day": [0.08, "eerste brieven per lid per dag, vóór de spreiding hieronder"], "activity-sigma": [0.9, "spreiding in schrijflust (lognormaal); 0 = iedereen schrijft evenveel"], "desire-sigma": [0.85, "spreiding in hoeveel post je krijgt (lognormaal) — dit is wat de Gini stuurt"], "desire-age": [1, "hoe sterk leeftijd de begeerlijkheid vormt (0 = niet; 1 = zoals Bruch & Newman meten)"], "reply-rate": [0.22, "mediane geneigdheid van een ACTIEF lid om op een eerste brief te antwoorden"], "reply-spread": [1.2, "spreiding daarin (logit-normaal); 0 = iedereen even geneigd"], "reply-hours": [20, "mediane wachttijd op antwoord, in uren"], "reply-sigma": [1.3, "spreiding in die wachttijd (lognormaal); hoger geeft een langere staart trage antwoorders"], fatigue: [0.06, "hoeveel de antwoordkans daalt per eerder ontvangen brief — wie veel post krijgt antwoordt minder"], persist: [0.62, "kans dat een correspondentie na een brief nog een brief krijgt"], "persist-spread": [1.0, "spreiding daarin"], "radius-share": [0.15, "aandeel leden dat een zoekstraal instelt (en dus onzichtbaar is voor nieuwkomers)"], "location-share": [0.75, "aandeel leden met een locatie"], "homo-share": [0.03, "aandeel dat uitsluitend het eigen gender zoekt (zie de kop: CBS + oververtegenwoordiging)"], "bi-share": [0.17, "aandeel dat zich tot meer dan één gender aangetrokken voelt"], "bi-both": [0.6, "van die groep: het deel dat ook daadwerkelijk meerdere genders in het filter zet"], "age-skew": [3, "asymmetrie in leeftijdsvoorkeur: mannen zoeken zoveel jaar jonger, vrouwen ouder"], hostile: [0.02, "aandeel leden dat vervelend is; hun eerste brieven leiden tot blokkades en meldingen"], "block-rate": [0.03, "kans dat een gewone correspondentie op een blokkade eindigt"], "handled-share": [0.7, "aandeel meldingen dat al afgehandeld is"], "suspended-share": [0.004, "aandeel leden dat nu geschorst is"], departed: [0.12, "vertrokken leden, als aandeel van de huidige — zij bestaan niet meer, hun plek wel"], "photo-share": [0.08, "aandeel brieven met een foto"], }; const argv = process.argv.slice(2); const has = (n) => argv.includes(`--${n}`); const opt = (n) => { const i = argv.indexOf(`--${n}`); const [dflt] = KNOBS[n]; if (i === -1) return dflt; const v = Number(argv[i + 1]); if (!Number.isFinite(v) || v < 0) { console.error(`--${n} wil een getal (standaard ${dflt})`); process.exit(1); } return v; }; if (has("list")) { const w = Math.max(...Object.keys(KNOBS).map((k) => k.length)); for (const [k, [v, why]] of Object.entries(KNOBS)) console.log(` --${k.padEnd(w)} ${String(v).padStart(6)} ${why}`); process.exit(0); } const P = Object.fromEntries(Object.keys(KNOBS).map((k) => [k, opt(k)])); const N = Math.max(2, Math.round(P.users)); const DAYS = Math.max(1, Math.round(P.days)); /* De grendel: dit wist alles. Alleen localhost, tenzij je uitdrukkelijk zegt dat je het meent. */ { const url = new URL(process.env.DATABASE_URL ?? "postgres://postgres@localhost:5432/liefdesbrieven"); const local = ["localhost", "127.0.0.1", "::1", ""].includes(url.hostname); if (!local && !has("force")) { console.error(`weigering: DATABASE_URL wijst naar ${url.hostname}, en dit wist de hele database.`); console.error(`draait daar echt niets van waarde, herhaal dan met --force.`); process.exit(1); } if (!local) console.log(`LET OP: dit wist de database op ${url.hostname}.`); } /* ---------- Toeval, maar reproduceerbaar ---------- * * mulberry32: klein, snel, en met een expliciet zaad. Math.random() zou dit bestand waardeloos * maken voor waar het voor bedoeld is — je moet een knop kunnen verzetten en kunnen zien dat * alléén die knop het verschil maakte. */ const mulberry32 = (a) => () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const rnd = mulberry32(Math.round(P.seed) || 1); const uni = (lo, hi) => lo + rnd() * (hi - lo); const int = (lo, hi) => Math.floor(uni(lo, hi + 1)); const chance = (p) => rnd() < p; const pick = (xs) => xs[Math.floor(rnd() * xs.length)]; /* Box-Muller; de tweede trekking wordt weggegooid — eenvoud boven een halve trekking besparen */ const normal = () => { const u = Math.max(rnd(), 1e-12); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * rnd()); }; /* Lognormaal met mediaan 1: de natuurlijke vorm voor "de meesten weinig, enkelen heel veel" */ const lognormal = (sigma) => Math.exp(sigma * normal()); const expo = (mean) => -Math.log(Math.max(rnd(), 1e-12)) * mean; /* * Logit-normaal: een kans tussen 0 en 1 met een instelbare mediaan en spreiding. Netter dan een * afgeknotte normaal (die stapelt massa op 0 en 1) en eenvoudiger dan een beta (waarvan de * parameters niets zeggen over wat je wilt instellen). */ const logitNormal = (median, spread) => { const m = Math.min(Math.max(median, 1e-4), 1 - 1e-4); const mu = Math.log(m / (1 - m)); return 1 / (1 + Math.exp(-(mu + spread * normal()))); }; /* Poisson: Knuth voor kleine lambda, normale benadering zodra dat te traag wordt */ const poisson = (lambda) => { if (lambda <= 0) return 0; if (lambda < 30) { const L = Math.exp(-lambda); let k = 0; let p = 1; do { k++; p *= rnd(); } while (p > L); return k - 1; } return Math.max(0, Math.round(lambda + Math.sqrt(lambda) * normal())); }; /* ---------- Bouwstenen voor namen, plaatsen en tekst ---------- */ const VOORNAMEN = [ "Daan", "Marit", "Noor", "Hanna", "Vera", "Jasper", "Sam", "Lieke", "Bram", "Sanne", "Thijs", "Fleur", "Ruben", "Anouk", "Koen", "Maud", "Sven", "Iris", "Joris", "Femke", "Tim", "Roos", "Stijn", "Eva", "Wouter", "Julia", "Bas", "Sophie", "Niels", "Lotte", "Pieter", "Esmee", "Mark", "Nina", "Jeroen", "Karin", "Erik", "Marieke", "Hidde", "Tessa", "Casper", "Isa", "Lars", "Willemijn", "Olivier", "Saar", "Robin", "Nour", "Youssef", "Amir", "Fatima", "Zeynep", "Lucas", "Emma", "Milan", "Sara", "Levi", "Nora", ]; /* Plaatsen met een gewicht dat ruwweg de inwoners volgt — zonder dat is elke afstandsmeting vlak */ const PLAATSEN = [ ["Amsterdam", 52.3702, 4.8952, 90], ["Rotterdam", 51.9244, 4.4777, 65], ["Den Haag", 52.0705, 4.3007, 55], ["Utrecht", 52.0907, 5.1214, 40], ["Eindhoven", 51.4416, 5.4697, 25], ["Groningen", 53.2194, 6.5665, 24], ["Tilburg", 51.5555, 5.0913, 22], ["Almere", 52.3508, 5.2647, 21], ["Breda", 51.5719, 4.7683, 19], ["Nijmegen", 51.8426, 5.8546, 18], ["Arnhem", 51.9851, 5.8987, 16], ["Haarlem", 52.3874, 4.6462, 16], ["Enschede", 52.2215, 6.8937, 16], ["Amersfoort", 52.1560, 5.3878, 15], ["Zwolle", 52.5168, 6.0830, 13], ["Leiden", 52.1601, 4.4970, 12], ["Maastricht", 50.8514, 5.6910, 12], ["Leeuwarden", 53.2012, 5.7999, 9], ["Middelburg", 51.4988, 3.6136, 5], ["Assen", 52.9925, 6.5649, 5], ]; const PLAATS_TOTAAL = PLAATSEN.reduce((s, p) => s + p[3], 0); const kiesPlaats = () => { let r = rnd() * PLAATS_TOTAAL; for (const p of PLAATSEN) if ((r -= p[3]) <= 0) return p; return PLAATSEN[0]; }; /* * Zinnen om brieven en profielteksten uit te bouwen. Ze hoeven niet mooi te zijn, wel Nederlands en * gevarieerd: de fulltext-index (to_tsvector('dutch', ...)) staat op deze tekst, dus zoeken op een * gegenereerde gemeenschap moet iets kunnen vinden. Vandaar echte woorden en geen lorem ipsum. */ const ONDERWERPEN = [ "het brood dat dit weekend weer mislukte", "de leeszaal als het regent", "een lange fietstocht langs de dijk", "de volkstuin na de eerste vorst", "een boek dat ik te traag lees", "de markt op zaterdagochtend", "het wandelpad dat doodliep", "mijn zuurdesem, die aandacht eist", "een concert waar ik te vroeg was", "de trein die stilstond tussen twee weilanden", "het bos achter het station", "een oude brief van mijn oma", "het schilderij waar ik steeds langs loop", "de kat van de buren", "een film die me niet losliet", "de eerste koude ochtend", "mijn moestuin die alles tegelijk wil", "een plaat die ik grijsdraai", ]; const OPENINGEN = [ "Ik schrijf je vanuit huis, met de deur open en het geluid van buiten erbij.", "Je vorige brief lag hier nog naast me toen ik aan deze begon.", "Het is laat en ik zou moeten slapen, maar ik wilde je dit eerst schrijven.", "Vanochtend dacht ik aan wat je schreef, en de hele dag bleef het terugkomen.", "Ik heb lang zitten kijken naar het lege vel voordat ik begon.", "Er is niets bijzonders gebeurd deze week, en dat is precies waarom ik schrijf.", ]; const MIDDEN = [ "Wat me daarbij opviel is dat ik het pas begreep toen ik het probeerde op te schrijven.", "Ik denk dat ik het vroeger anders zou hebben gedaan, en ik weet niet of dat vooruitgang is.", "Het duurde even voordat ik doorhad dat ik gewoon aan het wachten was.", "Misschien is dat wel de reden dat ik het steeds uitstel, al zeg ik iets anders tegen mezelf.", "Ik merk dat ik daar rustiger van word dan van alles wat ik erbij zou kunnen bedenken.", "Het klinkt kleiner dan het was, maar zo gaat dat met dingen die je zelf hebt meegemaakt.", "Ik heb er met niemand over gesproken, en nu schrijf ik het aan jou, wat ook iets zegt.", ]; const VRAGEN = [ "Vertel me wat jij deze week hebt gedaan, ook als het niets bijzonders was.", "Wat lees je op dit moment, en gaat het snel of langzaam?", "Waar loop je naartoe als je even weg wilt zijn?", "Wat is het laatste dat je hebt gemaakt met je handen?", "Welke gewoonte van jezelf zou je willen houden, en welke niet?", "Schrijf me over de plek waar je je brieven schrijft.", ]; /* Een brief van minstens MIN_CHARS tekens — het schema weigert kortere, en terecht */ const brief = () => { let t = `${pick(OPENINGEN)}\n\nIk wilde je schrijven over ${pick(ONDERWERPEN)}. ${pick(MIDDEN)} ${pick(MIDDEN)}`; while (t.length < 320) t += ` ${pick(MIDDEN)}`; return `${t}\n\n${pick(VRAGEN)}`; }; const bio = () => { const n = int(1, 3); const stukken = []; for (let i = 0; i < n; i++) stukken.push(`${pick(ONDERWERPEN)}`); return `Ik houd van ${stukken.join(", ")}. ${pick(VRAGEN)}`; }; /* ---------- De leden ---------- */ const NU = Date.now(); const DAG = 86400000; const start = NU - DAYS * DAG; const GENDERS = ["v", "m", "x"]; /* Een datingapp trekt zelden een gelijke verdeling; scheefheid hier is zichtbaar in het rapport */ const GENDER_KANS = [0.4, 0.56, 0.04]; const kiesGender = () => { const r = rnd(); return r < GENDER_KANS[0] ? "v" : r < GENDER_KANS[0] + GENDER_KANS[1] ? "m" : "x"; }; /* * Begeerlijkheid naar leeftijd, in de vorm die Bruch & Newman (2018) meten: bij vrouwen daalt hij * continu van 18 tot 60, bij mannen loopt hij op tot ongeveer 50 en zakt daarna. De hoogtes zijn * getemperd — hun grafieken tonen de vorm, niet een factor die je zo kunt overnemen — maar de vorm * is van hen en niet van mij. * * Dit is de enige plek waar gender en leeftijd iets doen aan hoeveel post je krijgt. Stond dit er * niet (en dat was zo), dan kán het rapport hier niets over vinden, en is elke uitspraak erover in * het rapport een uitspraak over de leeftijdsverdeling die hierboven is ingesteld. Met --desire-age 0 * zet je hem uit en zie je precies dat verschil. * * Voor non-binaire leden is er geen onderzoek; die krijgen een flauwe daling en dat is een aanname. */ const desireByAge = (gender, age) => { const a = Math.min(Math.max(age, 18), 75); if (gender === "v") return Math.exp(-0.022 * (a - 18)); /* 18 → 1,00 · 40 → 0,62 · 60 → 0,40 */ if (gender === "m") return Math.exp(-0.5 * ((a - 50) / 28) ** 2); /* piek rond 50; 18 → 0,52 */ return Math.exp(-0.012 * (a - 18)); }; /* * Oriëntatie → wat er in het zoekfilter komt te staan. Dat zijn twee verschillende dingen, en de * vorige versie haalde ze door elkaar: die trok een gender uit een lijstje dat ik zelf had bedacht * en kwam zo op 30% dat álle genders zoekt en de helft die openstaat voor het eigen gender. Dat is * geen bevolking, dat is een lijstje. * * Voor non-binaire leden bestaat "de andere sekse" niet, dus daar is de vraag anders gesteld; er is * geen bruikbaar cijfer over hoe die groep filtert, dus dit is openlijk een aanname. */ const kiesFilter = (gender) => { if (gender === "x") return chance(0.55) ? ["v", "m", "x"] : chance(0.5) ? ["v", "x"] : ["m", "x"]; const ander = gender === "m" ? "v" : "m"; const r = rnd(); if (r < P["homo-share"]) return [gender]; if (r < P["homo-share"] + P["bi-share"]) { /* bi-plus, maar lang niet iedereen zet dat ook zo in: wie dat niet doet kiest één kant */ if (!chance(P["bi-both"])) return chance(0.5) ? [ander] : [gender]; return chance(0.25) ? ["v", "m", "x"] : ["v", "m"]; } return [ander]; }; const afstandKm = (la1, lo1, la2, lo2) => { const R = Math.PI / 180; return 6371 * 2 * Math.asin(Math.sqrt( Math.sin(((la2 - la1) * R) / 2) ** 2 + Math.cos(la1 * R) * Math.cos(la2 * R) * Math.sin(((lo2 - lo1) * R) / 2) ** 2 )); }; console.log(`gemeenschap opbouwen: ${N} leden, ${DAYS} dagen, zaad ${P.seed}`); const U = []; for (let i = 0; i < N; i++) { const gender = kiesGender(); const trans = chance(0.02); /* * Leeftijd lognormaal rond 33, met een ruimere staart dan eerst. De vorm volgt Pew (2023): ruim * de helft van de min-dertigers heeft ooit een datingapp gebruikt, ruim een derde van de dertigers * en veertigers, een vijfde van 50-64 en 13% daarboven. De eerste versie had een te smalle staart * (p90 op 45), en dan komt er uit het rapport dat ouderen niemand vinden — wat dan een eigenschap * van dít bestand is en niet van een gemeenschap. */ const age = Math.min(79, Math.max(18, Math.round(33 * lognormal(0.38)))); /* * Aanmelddatum. `growth` buigt de kromme: bij 1 komt iedereen even gelijkmatig binnen, bij hoger * kwamen de meesten recent. Dat verschil is wat de trechter per cohort zichtbaar moet maken. */ const joined = start + DAYS * DAG * Math.pow(rnd(), 1 / Math.max(P.growth, 0.05)); /* Hoe lang iemand meedoet: exponentieel, dus de meesten haken snel af en enkelen blijven lang */ const lifetime = expo(P.lifetime) * DAG; const lastActive = Math.min(NU, joined + lifetime); const heeftLocatie = chance(P["location-share"]); const [, lat0, lon0] = heeftLocatie ? kiesPlaats() : []; const lat = heeftLocatie ? +(lat0 + normal() * 0.06).toFixed(2) : null; const lon = heeftLocatie ? +(lon0 + normal() * 0.09).toFixed(2) : null; /* * Het filter begint bij wat createUser() geeft (alle genders, de band uit src/constants.js) en wordt daarna * aangepast, want dat doen mensen ook. Wie versmalt kiest één voorkeur; de leeftijdsband wordt * ruimer of krapper. `age-skew` voegt desgewenst een gendergebonden asymmetrie toe — standaard 0, * want het is een scenario om te kunnen beproeven, geen aanname die hier hoort te sluipen. */ const seekGenders = kiesFilter(gender); const skew = P["age-skew"] * (gender === "m" ? -1 : gender === "v" ? 1 : 0); const onder = Math.round(uni(2, 12) - skew); const boven = Math.round(uni(2, 12) + skew); const seekAgeMin = Math.min(Math.max(18, age - Math.max(onder, 1)), 98); const seekAgeMax = Math.min(99, Math.max(seekAgeMin + 1, age + Math.max(boven, 1))); U.push({ i, email: `lid${i}@voorbeeld.nl`, name: `${pick(VOORNAMEN)}`, gender, trans, relationship: chance(0.12) ? "relatie" : "vrijgezel", age, ageJitter: int(0, 300), bio: chance(0.06) ? "" : bio(), joined, lastActive, seekGenders, seekRelatie: chance(0.3), seekTrans: trans ? true : chance(0.35), seekAgeMin, seekAgeMax, lat, lon, radius: heeftLocatie && chance(P["radius-share"]) ? pick([10, 15, 25, 25, 50, 50, 100]) : null, /* De drie eigenschappen die het sociale gedrag sturen */ activity: lognormal(P["activity-sigma"]), desire: lognormal(P["desire-sigma"]) * Math.pow(desireByAge(gender, age), P["desire-age"]), responsiveness: logitNormal(P["reply-rate"], P["reply-spread"]), persistence: logitNormal(P.persist, P["persist-spread"]), hostile: chance(P.hostile), received: 0, suspendedAt: null, suspendedUntil: null, suspendReason: "", }); } /* Een handvol geschorsten, zodat de handhavingscijfers niet leeg blijven */ for (const u of U) { if (chance(P["suspended-share"])) { u.suspendedAt = new Date(uni(u.joined, NU)); u.suspendedUntil = chance(0.6) ? new Date(NU + uni(1, 60) * DAG) : null; u.suspendReason = pick(["herhaalde intimidatie", "nepprofiel", "ongewenst seksueel beeld"]); } } /* ---------- De wederzijdse match, in JS ---------- * * Dit is met opzet een kópie van MUTUAL_MATCH uit server/queries.js, en dat is hier verdedigbaar * omdat het om het máken van gegevens gaat en niet om het meten ervan: het rapport meet straks met * het origineel. Wél moeten ze dezelfde regels hebben, anders schrijven de gegenereerde leden aan * mensen die ze in de app nooit gezien zouden hebben. Verandert MUTUAL_MATCH, dan hoort dit mee te * veranderen — en `npm run health` laat het meteen zien als dat vergeten is: de bereikbaarheid * loopt dan niet meer in de pas met wie er daadwerkelijk post kreeg. */ const past = (a, b) => { if (!a.seekGenders.includes(b.gender)) return false; if (b.relationship !== "vrijgezel" && !a.seekRelatie) return false; if (b.trans && !a.seekTrans) return false; if (b.age < a.seekAgeMin || b.age > a.seekAgeMax) return false; if (a.radius != null && (b.lat == null || afstandKm(a.lat, a.lon, b.lat, b.lon) > a.radius)) return false; return true; }; const wederzijds = (a, b) => a !== b && past(a, b) && past(b, a) && !b.suspendedAt && !a.suspendedAt; /* * Doelwitten worden getrokken naar begeerlijkheid, niet uniform. Dát is wat de aandacht scheef * maakt, en scheve aandacht is het verschijnsel waarvoor sectie D van het rapport bestaat. De * cumulatieve tabel wordt één keer gebouwd; daarna is elke trekking een binaire zoekactie. */ const cum = new Float64Array(N); { let s = 0; for (let i = 0; i < N; i++) cum[i] = s += U[i].desire; } const trekNaarBegeerte = () => { const r = rnd() * cum[N - 1]; let lo = 0; let hi = N - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (cum[mid] < r) lo = mid + 1; else hi = mid; } return U[lo]; }; /* ---------- Het sociale proces ---------- */ const letters = []; const blocks = []; const reports = []; const favorites = []; const paren = new Set(); const pairKey = (a, b) => (a.i < b.i ? `${a.i}:${b.i}` : `${b.i}:${a.i}`); for (const from of U) { if (from.suspendedAt) continue; const leefdagen = Math.max(0, (from.lastActive - from.joined) / DAG); const aantal = poisson(P["openers-per-day"] * from.activity * leefdagen); for (let k = 0; k < aantal; k++) { /* * Verwerpingsmethode: trek naar begeerte, kijk of het wederzijds past en of die persoon toen al * bestond. Dat is nauwkeuriger én sneller dan per schrijver de volledige lijst passende leden * bijhouden — bij duizenden leden is dat een kruistabel van miljoenen, en die is hier nergens * voor nodig. */ const t0 = uni(from.joined, from.lastActive); let to = null; for (let poging = 0; poging < 40; poging++) { const kandidaat = trekNaarBegeerte(); if (kandidaat.joined <= t0 && wederzijds(from, kandidaat) && !paren.has(pairKey(from, kandidaat))) { to = kandidaat; break; } } if (!to) continue; paren.add(pairKey(from, to)); /* * De antwoordkans daalt met wat de ontvanger al gekregen heeft. Zonder die vermoeidheid * antwoordt de populairste persoon net zo trouw als iemand met twee brieven, en dan mist het * model precies het verschijnsel waar een nieuwkomer tegenaan loopt. */ const p = to.responsiveness / (1 + P.fatigue * to.received); to.received++; let t = t0; let schrijver = from; let ontvanger = to; letters.push({ from: from.i, to: to.i, at: t }); let diepte = 1; if (!chance(p)) { if (from.hostile || chance(P["block-rate"] * 0.5)) hostiliteit(from, to, t); continue; } /* Het antwoord, en daarna heen en weer zolang beiden volhouden */ const volhouden = Math.min(from.persistence, to.persistence); for (let beurt = 0; beurt < 60; beurt++) { t += lognormal(P["reply-sigma"]) * P["reply-hours"] * 3600000; if (t > Math.min(NU, ontvanger.lastActive + 3 * DAG)) break; [schrijver, ontvanger] = [ontvanger, schrijver]; letters.push({ from: schrijver.i, to: ontvanger.i, at: t }); diepte++; if (!chance(volhouden)) break; } /* Favoriet is iets voor wie je echt kent — pas als er een briefwisseling ligt, niet na één brief */ if (diepte >= 4) { if (chance(0.25)) favorites.push({ user: from.i, other: to.i, at: t }); if (chance(0.25)) favorites.push({ user: to.i, other: from.i, at: t }); } if (from.hostile) hostiliteit(from, to, t); else if (chance(P["block-rate"])) blocks.push({ user: to.i, blocked: from.i, at: t + uni(0, 3) * DAG }); } } /* Wat er gebeurt na een brief van iemand die vervelend is: blokkeren, melden, of allebei */ function hostiliteit(from, to, t) { const at = t + uni(0.1, 4) * DAG; if (at > NU) return; if (chance(0.55)) blocks.push({ user: to.i, blocked: from.i, at }); if (chance(0.4)) { reports.push({ reporter: to.i, reported: from.i, reason: pick(["intimidatie", "seksueel", "nep", "oplichting", "minderjarig", "anders"]), at, handled: chance(P["handled-share"]) ? at + uni(0.2, 6) * DAG : null, }); } } letters.sort((a, b) => a.at - b.at); /* Gelezen: wie nog meedeed opende zijn post meestal binnen een dag; de nieuwste post ligt er nog */ for (const l of letters) { const to = U[l.to]; const read = l.at + lognormal(1.1) * 8 * 3600000; l.read = read <= Math.min(NU, to.lastActive + DAG) ? read : null; } console.log(` ${letters.length} brieven in ${paren.size} correspondenties, ${blocks.length} blokkades, ${reports.length} meldingen`); /* ---------- Wegschrijven ---------- */ const chunk = (xs, n) => { const out = []; for (let i = 0; i < xs.length; i += n) out.push(xs.slice(i, i + n)); return out; }; const ts = (ms) => (ms == null ? null : new Date(ms).toISOString()); await query( `truncate farewells, departures, purchases, measures, warnings, reports, blocks, favorites, photos, letters, sessions, password_resets, users restart identity` ); /* * De geboortedatum en de filtergrenzen worden in SQL uitgerekend met oldest_birthdate() / * youngest_birthdate() — dezelfde functies die het schema en createUser() gebruiken. Ze hier in JS * narekenen zou een derde plek zijn waar dezelfde omrekening staat, en dat is er één te veel. */ const HASH = "scrypt$16384$8$1$bGllZmRlc2JyaWV2ZW4tc2VlZCE$2ODMSaaLZbo7LBHzKxahvLBrOndQ9OTDkCOMfCOiIY8"; for (const deel of chunk(U, 500)) { await query( `insert into users (email, password_hash, consent_at, name, gender, trans, relationship, birthdate, bio, created_at, last_active, seek_genders, seek_relatie, seek_trans, seek_min_birth, seek_max_birth, lat, lon, seek_radius_km, suspended_at, suspended_until, suspend_reason) select t.email, $1, t.created_at, t.name, t.gender, t.trans, t.relationship, (current_date - make_interval(years => t.age) - make_interval(days => t.jitter))::date, t.bio, t.created_at, t.last_active, t.seek_genders::char(1)[], t.seek_relatie, t.seek_trans, oldest_birthdate(t.seek_age_max), youngest_birthdate(t.seek_age_min), t.lat, t.lon, t.radius, t.suspended_at, t.suspended_until, t.suspend_reason from unnest($2::text[], $3::text[], $4::char(1)[], $5::boolean[], $6::text[], $7::int[], $8::int[], $9::text[], $10::timestamptz[], $11::timestamptz[], $12::text[], $13::boolean[], $14::boolean[], $15::int[], $16::int[], $17::double precision[], $18::double precision[], $19::int[], $20::timestamptz[], $21::timestamptz[], $22::text[]) as t(email, name, gender, trans, relationship, age, jitter, bio, created_at, last_active, seek_genders, seek_relatie, seek_trans, seek_age_min, seek_age_max, lat, lon, radius, suspended_at, suspended_until, suspend_reason)`, [ HASH, deel.map((u) => u.email), deel.map((u) => u.name), deel.map((u) => u.gender), deel.map((u) => u.trans), deel.map((u) => u.relationship), deel.map((u) => u.age), deel.map((u) => u.ageJitter), deel.map((u) => u.bio), deel.map((u) => ts(u.joined)), deel.map((u) => ts(u.lastActive)), deel.map((u) => `{${u.seekGenders.join(",")}}`), deel.map((u) => u.seekRelatie), deel.map((u) => u.seekTrans), deel.map((u) => u.seekAgeMin), deel.map((u) => u.seekAgeMax), deel.map((u) => u.lat), deel.map((u) => u.lon), deel.map((u) => u.radius), deel.map((u) => (u.suspendedAt ? u.suspendedAt.toISOString() : null)), deel.map((u) => (u.suspendedUntil ? u.suspendedUntil.toISOString() : null)), deel.map((u) => u.suspendReason), ] ); } /* Index → echt id. Via het e-mailadres, want dat draagt de index; op de volgorde van identity vertrouwen zou werken maar staat nergens gegarandeerd. */ const ids = new Int32Array(N); { const { rows } = await query(`select id, email from users`); for (const r of rows) ids[Number(r.email.slice(3, -"@voorbeeld.nl".length))] = Number(r.id); } const id = (i) => ids[i]; for (const deel of chunk(letters, 400)) { await query( `insert into letters (from_id, to_id, sent_at, read_at, body) select * from unnest($1::bigint[], $2::bigint[], $3::timestamptz[], $4::timestamptz[], $5::text[])`, [deel.map((l) => id(l.from)), deel.map((l) => id(l.to)), deel.map((l) => ts(l.at)), deel.map((l) => ts(l.read)), deel.map(() => brief())] ); } /* Een 1×1 PNG als foto: het gaat om de tellingen en de cascade, niet om het plaatje */ const PNG = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d4944415478da6360000002000154a24f6b0000000049454e44ae426082", "hex"); { /* Alleen brieven die er in het echt ook een foto bij mochten hebben: de ander moet eerder geschreven hebben. Openingsbrieven blijven dus zonder beeld, net als in de app. */ const { rows } = await query( `select l.id from letters l where exists ( select 1 from letters e where e.from_id = l.to_id and e.to_id = l.from_id and e.sent_at < l.sent_at ) order by random() limit $1::int`, [Math.round(letters.length * P["photo-share"])] ); for (const deel of chunk(rows, 200)) { await query( `insert into photos (letter_id, mime, bytes, width, height, alt) select t.id, 'image/png', $2, 1, 1, t.alt from unnest($1::bigint[], $3::text[]) as t(id, alt)`, [deel.map((r) => r.id), PNG, deel.map(() => pick(ONDERWERPEN))] ); } } const uniekeBlokkades = [...new Map(blocks.map((b) => [`${b.user}:${b.blocked}`, b])).values()]; for (const deel of chunk(uniekeBlokkades, 500)) { await query( `insert into blocks (user_id, blocked_id, created_at) select * from unnest($1::bigint[], $2::bigint[], $3::timestamptz[])`, [deel.map((b) => id(b.user)), deel.map((b) => id(b.blocked)), deel.map((b) => ts(b.at))] ); } const uniekeFavorieten = [...new Map(favorites.map((f) => [`${f.user}:${f.other}`, f])).values()]; for (const deel of chunk(uniekeFavorieten, 500)) { await query( `insert into favorites (user_id, other_id, created_at) select * from unnest($1::bigint[], $2::bigint[], $3::timestamptz[])`, [deel.map((f) => id(f.user)), deel.map((f) => id(f.other)), deel.map((f) => ts(f.at))] ); } for (const deel of chunk(reports, 500)) { await query( `insert into reports (reporter_id, reported_id, reason, details, created_at, handled_at) select * from unnest($1::bigint[], $2::bigint[], $3::text[], $4::text[], $5::timestamptz[], $6::timestamptz[])`, [deel.map((r) => id(r.reporter)), deel.map((r) => id(r.reported)), deel.map((r) => r.reason), deel.map(() => pick(["", "Het gebeurde meerdere keren.", "Zie de brief van vorige week."])), deel.map((r) => ts(r.at)), deel.map((r) => ts(r.handled))] ); } /* Bij elke geschorste hoort een maatregel in het logboek; anders is "we kijken ernaar" niet te toetsen */ { const g = U.filter((u) => u.suspendedAt); if (g.length) { await query( `insert into measures (user_id, kind, note, created_at) select * from unnest($1::bigint[], $2::text[], $3::text[], $4::timestamptz[])`, [g.map((u) => id(u.i)), g.map(() => "schorsing"), g.map((u) => u.suspendReason), g.map((u) => u.suspendedAt.toISOString())] ); } } /* * Sessies. Zonder deze rijen valt "actief" in het rapport terug op last_active, en dat mist de * stille lezer — precies de groep die het rapport zichtbaar hoort te maken. Alleen voor wie binnen * de bewaartermijn actief was; daarbuiten zou purgeExpired() de rij allang hebben opgeruimd. */ { const levend = U.filter((u) => !u.suspendedAt && NU - u.lastActive < SESSION_TTL_DAYS * DAG); const hex = () => Array.from({ length: 64 }, () => "0123456789abcdef"[int(0, 15)]).join(""); for (const deel of chunk(levend, 500)) { await query( `insert into sessions (token_hash, user_id, created_at, last_seen_at) select * from unnest($1::text[], $2::bigint[], $3::timestamptz[], $4::timestamptz[])`, [deel.map(() => hex()), deel.map((u) => id(u.i)), deel.map((u) => ts(u.joined)), /* de stille lezer: iets recenter gezien dan zijn laatste schrijfhandeling */ deel.map((u) => ts(Math.min(NU, u.lastActive + uni(0, 5) * DAG)))] ); } } /* * Vertrek. Deze mensen bestaan niet meer — dat is het punt — dus het enige wat er van hen wordt * aangemaakt is de rij in `departures` die een echt vertrek ook zou achterlaten: een datum, hoe lang * ze meededen, waarom ze weg zijn, en of ze ooit een brief schreven. * * De verdeling volgt dezelfde levensduur als die van de blijvers, want het zijn dezelfde mensen — * alleen degenen bij wie hij afliep. Wie kort lid was schreef meestal niets; dat verband is wat het * rapport zichtbaar hoort te maken, dus het zit hier bewust in. */ const vertrokken = Math.round(N * P.departed); { const rijen = Array.from({ length: vertrokken }, () => { const dagen = Math.max(0, Math.round(expo(P.lifetime))); return { at: uni(start, NU), dagen, /* Kort lid = vrijwel zeker nooit geschreven; lang lid = vrijwel zeker wel */ wrote: chance(Math.min(0.9, dagen / (dagen + 20))), reason: chance(P["suspended-share"] * 4) ? "maatregel" : dagen > 730 ? "slapend" : "zelf", }; }); for (const deel of chunk(rijen, 500)) { await query( `insert into departures (at, days_member, reason, wrote) select * from unnest($1::timestamptz[], $2::int[], $3::text[], $4::boolean[])`, [deel.map((r) => ts(r.at)), deel.map((r) => r.dagen), deel.map((r) => r.reason), deel.map((r) => r.wrote)] ); } } /* * En de brieven die sommigen van hen achterlieten. Eén op de zes, want dat is ongeveer hoe vaak * iemand de moeite neemt — en de verhouding klopt: meer klachten dan bedankjes, want wie tevreden * weggaat heeft zijn reden om te vertrekken meestal al gevonden en kijkt niet meer om. * * Ze horen bij géén van de rijen hierboven; dat is precies wat de tabel is (zie db/schema.sql). * Ze staan hier zodat `npm run reports -- --afscheid` iets te lezen heeft. */ { const teksten = [ "Ik heb hier iemand leren kennen met wie ik nu samenwoon. Dank daarvoor. Ik hoop dat de app blijft bestaan zoals hij is: traag, en zonder dat je elke dag moet komen opdagen.", "Het werkte niet voor mij. Ik heb elf brieven geschreven en er drie terug gekregen, en na een half jaar houdt het op. Niet jullie schuld denk ik, er zijn gewoon te weinig mensen in mijn buurt.", "De zoekfunctie deugt niet. Ik zocht op 'klimmen' en kreeg iemand die schreef dat hij nooit klimt. Verder een mooi idee.", "Ik vond het prettig dat er geen foto's op de profielen staan. Dat scheelde me het gevoel dat ik in een etalage stond. Ik ga weg omdat het me nu te veel tijd kost, niet omdat er iets mis is.", "Drie keer gemeld dat iemand mij bleef schrijven nadat ik duidelijk had gezegd dat ik niet wilde. Er is wel iets mee gedaan, maar ik hoorde er niets over terug en dat voelde alsof ik in het niets had staan praten. Zeg tegen mensen wat je met hun melding doet.", "Minimaal 300 tekens is te veel. Soms wil je gewoon zeggen dat je het leuk vond.", "Bedankt. Ik heb hier vier maanden met iemand geschreven en het is niets geworden, maar het waren de beste brieven die ik ooit gekregen heb.", ]; const brieven = Array.from({ length: Math.max(1, Math.round(vertrokken / 6)) }, () => ({ at: uni(start, NU), body: pick(teksten), })); for (const deel of chunk(brieven, 500)) { await query( `insert into farewells (at, body) select * from unnest($1::timestamptz[], $2::text[])`, [deel.map((b) => ts(b.at)), deel.map((b) => b.body)] ); } } /* * De plekken. `granted` telt óók de vertrokkenen mee: een verwijderd account geeft zijn plek niet * terug (zie db/schema.sql). Dat is een poort tegen "wissen en opnieuw", geen verloopcijfer — het * verloop staat hierboven, in `departures`. */ const gratis = Math.min(N + vertrokken, FREE_ACCOUNTS); const betaald = Math.max(0, N + vertrokken - gratis); await query(`update signup_slots set granted = $1`, [gratis]); if (betaald > 0) { /* De laatst aangemelde leden betaalden; de vertrokken betalers laten een bon zonder account achter */ const metAccount = U.slice().sort((a, b) => a.joined - b.joined).slice(-Math.max(0, betaald - vertrokken)); const rijen = [ ...metAccount.map((u) => ({ user: u.i, at: u.joined })), ...Array.from({ length: Math.min(vertrokken, betaald) }, () => ({ user: null, at: uni(start, NU) })), ]; for (const deel of chunk(rijen, 500)) { await query( `insert into purchases (provider, token, product_id, order_id, user_id, created_at) select 'google', t.token, 'account', 'GPA.' || t.token, t.user_id, t.created_at from unnest($1::text[], $2::bigint[], $3::timestamptz[]) as t(token, user_id, created_at)`, [deel.map((_, k) => `tok-${rnd().toString(36).slice(2)}-${k}`), deel.map((r) => (r.user == null ? null : id(r.user))), deel.map((r) => ts(r.at))] ); } } /* ---------- Klaar ---------- */ const tel = async (t) => Number((await query(`select count(*)::int n from ${t}`)).rows[0].n); console.log(""); console.log(` leden ${await tel("users")}`); console.log(` brieven ${await tel("letters")} (${await tel("photos")} met foto)`); console.log(` blokkades ${await tel("blocks")}`); console.log(` meldingen ${await tel("reports")}`); console.log(` favorieten ${await tel("favorites")}`); console.log(` sessies ${await tel("sessions")}`); console.log(` vertrokken ${await tel("departures")} (${await tel("farewells")} lieten een brief achter)`); console.log(` plekken ${gratis} gratis uitgegeven, ${await tel("purchases")} gekocht`); console.log(""); console.log(`inloggen kan als elk lid: lid0@voorbeeld.nl … lid${N - 1}@voorbeeld.nl, wachtwoord "liefdesbrieven"`); console.log(`nu meten: npm run health`); await pool.end();