{ "cells": [ { "cell_type": "markdown", "id": "f0435d52-4276-4026-bfeb-8a741b01fc31", "metadata": {}, "source": [ "# Buena Muerte Social Club" ] }, { "cell_type": "markdown", "id": "b0f77714-e9ad-4a32-8382-9527016422b1", "metadata": {}, "source": [ ":::note\n", "This is the companion notebook to the article *Buena Muerte Social Club*, submitted to [FUN 2026](https://fun2026.limos.fr/). It is NOT intended to be self-contained. Please refer to the paper for context and explanations.\n", ":::" ] }, { "cell_type": "markdown", "id": "922af91d-3fd2-4696-aa90-e49f3c1f0d64", "metadata": {}, "source": [ "## Preliminaries" ] }, { "cell_type": "markdown", "id": "99b65834-2028-469a-8681-6db920df9d70", "metadata": {}, "source": [ "### Basic Blood Types" ] }, { "cell_type": "markdown", "id": "42dec34d-9cf2-4aa1-a814-ccddeaa2c464", "metadata": {}, "source": [ "First, we define the different blood types. In addition to the $2^3$ main types depending on the presence of $A$, $B$, and $Rhesus$ markers, we add a void type that will be use later to model non-ghoul humans.\n", "\n", "An important property is *can_feed*, that tells if a vampire of given type can feed on a ghoul of other type." ] }, { "cell_type": "code", "execution_count": 1, "id": "3ccbc307-90c6-4a63-87d3-1984672fa51d", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.186602Z", "iopub.status.busy": "2026-01-12T15:27:29.185106Z", "iopub.status.idle": "2026-01-12T15:27:29.316289Z", "shell.execute_reply": "2026-01-12T15:27:29.316289Z", "shell.execute_reply.started": "2026-01-12T15:27:29.186602Z" } }, "outputs": [], "source": [ "import numpy as np \n", "\n", "class BloodType:\n", " A = [\"\", \"A\"]\n", " B = [\"\", \"B\"]\n", " R = [\"-\", \"+\"]\n", "\n", " def __init__(self, i, j, k):\n", " self.markers = np.array([i, j, k])\n", " self.x = False\n", "\n", " def __repr__(self):\n", " if self.x:\n", " return 'X'\n", " i, j, k = self.markers\n", " result = f\"{self.A[i]}{self.B[j]}{self.R[k]}\"\n", " if len(result) == 1:\n", " return f\"O{result}\"\n", " else:\n", " return result\n", "\n", " @property\n", " def sanitized_name(self):\n", " res = self.__repr__()\n", " return res.replace('-','m').replace('+','p')\n", "\n", " def can_feed_on(self, other):\n", " return np.all(other.markers <= self.markers)\n", "\n", " @classmethod\n", " def void(cls):\n", " res = cls(1, 1, 1)\n", " res.x = True\n", " return res" ] }, { "cell_type": "markdown", "id": "f7ff914a-2295-4e1f-894a-0b7747d8f4d0", "metadata": {}, "source": [ "Let us compute all possible groups. We'll store them in a list, a class, and a dict for easy access." ] }, { "cell_type": "code", "execution_count": 2, "id": "f6a15fa9-0e85-4410-a9da-2070519d2cfe", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.317305Z", "iopub.status.busy": "2026-01-12T15:27:29.317305Z", "iopub.status.idle": "2026-01-12T15:27:29.328238Z", "shell.execute_reply": "2026-01-12T15:27:29.328238Z", "shell.execute_reply.started": "2026-01-12T15:27:29.317305Z" } }, "outputs": [ { "data": { "text/plain": [ "[O-, O+, B-, B+, A-, A+, AB-, AB+]" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "class Groups:\n", " pass\n", "\n", "groups = [BloodType(i, j, k) for i in range(2) for j in range(2) for k in range(2)]\n", "g = Groups\n", "for t in groups:\n", " setattr(g, t.sanitized_name, t)\n", "groups_ = {str(g): g for g in groups}\n", "\n", "groups" ] }, { "cell_type": "markdown", "id": "8c3d6104-7410-4463-9bff-466f7cf4d8bd", "metadata": {}, "source": [ "### Vampire/Ghoul Types" ] }, { "cell_type": "markdown", "id": "96ef629a-334e-4e29-b28c-f649ece2c40e", "metadata": {}, "source": [ "We now define Vampire/Ghoul types, which are defined by two blood types. The following properties are defined :\n", "- *autarkic*: the vampire can feed on their ghoul.\n", "- *compatible_with*: each vampire of two pairs can feed on the other one ghoul.\n", "- *swinger*: a non plug-and-drink pair compatible with another non plug-and-drink pair.\n", "- *helpless*: a non plug-and-drink pair that is only compatible with plug-and-drink-pairs.\n", "- *three_compatible*: each vampire of three pairs can feed on a distinct available ghoul, in a cyclic fashion." ] }, { "cell_type": "code", "execution_count": 3, "id": "245429b6-8b16-41d8-9495-3275928ce083", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.329584Z", "iopub.status.busy": "2026-01-12T15:27:29.329584Z", "iopub.status.idle": "2026-01-12T15:27:29.337543Z", "shell.execute_reply": "2026-01-12T15:27:29.336537Z", "shell.execute_reply.started": "2026-01-12T15:27:29.329584Z" } }, "outputs": [], "source": [ "class VGType:\n", " def __init__(self, v, g):\n", " self.v = v\n", " self.g = g\n", " self.compatible_types = []\n", "\n", " def __repr__(self):\n", " return f\"{self.v}/{self.g}\"\n", "\n", " @property\n", " def category(self):\n", " if self.auto_compatible:\n", " return \"autarkic\"\n", " if all(c.auto_compatible for c in self.compatible_types):\n", " return \"helpless\"\n", " return \"swinger\"\n", " \n", " @property\n", " def auto_compatible(self):\n", " return self.v.can_feed_on(self.g)\n", "\n", " def compatible_with(self, other):\n", " return self.v.can_feed_on(other.g) and other.v.can_feed_on(self.g)\n", "\n", " def three_cycle(self, other1, other2):\n", " return self.v.can_feed_on(other1.g) and other1.v.can_feed_on(other2.g) and other2.v.can_feed_on(self.g)\n", " \n", " def three_compatible_with(self, other1, other2):\n", " return self.three_cycle(other1, other2) or self.three_cycle(other2, other1)" ] }, { "cell_type": "markdown", "id": "2d9aad78-0beb-492d-ab80-622214fd2aa8", "metadata": {}, "source": [ "We now prepare all possible VG pairs and observe their repartition." ] }, { "cell_type": "code", "execution_count": 4, "id": "2643225d-c14d-49da-b1c6-8e201312f1b9", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.338543Z", "iopub.status.busy": "2026-01-12T15:27:29.338543Z", "iopub.status.idle": "2026-01-12T15:27:29.369275Z", "shell.execute_reply": "2026-01-12T15:27:29.369275Z", "shell.execute_reply.started": "2026-01-12T15:27:29.338543Z" } }, "outputs": [ { "data": { "text/plain": [ "Counter({'autarkic': 27, 'helpless': 19, 'swinger': 18})" ] }, "execution_count": 4, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from collections import Counter\n", "vgs = [VGType(v, g) for v in groups for g in groups]\n", "for v1 in vgs:\n", " for v2 in vgs:\n", " if v1.compatible_with(v2):\n", " v1.compatible_types.append(v2)\n", "vgs_ = {str(vg): i for i, vg in enumerate(vgs)}\n", "Counter(vg.category for vg in vgs)" ] }, { "cell_type": "markdown", "id": "3651463f-d2d3-4aa1-8183-2cd4becbcce9", "metadata": {}, "source": [ "### Human types" ] }, { "cell_type": "markdown", "id": "2c4c2aec-db78-43fb-a94c-b2de6bfeadfd", "metadata": {}, "source": [ "We call *human* a potential ghoul not affiliated yet to any vampire. Humans can be modeled as a VG pair where the vampire is a placeholder, denoted $X$, compatible with everyone, e.g. *X/O+*" ] }, { "cell_type": "code", "execution_count": 5, "id": "079e1f8e-76c9-4ad3-b8d9-c2b9ce0bc6fe", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.370281Z", "iopub.status.busy": "2026-01-12T15:27:29.370281Z", "iopub.status.idle": "2026-01-12T15:27:29.375041Z", "shell.execute_reply": "2026-01-12T15:27:29.375041Z", "shell.execute_reply.started": "2026-01-12T15:27:29.370281Z" } }, "outputs": [], "source": [ "class Human(VGType):\n", " category = \"human\"\n", " def __init__(self, g):\n", " self.v = BloodType.void() # alway compatible\n", " self.g = g" ] }, { "cell_type": "code", "execution_count": 6, "id": "f7cbb332-3ff7-4156-8fcf-f41877e400b1", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.378417Z", "iopub.status.busy": "2026-01-12T15:27:29.377417Z", "iopub.status.idle": "2026-01-12T15:27:29.383434Z", "shell.execute_reply": "2026-01-12T15:27:29.382430Z", "shell.execute_reply.started": "2026-01-12T15:27:29.378417Z" } }, "outputs": [ { "data": { "text/plain": [ "[X/O-, X/O+, X/B-, X/B+, X/A-, X/A+, X/AB-, X/AB+]" ] }, "execution_count": 6, "metadata": {}, "output_type": "execute_result" } ], "source": [ "humans = [Human(g) for g in groups]\n", "humans" ] }, { "cell_type": "markdown", "id": "eead2b8c-db17-40a7-aef9-3942ddc9696c", "metadata": {}, "source": [ "### Blood type distribution" ] }, { "cell_type": "markdown", "id": "0173162c-7e8f-44f3-926e-b07b359af486", "metadata": {}, "source": [ "For numeric computation, we need statistics on the blood type probabilities. We leverage Wikipedia to access recent data." ] }, { "cell_type": "code", "execution_count": 7, "id": "aae163b5-45ee-4cb0-a05d-b686dccee2f3", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:29.384446Z", "iopub.status.busy": "2026-01-12T15:27:29.384446Z", "iopub.status.idle": "2026-01-12T15:27:30.631895Z", "shell.execute_reply": "2026-01-12T15:27:30.631895Z", "shell.execute_reply.started": "2026-01-12T15:27:29.384446Z" } }, "outputs": [], "source": [ "import requests\n", "import re\n", "from bs4 import BeautifulSoup as bs\n", "from fake_useragent import UserAgent\n", "ua = UserAgent()\n", "soup = bs(requests.get(\"https://en.wikipedia.org/wiki/Blood_type_distribution_by_country\", headers={'User-Agent': ua.random}).content)\n", "\n", "table = soup('table')[1]('tr')\n", "names = [s.text.strip().replace(\"−\", \"-\") for s in table[0]('th')[2:]]\n", "blood_type_by_country = dict()\n", "for line in table[1:]:\n", " country = line.th.text.strip().split('[')[0]\n", " stats = {n: float(s.text.strip()[:-1]) if s.text[0].isdigit() else 0.0 for n, s in zip(names, line('td')[1:])}\n", " total = sum(stats.values())\n", " blood_type_by_country[country] = {k: v/total for k, v in stats.items()} \n", " blood_type_by_country[country]['X'] = 1.0" ] }, { "cell_type": "code", "execution_count": 8, "id": "54ea290e-2722-427f-867b-7f7ff511b361", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:30.632900Z", "iopub.status.busy": "2026-01-12T15:27:30.632900Z", "iopub.status.idle": "2026-01-12T15:27:30.638315Z", "shell.execute_reply": "2026-01-12T15:27:30.638315Z", "shell.execute_reply.started": "2026-01-12T15:27:30.632900Z" } }, "outputs": [ { "data": { "text/plain": [ "{'O+': 0.374,\n", " 'A+': 0.35700000000000004,\n", " 'B+': 0.085,\n", " 'AB+': 0.034,\n", " 'O-': 0.066,\n", " 'A-': 0.063,\n", " 'B-': 0.015,\n", " 'AB-': 0.006,\n", " 'X': 1.0}" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "blood_type_by_country['United States']" ] }, { "cell_type": "code", "execution_count": 9, "id": "56001340-90a6-4b9e-816d-64a25d322e96", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:30.639320Z", "iopub.status.busy": "2026-01-12T15:27:30.639320Z", "iopub.status.idle": "2026-01-12T15:27:30.644580Z", "shell.execute_reply": "2026-01-12T15:27:30.644580Z", "shell.execute_reply.started": "2026-01-12T15:27:30.639320Z" } }, "outputs": [ { "data": { "text/plain": [ "{'O+': 0.365,\n", " 'A+': 0.382,\n", " 'B+': 0.077,\n", " 'AB+': 0.025,\n", " 'O-': 0.065,\n", " 'A-': 0.068,\n", " 'B-': 0.013999999999999999,\n", " 'AB-': 0.004,\n", " 'X': 1.0}" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "blood_type_by_country['France']" ] }, { "cell_type": "code", "execution_count": 10, "id": "65d30348-180a-4508-823f-f30970696912", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:30.645585Z", "iopub.status.busy": "2026-01-12T15:27:30.645585Z", "iopub.status.idle": "2026-01-12T15:27:30.649754Z", "shell.execute_reply": "2026-01-12T15:27:30.649754Z", "shell.execute_reply.started": "2026-01-12T15:27:30.645585Z" } }, "outputs": [ { "data": { "text/plain": [ "{'O+': 0.3878396121603878,\n", " 'A+': 0.2757297242702757,\n", " 'B+': 0.0818099181900818,\n", " 'AB+': 0.0201999798000202,\n", " 'O-': 0.1323098676901323,\n", " 'A-': 0.0818099181900818,\n", " 'B-': 0.0201999798000202,\n", " 'AB-': 0.00010099989900010099,\n", " 'X': 1.0}" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "blood_type_by_country['World']" ] }, { "cell_type": "markdown", "id": "1670d114-d53f-416f-b8e3-aefec6fb4a68", "metadata": {}, "source": [ "Given a list of VG types and a country, issue normalized arrival rates." ] }, { "cell_type": "code", "execution_count": 11, "id": "b33926a1-cd56-4b27-b759-7c891c1f57d6", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:30.650760Z", "iopub.status.busy": "2026-01-12T15:27:30.650760Z", "iopub.status.idle": "2026-01-12T15:27:30.656265Z", "shell.execute_reply": "2026-01-12T15:27:30.655759Z", "shell.execute_reply.started": "2026-01-12T15:27:30.650760Z" } }, "outputs": [], "source": [ "def arrival_rates(vgs, country=\"United States\"):\n", " probs = blood_type_by_country[country]\n", " res = [probs[str(vg.v)]*probs[str(vg.g)] for vg in vgs ]\n", " total = sum(res)\n", " return [r/total for r in res]" ] }, { "cell_type": "markdown", "id": "069f6607-4dd2-482b-9f0e-170d9c5c48c9", "metadata": {}, "source": [ "For the record, one can compute distribution per VG category:" ] }, { "cell_type": "code", "execution_count": 12, "id": "91c7059b-f913-4595-8218-5576831828fb", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:30.657268Z", "iopub.status.busy": "2026-01-12T15:27:30.657268Z", "iopub.status.idle": "2026-01-12T15:27:30.663331Z", "shell.execute_reply": "2026-01-12T15:27:30.663331Z", "shell.execute_reply.started": "2026-01-12T15:27:30.657268Z" } }, "outputs": [ { "data": { "text/plain": [ "{'autarkic': 56.6078, 'helpless': 28.1786, 'swinger': 15.2136}" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from collections import defaultdict\n", "shares = defaultdict(float)\n", "rates = arrival_rates(vgs)\n", "for i, vg in enumerate(vgs):\n", " shares[vg.category] += float(100*rates[i])\n", "dict(shares)" ] }, { "cell_type": "markdown", "id": "0c81aba7-689a-4894-88a7-313f5184d5cf", "metadata": {}, "source": [ "## Instability study" ] }, { "cell_type": "markdown", "id": "773494dc-126b-44b2-8187-00d0ab512e5a", "metadata": {}, "source": [ "We know that the base model is not stabilisable. Yet, we still can investigate through simulations the practical performance of robust matching algorithm in that settings.\n", "\n", "Towards that goal, we write a function that converts a list of VG types into a matching problem." ] }, { "cell_type": "code", "execution_count": 13, "id": "1d0549fc-6fb6-4717-a697-4de6adfd52aa", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:30.665601Z", "iopub.status.busy": "2026-01-12T15:27:30.664590Z", "iopub.status.idle": "2026-01-12T15:27:32.332518Z", "shell.execute_reply": "2026-01-12T15:27:32.332011Z", "shell.execute_reply.started": "2026-01-12T15:27:30.665601Z" } }, "outputs": [], "source": [ "import stochastic_matching as sm\n", "\n", "def build_model(vgs, country=\"United States\", self=True, relaxed=False, threesomes=False):\n", " edges = []\n", " n = len(vgs)\n", " for i, vg in enumerate(vgs):\n", " if relaxed or (self and vg.auto_compatible):\n", " edges.append([i])\n", " for j in range(i+1, n):\n", " if vg.compatible_with(vgs[j]):\n", " edges.append([i, j])\n", " if not threesomes:\n", " continue\n", " for k in range(j+1, n):\n", " if vg.three_compatible_with(vgs[j], vgs[k]):\n", " if any(str(p.v)==str(p.g) for p in [vg, vgs[j], vgs[k]]):\n", " continue\n", " edges.append([i, j, k])\n", " incidence = np.zeros((n, len(edges)), dtype=int)\n", " for i, e in enumerate(edges):\n", " incidence[e, i] = 1\n", " model = sm.Model(incidence=incidence, names=[str(vg) for vg in vgs], \n", " rates=arrival_rates(vgs, country=country))\n", " model.cats = [vg.category for vg in vgs]\n", " model.edges = edges\n", " try:\n", " model.adjacency = sm.model.incidence_to_adjacency(model.incidence)\n", " except ValueError:\n", " pass\n", " return model" ] }, { "cell_type": "markdown", "id": "34e0619c-661f-4f5b-83dd-77cfe1a5bf34", "metadata": {}, "source": [ "### Swingers-only" ] }, { "cell_type": "markdown", "id": "9c58b2df-3662-4e0d-91aa-832a914cb920", "metadata": {}, "source": [ "We first focus on the inter-dependent types, because it is smaller and makes sense from a selfish perspective." ] }, { "cell_type": "code", "execution_count": 14, "id": "ec596efc-7003-4171-93a9-dc195556e9a6", "metadata": { "execution": { "iopub.execute_input": "2026-01-12T15:27:32.333523Z", "iopub.status.busy": "2026-01-12T15:27:32.333523Z", "iopub.status.idle": "2026-01-12T15:27:32.345541Z", "shell.execute_reply": "2026-01-12T15:27:32.345541Z", "shell.execute_reply.started": "2026-01-12T15:27:32.333523Z" } }, "outputs": [ { "data": { "text/html": [ "\n", "