{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "0a6c73",
   "metadata": {},
   "source": [
    "# More ways to use Sage\n",
    "\n",
    "Five small programs from different parts of combinatorics. Each one turns a proof into code, and then uses Sage's built-in functions to check the answer.\n",
    "\n",
    "1. **Arithmetic matroids** (the `arithmat` package): balanced signed graphs.\n",
    "2. **Partitions**: Glaisher's bijection.\n",
    "3. **Designs**: Steiner triple systems.\n",
    "4. **Graph colouring**: chromatic polynomials.\n",
    "5. **Matroid connectivity**: 2-separations.\n",
    "\n",
    "Note that free CoCalc projects have no internet access to get `arithmat` and so you probably can't run Example 1. Theoretically, the `%pip install arithmat` notation works if you have money. Even better, get your own local install :) "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e3b18c",
   "metadata": {},
   "source": [
    "## 1. Balanced signed graphs and arithmetic matroids\n",
    "\n",
    "**What this example demonstrates:** loading an external package, building integer matrices, and a breadth-first search.\n",
    "\n",
    "A *signed graph* is a graph whose edges are labelled $+$ or $-$. It is *balanced* if every cycle has an even number of negative edges.\n",
    "\n",
    "**Theorem (Harary).** A signed graph is balanced if and only if its vertices can be 2-coloured so that the negative edges are exactly the edges between the two colours.\n",
    "\n",
    "`balance_certificate` returns either a 2-colouring or a cycle with an odd number of negative edges. It walks the graph breadth-first, flipping colour along each negative edge. When it tries to assign conflicting colours, the two search-tree paths to that vertex form an odd cycle.\n",
    "\n",
    "**The arithmat connection.** Map a positive edge $uv$ to the vector $e_u - e_v$ and a negative one to $e_u + e_v$. These columns define a *toric arithmetic matroid*. Each set of edges $A$ gets a multiplicity $m(A) = 2^{\\#\\text{unbalanced components of } A}$, since a single unbalanced component on $k$ vertices spans the index-2 lattice $\\{x \\in \\mathbb{Z}^k : \\sum x_i \\equiv 0 \\pmod 2\\}$. Write $\\mathcal{M}$ for the matroid of these columns over $\\mathbb{Q}$ (the *signed-graphic matroid*) and $T_{\\mathcal{M}}$ for its Tutte polynomial. This is the Tutte polynomial of $G$ itself only when $G$ is balanced. The arithmetic Tutte polynomial $M_G$ is $T_{\\mathcal{M}}$ with each term weighted by $m(A)$, so\n",
    "\n",
    "$$G \\text{ is balanced} \\iff M_G(x, y) = T_{\\mathcal{M}}(x, y).$$\n",
    "\n",
    "**References.**\n",
    "- F. Harary, On the notion of balance of a signed graph, *Michigan Math. J.* 2 (1954), 143-146. [doi:10.1307/mmj/1028989917](https://doi.org/10.1307/mmj/1028989917)\n",
    "- T. Zaslavsky, Signed graphs, *Discrete Appl. Math.* 4 (1982), 47-74. [doi:10.1016/0166-218X(82)90033-6](https://doi.org/10.1016/0166-218X(82)90033-6)\n",
    "- L. Moci, A Tutte polynomial for toric arrangements, *Trans. Amer. Math. Soc.* 364 (2012), 1067-1088. [doi:10.1090/S0002-9947-2011-05491-7](https://doi.org/10.1090/S0002-9947-2011-05491-7)\n",
    "- M. D'Adderio and L. Moci, Arithmetic matroids, the Tutte polynomial and toric arrangements, *Adv. Math.* 232 (2013), 335-367. [doi:10.1016/j.aim.2012.09.001](https://doi.org/10.1016/j.aim.2012.09.001)\n",
    "- R. Pagaria and G. Paolini, *Arithmat: SageMath implementation of arithmetic matroids and toric arrangements* (2019), <https://github.com/giove91/arithmat>.\n",
    "- R. Pagaria and G. Paolini, Representations of torsion-free arithmetic matroids, *European J. Combin.* 93 (2021), 103272. [doi:10.1016/j.ejc.2020.103272](https://doi.org/10.1016/j.ejc.2020.103272)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a69178",
   "metadata": {},
   "outputs": [],
   "source": [
    "from arithmat import ToricArithmeticMatroid\n",
    "\n",
    "def is_cycle(G, C):\n",
    "    return (C is not None and len(C) >= 3 and len(set(C)) == len(C)\n",
    "            and all(G.has_edge(C[i], C[(i + 1) % len(C)]) for i in range(len(C))))\n",
    "\n",
    "def is_negative(u, v, negative):\n",
    "    return (u, v) in negative or (v, u) in negative\n",
    "\n",
    "def toric_matroid(G, negative):\n",
    "    # e_u - e_v for a positive edge, e_u + e_v for a negative one.\n",
    "    V = G.vertices(sort=True)\n",
    "    E = G.edges(sort=True, labels=False)\n",
    "    A = matrix(ZZ, len(V), len(E))\n",
    "    for j, (u, v) in enumerate(E):\n",
    "        A[V.index(u), j] = 1\n",
    "        A[V.index(v), j] = 1 if is_negative(u, v, negative) else -1\n",
    "    return ToricArithmeticMatroid(A, ordered_groundset=E)\n",
    "\n",
    "def balance_certificate(G, negative):\n",
    "    # (\"balanced\", colouring) or (\"unbalanced\", odd cycle).\n",
    "    colour, parent = {}, {}\n",
    "    for root in G.vertices(sort=True):\n",
    "        if root in colour:\n",
    "            continue\n",
    "        colour[root], parent[root] = 0, None\n",
    "        queue = [root]\n",
    "        while queue:\n",
    "            v = queue.pop(0)\n",
    "            for u in G.neighbors(v):\n",
    "                want = colour[v] ^^ int(is_negative(u, v, negative))   # ^^ is XOR in Sage\n",
    "                if u not in colour:\n",
    "                    colour[u], parent[u] = want, v\n",
    "                    queue.append(u)\n",
    "                elif colour[u] != want:\n",
    "                    return \"unbalanced\", odd_cycle(parent, u, v)\n",
    "    return \"balanced\", colour\n",
    "\n",
    "def odd_cycle(parent, u, v):\n",
    "    # The search-tree paths from u and v up to where they meet, closed by uv.\n",
    "    def path_to_root(x):\n",
    "        path = []\n",
    "        while x is not None:\n",
    "            path.append(x)\n",
    "            x = parent[x]\n",
    "        return path\n",
    "    pu, pv = path_to_root(u), path_to_root(v)\n",
    "    on_pv = set(pv)\n",
    "    top = next(x for x in pu if x in on_pv)\n",
    "    return pu[:pu.index(top) + 1] + pv[:pv.index(top)][::-1]\n",
    "\n",
    "def negative_count(C, negative):\n",
    "    return sum(is_negative(C[i], C[(i + 1) % len(C)], negative) for i in range(len(C)))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "42f643",
   "metadata": {},
   "source": [
    "Example: an (unbalanced) 4-cycle with a single negative edge."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "7af59e",
   "metadata": {},
   "outputs": [],
   "source": [
    "G = graphs.CycleGraph(4)\n",
    "negative = {(0, 1)}\n",
    "M = toric_matroid(G, negative)\n",
    "print(M.arrangement_matrix())\n",
    "print()\n",
    "print(\"certificate:      \", balance_certificate(G, negative))\n",
    "print(\"Tutte:            \", M.tutte_polynomial())\n",
    "print(\"arithmetic Tutte: \", M.arithmetic_tutte_polynomial())\n",
    "print(\"m(all edges):     \", M.multiplicity(M.groundset()))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "47453f",
   "metadata": {},
   "outputs": [],
   "source": [
    "for name, G, negative in [\n",
    "        (\"C4, two negative\",       graphs.CycleGraph(4),    {(0, 1), (1, 2)}),\n",
    "        (\"C4, one negative\",       graphs.CycleGraph(4),    {(0, 1)}),\n",
    "        (\"K4, star negative\",      graphs.CompleteGraph(4), {(0, 1), (0, 2), (0, 3)}),\n",
    "        (\"K4, one negative\",       graphs.CompleteGraph(4), {(0, 1)}),\n",
    "        (\"Petersen, one negative\", graphs.PetersenGraph(),  {(0, 1)})]:\n",
    "    verdict, cert = balance_certificate(G, negative)\n",
    "    if verdict == \"balanced\":\n",
    "        ok = all((cert[u] != cert[v]) == is_negative(u, v, negative)\n",
    "                 for u, v in G.edges(labels=False))\n",
    "    else:\n",
    "        ok = is_cycle(G, cert) and negative_count(cert, negative) % 2 == 1\n",
    "    line = f\"{name:24} {verdict:11} certificate {'ok' if ok else 'WRONG'}\"\n",
    "    if G.size() <= 12:   # arithmat checks every subset of edges, so keep graphs small\n",
    "        M = toric_matroid(G, negative)\n",
    "        same = M.arithmetic_tutte_polynomial() == M.tutte_polynomial()\n",
    "        line += f\"   arithmat agrees: {same == (verdict == 'balanced')}\"\n",
    "    print(line)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77f876",
   "metadata": {},
   "source": [
    "## 2. Glaisher's bijection\n",
    "\n",
    "**What this example demonstrates:** `Partitions` with constraints, and power series rings.\n",
    "\n",
    "**Theorem (Euler).** The number of partitions of $n$ into odd parts equals the number of partitions of $n$ into distinct parts.\n",
    "\n",
    "**Bijective proof (Glaisher).** Write each distinct part as $2^k m$ with $m$ odd, and split it into $2^k$ copies of $m$. To go back, write the number of copies of each odd part in binary and merge accordingly.\n",
    "\n",
    "**Generating function proof.**\n",
    "$$\\prod_{k \\geq 1} (1 + q^k) = \\prod_{k \\geq 1} \\frac{1 - q^{2k}}{1 - q^k} = \\prod_{k \\geq 1} \\frac{1}{1 - q^{2k-1}}.$$\n",
    "\n",
    "**References.**\n",
    "- L. Euler, *Introductio in analysin infinitorum*, vol. 1, Lausanne, 1748, ch. 16.\n",
    "- J. W. L. Glaisher, A theorem in partitions, *Messenger of Math.* 12 (1882), 158-170."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "a04200",
   "metadata": {},
   "outputs": [],
   "source": [
    "def glaisher(p):\n",
    "    # Distinct parts to odd parts.\n",
    "    parts = []\n",
    "    for a in p:\n",
    "        k = a.valuation(2)            # the largest k with 2^k dividing a\n",
    "        parts += [a // 2^k] * 2^k\n",
    "    return Partition(sorted(parts, reverse=True))\n",
    "\n",
    "def glaisher_inverse(q):\n",
    "    # Odd parts to distinct parts.\n",
    "    parts = []\n",
    "    for m in set(q):\n",
    "        count = ZZ(list(q).count(m))\n",
    "        parts += [m * 2^k for k, bit in enumerate(count.digits(2)) if bit]\n",
    "    return Partition(sorted(parts, reverse=True))\n",
    "\n",
    "def euler_by_generating_functions(N):\n",
    "    R.<q> = PowerSeriesRing(ZZ, default_prec=N)\n",
    "    distinct = prod(1 + q^k for k in range(1, N))\n",
    "    odd = prod(1 / (1 - q^k) for k in range(1, N, 2))\n",
    "    return distinct == odd"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d6a226",
   "metadata": {},
   "outputs": [],
   "source": [
    "p = Partition([12, 5, 2])\n",
    "print(p, \"->\", glaisher(p), \"->\", glaisher_inverse(glaisher(p)))\n",
    "\n",
    "for n in range(1, 21):\n",
    "    distinct = Partitions(n, max_slope=-1).list()                    # each part smaller than the last\n",
    "    odd = Partitions(n, parts_in=list(range(1, n + 1, 2))).list()    # only odd parts allowed\n",
    "    assert {glaisher(p) for p in distinct} == set(odd), n\n",
    "    assert all(glaisher_inverse(glaisher(p)) == p for p in distinct), n\n",
    "print(\"bijection for n <= 20: ok\")\n",
    "print(\"generating functions agree to q^30:\", euler_by_generating_functions(30))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "74c975",
   "metadata": {},
   "source": [
    "## 3. Steiner triple systems\n",
    "\n",
    "**What this example demonstrates:** the integers mod $m$, and the `IncidenceStructure` class from design theory.\n",
    "\n",
    "A *Steiner triple system* STS$(n)$ is a collection of 3-element subsets (triples) of an $n$-element set such that every pair of points lies in exactly one triple. Counting pairs, it has $n(n-1)/6$ triples.\n",
    "\n",
    "**Theorem (Kirkman).** An STS$(n)$ exists if and only if $n \\equiv 1$ or $3 \\pmod 6$.\n",
    "\n",
    "**Bose's construction** for $n = 6k + 3$. Let $m = 2k + 1$ and take points $\\mathbb{Z}_m \\times \\mathbb{Z}_3$. The triples are\n",
    "- $\\{(x, 0), (x, 1), (x, 2)\\}$ for each $x$, and\n",
    "- $\\{(x, i), (y, i), (2^{-1}(x + y), i + 1)\\}$ for each $x < y$ and each $i$.\n",
    "\n",
    "**References.**\n",
    "- T. P. Kirkman, On a problem in combinations, *Cambridge and Dublin Math. J.* 2 (1847), 191-204.\n",
    "- R. C. Bose, On the construction of balanced incomplete block designs, *Ann. Eugenics* 9 (1939), 353-399. [doi:10.1111/j.1469-1809.1939.tb02219.x](https://doi.org/10.1111/j.1469-1809.1939.tb02219.x)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "596816",
   "metadata": {},
   "outputs": [],
   "source": [
    "def bose_sts(n):\n",
    "    if n % 6 != 3:\n",
    "        raise ValueError(\"Bose's construction needs n = 3 mod 6\")\n",
    "    m = n // 3\n",
    "    Zm = Integers(m)\n",
    "    half = ~Zm(2)                     # the inverse of 2 mod m\n",
    "    point = lambda x, i: (ZZ(x), i % 3)\n",
    "    triples = [[point(x, 0), point(x, 1), point(x, 2)] for x in range(m)]\n",
    "    for x in range(m):\n",
    "        for y in range(x + 1, m):\n",
    "            z = (Zm(x) + Zm(y)) * half\n",
    "            for i in range(3):\n",
    "                triples.append([point(x, i), point(y, i), point(z, i + 1)])\n",
    "    return IncidenceStructure(triples)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2ff01e",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(bose_sts(9).blocks())\n",
    "print()\n",
    "for n in [3, 9, 15, 21, 27, 33]:\n",
    "    STS = bose_sts(n)\n",
    "    ok = STS.is_t_design(2, n, 3, 1) and len(STS.blocks()) == n * (n - 1) // 6\n",
    "    print(f\"STS({n}): {len(STS.blocks())} triples  {'ok' if ok else 'WRONG'}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3981d8",
   "metadata": {},
   "source": [
    "## 4. Chromatic polynomials\n",
    "\n",
    "**What this example demonstrates:** polynomial rings, copying and modifying graphs, and recursion.\n",
    "\n",
    "The *chromatic polynomial* $P(G, k)$ counts the proper colourings of $G$ with $k$ colours.\n",
    "\n",
    "**Theorem (deletion-contraction).** For any edge $e$, $P(G, k) = P(G - e, k) - P(G / e, k)$, and an edgeless graph on $n$ vertices has $P = k^n$.\n",
    "\n",
    "**Proof.** A colouring of $G - e$ either gives the ends of $e$ different colours, which is a proper colouring of $G$, or the same colour, which is a proper colouring of $G / e$. $\\square$\n",
    "\n",
    "Contracting can create parallel edges. They don't change $P$, so the contracted graph is kept simple. Two more checks: a brute-force count, and the Tutte specialisation $P(G, k) = (-1)^{n-c} k^c \\, T_G(1-k, 0)$, where $c$ is the number of components.\n",
    "\n",
    "**References.**\n",
    "- G. D. Birkhoff, A determinant formula for the number of ways of coloring a map, *Ann. of Math.* 14 (1912), 42-46. [doi:10.2307/1967597](https://doi.org/10.2307/1967597)\n",
    "- H. Whitney, The coloring of graphs, *Ann. of Math.* 33 (1932), 688-718. [doi:10.2307/1968214](https://doi.org/10.2307/1968214)\n",
    "- W. T. Tutte, A contribution to the theory of chromatic polynomials, *Canad. J. Math.* 6 (1954), 80-91. [doi:10.4153/CJM-1954-010-9](https://doi.org/10.4153/CJM-1954-010-9)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6c656e",
   "metadata": {},
   "outputs": [],
   "source": [
    "R.<k> = ZZ[]\n",
    "\n",
    "def chrom_poly(G):\n",
    "    if G.size() == 0:\n",
    "        return k^G.order()\n",
    "    u, v = G.edges(sort=True, labels=False)[0]\n",
    "    deleted = G.copy()\n",
    "    deleted.delete_edge(u, v)\n",
    "    contracted = deleted.copy()\n",
    "    contracted.merge_vertices([u, v])     # u and v become one vertex\n",
    "    return chrom_poly(deleted) - chrom_poly(contracted)\n",
    "\n",
    "def count_colourings(G, colours):\n",
    "    # Brute force: try every assignment of colours to vertices.\n",
    "    V = G.vertices(sort=True)\n",
    "    E = G.edges(sort=True, labels=False)\n",
    "    count = 0\n",
    "    for c in itertools.product(range(colours), repeat=len(V)):\n",
    "        colour = dict(zip(V, c))\n",
    "        count += all(colour[a] != colour[b] for a, b in E)\n",
    "    return count\n",
    "\n",
    "def chrom_from_tutte(G):\n",
    "    n, c = G.order(), G.connected_components_number()\n",
    "    T = G.tutte_polynomial()\n",
    "    return (-1)^(n - c) * k^c * R(T(1 - k, 0))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6cf744",
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"Petersen:\", chrom_poly(graphs.PetersenGraph()).factor())\n",
    "print()\n",
    "for name, G in [(\"Petersen\",      graphs.PetersenGraph()),\n",
    "                (\"C5\",            graphs.CycleGraph(5)),\n",
    "                (\"K4\",            graphs.CompleteGraph(4)),\n",
    "                (\"random tree\",   graphs.RandomTree(8)),\n",
    "                (\"two triangles\", Graph([(0, 1), (1, 2), (0, 2), (3, 4), (4, 5), (3, 5)]))]:\n",
    "    P = chrom_poly(G)\n",
    "    ok = P == R(G.chromatic_polynomial()) and P == chrom_from_tutte(G)\n",
    "    if G.order() <= 8:\n",
    "        ok = ok and all(P(c) == count_colourings(G, c) for c in range(4))\n",
    "    chi = next(c for c in range(G.order() + 1) if P(c) > 0)   # fewest colours that work\n",
    "    ok = ok and chi == G.chromatic_number()\n",
    "    print(f\"{name:14} P(3) = {P(3):4}   chromatic number {chi}   {'ok' if ok else 'WRONG'}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b3aa35",
   "metadata": {},
   "source": [
    "## 5. Matroid connectivity\n",
    "\n",
    "**What this example demonstrates:** the `matroids` library, rank functions, and searching over subsets.\n",
    "\n",
    "Let $M$ be a matroid on ground set $E$ with rank function $r$. A *$j$-separation* is a partition $E = X \\sqcup Y$ with $|X|, |Y| \\geq j$ and\n",
    "$$r(X) + r(Y) - r(M) \\leq j - 1.$$\n",
    "$M$ is *connected* if it has no 1-separation, and *3-connected* if it has no 1- or 2-separation.\n",
    "\n",
    "`small_separation` searches all subsets and returns the smallest separation it finds, or reports that $M$ is 3-connected. That is a certificate either way, which Sage's `is_3connected` and `connectivity` then check.\n",
    "\n",
    "**Theorem (Tutte).** A simple graph with at least 4 vertices and no isolated vertices is 3-connected if and only if its cycle matroid is 3-connected.\n",
    "\n",
    "**References.**\n",
    "- W. T. Tutte, Connectivity in matroids, *Canad. J. Math.* 18 (1966), 1301-1324. [doi:10.4153/CJM-1966-129-2](https://doi.org/10.4153/CJM-1966-129-2)\n",
    "- J. Oxley, *Matroid Theory*, 2nd ed., Oxford University Press, 2011, Proposition 8.1.9."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8e571d",
   "metadata": {},
   "outputs": [],
   "source": [
    "def small_separation(M):\n",
    "    # (j, X, Y) for a j-separation with j = 1 or 2 as small as possible, else \"3-connected\".\n",
    "    E = M.groundset()\n",
    "    for j in (1, 2):\n",
    "        for size in range(j, len(E) // 2 + 1):\n",
    "            for X in itertools.combinations(sorted(E), size):\n",
    "                X = frozenset(X)\n",
    "                Y = E - X\n",
    "                if len(Y) >= j and M.rank(X) + M.rank(Y) - M.rank() <= j - 1:\n",
    "                    return j, X, Y\n",
    "    return \"3-connected\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "909efb",
   "metadata": {},
   "outputs": [],
   "source": [
    "theta = Graph([(0, 1), (1, 2), (2, 3), (3, 0), (1, 4), (4, 5), (5, 2)])   # two 4-cycles sharing an edge\n",
    "for name, M in [(\"Wheel(4)\",           matroids.Wheel(4)),\n",
    "                (\"M(K4)\",              Matroid(graph=graphs.CompleteGraph(4))),\n",
    "                (\"M(C5)\",              Matroid(graph=graphs.CycleGraph(5))),\n",
    "                (\"two C4s on an edge\", Matroid(graph=theta)),\n",
    "                (\"two triangles\",      Matroid(graph=Graph([(0, 1), (1, 2), (0, 2),\n",
    "                                                            (3, 4), (4, 5), (3, 5)])))]:\n",
    "    result = small_separation(M)\n",
    "    if result == \"3-connected\":\n",
    "        ok = M.is_3connected()\n",
    "        print(f\"{name:20} 3-connected   {'ok' if ok else 'WRONG'}\")\n",
    "    else:\n",
    "        j, X, Y = result\n",
    "        ok = (M.connectivity(X) == j - 1 and min(len(X), len(Y)) >= j\n",
    "              and not M.is_3connected() and M.is_connected() == (j == 2))\n",
    "        print(f\"{name:20} {j}-separation  X = {sorted(X)}   {'ok' if ok else 'WRONG'}\")\n",
    "\n",
    "agree = 0\n",
    "for trial in range(30):\n",
    "    G = graphs.RandomGNP(7, 0.6)\n",
    "    while min(G.degree()) == 0:\n",
    "        G = graphs.RandomGNP(7, 0.6)\n",
    "    agree += G.is_triconnected() == (small_separation(Matroid(graph=G)) == \"3-connected\")\n",
    "print(f\"\\nWhitney on 30 random graphs: {agree}/30 agree\")"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "SageMath 10.10",
   "language": "sage",
   "name": "sagemath"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.14.8"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
