{
  "source_file": "0930",
  "source_platform": "Hashnode",
  "author": "Wesley Kambale",
  "title_listed": "Catching bugs before they catch you",
  "keywords": "",
  "domain": "机器学习",
  "content_type": "深度技术解析",
  "is_paid_flag": "否",
  "url": "https://kambale.dev/chex",
  "publish_date": "2026-07-12",
  "corpus_stage": "",
  "notes": "",
  "id": "5578770ba78b",
  "collected_at": "2026-10-02T14:11:02",
  "title": "Catching bugs before they catch you",
  "body_markdown": "Catching bugs before they catch you\nUpdated\nJuly 29, 2026\n•\n14\nmin read\n•\nView as Markdown\nW\nWesley Kambale\nI'm a Machine Learning Engineer passionate about building production-ready ML systems for the African market.\nWith experience in TensorFlow, Keras, and Python-based workflows, I help teams bridge the gap between machine learning research and real-world deployment—especially on resource-constrained devices.\nI'm also a Google Developer Expert in AI. I regularly speak at tech conferences including PyCon Africa, DevFest Kampala, DevFest Nairobi and more and also write technical articles on AI/ML here.\nPart of series\nJAX\nHere's a scenario every ML engineer eventually lives through: you kick off a training run before leaving the office. Three hours later, you check back in. The loss curve looks like a flat line at\nNaN\n. Somewhere in hour one, a single bad gradient poisoned every parameter in your model, and you didn't find out until the GPU had burned through three hours of compute for nothing.\nThis is the dark side of JAX's speed.\n@jax.jit\ncompiles your training loop into a single optimized kernel that runs on the GPU with almost no Python overhead. That's exactly what makes it fast and exactly what makes it dangerous. When something goes wrong inside a compiled function, JAX doesn't pause to tell you. It either crashes with a wall of cryptic XLA output, or it keeps running and quietly corrupts your model.\nToday we fix that.\nWe're introducing\nChex\n, a library built by DeepMind specifically for testing and validating JAX code. Chex lets you write \"contracts\" for your functions; assertions that check shapes, types, and numerical validity, and that actually work inside compiled JAX code, unlike standard Python\nassert\n.\nBy the end of this article, you'll be able to:\nUnderstand why Python's\nassert\nsilently fails inside\njax.jit\nEnforce shape and type contracts on your functions\nHunt down NaN values before they destroy a training run\nCatch runtime value errors using\nchex.chexify\nDetect expensive, silent recompilation before it wastes your compute budget\nBuild a self-validating neural network layer\nLet's become site inspectors.\nWhy standard Python assertions fail\nIn ordinary Python, NumPy, or PyTorch code, an assertion is simple:\ndef process_batch(images):\n    assert images.shape == (32, 28, 28, 1), \"Wrong image shape!\"\n    return images * 2.0\nThis works because when the function runs,\nimages\nis a real array with real values. Python can check\n.shape\nimmediately and raise an error if it doesn't match.\nNow wrap this in\n@jax.jit\n:\n@jax.jit\ndef process_batch(images):\n    assert images.shape == (32, 28, 28, 1), \"Wrong image shape!\"\n    return images * 2.0\nHere's the problem.\nWhen JAX JIT-compiles a function, it doesn't run it with real data the first time. It runs it with\nabstract tracers\n; placeholder objects that only know the\nshape\nand\ndtype\nof the eventual input, not the actual values.\nShape assertions like the one above\ncan\nwork during tracing, because shape is known statically. But the moment you try to assert something about the actual\nvalues\n, like checking whether a tensor contains NaNs, things fall apart:\n@jax.jit\ndef broken_check(x):\n    # This will crash with a ConcretizationTypeError\n    assert jnp.all(x > 0), \"Values must be positive!\"\n    return x * 2\nJAX will throw a\nConcretizationTypeError\n, essentially saying: \"I can't evaluate\nx > 0\nas a Python boolean right now, because\nx\ndoesn't have real values yet, it's just a tracer.\"\nThis is the core problem Chex solves. It gives you assertion tools that understand the difference between the tracing phase and the execution phase, so your checks work reliably in both.\nShape and type contracts\nThe single most common source of silent bugs in deep learning is the\nsilent broadcast\n.\nImagine you have model predictions of shape\n(32,)\nand labels of shape\n(32, 1)\n. If you subtract them:\npredictions = jnp.zeros((32,))\nlabels = jnp.zeros((32, 1))\n\ndiff = predictions - labels  # No error!\nprint(diff.shape)  # (32, 32) completely wrong, but silent\nNumPy and JAX both allow this \"broadcasting\" behavior. Instead of throwing an error, they expand the shapes to be compatible, quietly producing a\n(32, 32)\nmatrix from what should have been a\n(32,)\nvector. Your loss function will average this garbage, and your model will train on nonsense; with no error message anywhere.\nChex fixes this by letting you enforce exact shape contracts.\nassert_shape\nimport jax.numpy as jnp\nimport chex\n\nx = jnp.ones((32, 12))\n\n# Exact shape check\nchex.assert_shape(x, (32, 12))\n\n# Use None for dimensions that can vary (e.g., batch size)\nchex.assert_shape(x, (None, 12))\n\n# This will raise an AssertionError with a clear message\nchex.assert_shape(x, (32, 10))\nassert_rank\nSometimes you don't care about exact dimensions, just how many there are:\n# Ensure x is a 2D matrix (batch, features)\nchex.assert_rank(x, 2)\n\n# Ensure x is either 2D or 3D\nchex.assert_rank(x, (2, 3))\nassert_type\n# Ensure the array is float32, not int32 or float64\nchex.assert_type(x, jnp.float32)\nThis catches a subtle but common bug: accidentally passing integer labels where the model expects floats, which can cause silent precision issues or gradient problems.\nassert_axis_dimension\nSometimes you need to check just one specific axis:\n# Ensure axis 1 has exactly 12 elements (e.g., 12 financial features)\nchex.assert_axis_dimension(x, axis=1, expected=12)\nPutting it together\nLet's write a data validation function for a mobile money fraud detection pipeline:\nimport jax\nimport jax.numpy as jnp\nimport chex\n\n@jax.jit\ndef process_transactions(features, user_ids):\n    # Type checks\n    chex.assert_type(features, jnp.float32)\n    chex.assert_type(user_ids, jnp.int32)\n    \n    # Rank checks\n    chex.assert_rank(features, 2)   # (batch, num_features)\n    chex.assert_rank(user_ids, 1)   # (batch,)\n    \n    # Shape checks: batch dimension is flexible, feature count is fixed\n    chex.assert_shape(features, (None, 12))\n    \n    # Ensure features and user_ids have matching batch sizes\n    chex.assert_equal_shape_prefix([features, user_ids], prefix_len=1)\n    \n    return features * 0.1  # Placeholder processing\nIf someone accidentally passes a\n(64, 13)\nfeature matrix instead of\n(64, 12)\n, Chex catches it immediately during tracing, with a clear message pointing at exactly which dimension is wrong. No silent broadcasting, no three-hour debugging session later.\nThere's a second benefit here that's easy to overlook: these assertions are\ndocumentation\n. A teammate reading this function doesn't have to guess what shape\nfeatures\nshould be: the code tells them, and enforces it.\nHunting NaNs\nShape bugs are annoying. NaN bugs are catastrophic.\nA\nNaN\n(Not a Number) is viral. If one gradient in a 20-million-parameter model becomes\nNaN\n,maybe from a division by zero, a\nlog(0)\n, or an exploding gradient, that NaN spreads through the optimizer state on the very next update. Within a step or two, every parameter in your model is\nNaN\n, and there's no way to recover without rolling back to an earlier checkpoint.\nThe hard part isn't knowing\nthat\nyour model died. It's knowing\nwhen\nand\nwhy\n.\nassert_tree_all_finite\nBecause our models are built with Flax NNX, gradients live inside deeply nested pytrees; dictionaries of dictionaries containing every layer's weights. Chex gives us a way to check the entire tree in one call:\nimport chex\n\n# grads is a pytree: could be nested dicts, lists, NNX State objects, etc.\nchex.assert_tree_all_finite(grads)\nThis single line recursively walks through every leaf array in the tree and checks that none of them contain\nNaN\nor\nInf\n. If even one value out of millions is bad, it raises an error immediately.\nLet's wire this into our training step from\nweek 4\n:\nfrom flax import nnx\nimport optax\nimport chex\n\n@nnx.jit\ndef bulletproof_train_step(model, optimizer, x, y):\n    \n    def loss_fn(m):\n        logits = m(x)\n        loss = optax.softmax_cross_entropy_with_integer_labels(logits, y).mean()\n        return loss\n    \n    loss, grads = nnx.value_and_grad(loss_fn)(model)\n    \n    # The Firewall: check before we let bad gradients touch the model\n    chex.assert_tree_all_finite(grads)\n    \n    optimizer.update(model, grads)\n    return loss\nBy placing the check immediately after computing gradients and before applying them, we build a firewall. If a batch produces exploding gradients, the program crashes right then and there, with the exact batch of data still available for inspection; rather than three hours later when you notice the loss plot is flat.\nChecking intermediate activations\nYou don't have to wait until the gradient step. You can check activations inside your model's forward pass too:\nclass SafeCNN(nnx.Module):\n    def __init__(self, *, rngs: nnx.Rngs):\n        self.conv1 = nnx.Conv(1, 32, kernel_size=(3, 3), rngs=rngs)\n        self.linear = nnx.Linear(21632, 10, rngs=rngs)\n    \n    def __call__(self, x):\n        x = self.conv1(x)\n        x = nnx.relu(x)\n        \n        # Catch problems right where they happen\n        chex.assert_tree_all_finite(x)\n        \n        x = x.reshape(x.shape[0], -1)\n        return self.linear(x)\nThis is more expensive than checking only the final gradients (since it runs on every forward pass), but during development it can pinpoint exactly which layer is misbehaving.\nRuntime value checks with chexify\nShape, type, and finiteness checks all work during JAX's abstract tracing phase because they only need to know about the\nstructure\nof your data, not its actual values.\nBut what if your check genuinely depends on runtime values? For example, verifying that a custom softmax implementation never produces negative probabilities:\n@jax.jit\ndef custom_softmax(x):\n    probs = jax.nn.softmax(x)\n    \n    # This crashes with ConcretizationTypeError\n    assert jnp.all(probs >= 0), \"Probabilities cannot be negative!\"\n    \n    return probs\nAs we saw earlier, this fails because\nprobs >= 0\ncan't be evaluated as a Python boolean during tracing—there are no concrete values yet.\n@chex.chexify\nsolves this by rewriting your function so that value-based assertions are deferred and evaluated safely at actual runtime, rather than during compilation.\nimport chex\n\n# chexify goes OUTSIDE jit\n\n@chex.chexify\n@jax.jit\ndef safe_softmax(x):\n    probs = jax.nn.softmax(x)\n    \n    chex.assert_equal(\n        jnp.all(probs >= 0), \n        True, \n        custom_message=\"Negative probability detected!\"\n    )\n    \n    return probs\nThere's a subtlety in how you call a chexified function: the assertion is checked asynchronously, so you need to explicitly wait for it to complete:\nresult = safe_softmax(jnp.array([1.0, 2.0, -1.0]))\nchex.block_until_chexify_assertions_complete()\nA word of caution\nchexify\nis not free. It adds real overhead by inserting extra runtime checks into what would otherwise be pure, optimized XLA code. It's also worth noting it currently doesn't work in Colab notebooks; something to keep in mind if you're prototyping there.\nThe right mental model:\nchexify\nis scaffolding, not a permanent structure. Use it liberally during development and debugging. Strip it out (or gate it behind a debug flag) before a long production training run where every millisecond of compute matters.\nDEBUG_MODE = True\n\ndef maybe_chexify(fn):\n    return chex.chexify(fn) if DEBUG_MODE else fn\n\n@maybe_chexify\n@jax.jit\ndef train_step(...):\n    ...\nThe silent assassin: recompilation\nSo far we've covered code that crashes loudly or corrupts silently. There's a third failure mode that's sneakier: code that runs, produces correct results, but runs\nfar\nslower than it should.\nRecall from Week 1 that\n@jax.jit\ncompiles a specialized version of your function for each unique combination of input shapes and dtypes. The first time you call it with a\n(32, 28, 28, 1)\nbatch, XLA compiles a kernel for that exact shape. If you then call it with a\n(16, 28, 28, 1)\nbatch, say, because it's the last, incomplete batch of an epoch, JAX has to compile an entirely new kernel from scratch.\nCompilation isn't cheap. It can take anywhere from milliseconds to several seconds depending on the complexity of your model. If your data pipeline is silently feeding inconsistent shapes, or worse, passing plain Python lists instead of JAX arrays, which forces recompilation based on the\nvalues\nof the list, your \"fast, compiled\" training loop can secretly spend most of its time recompiling instead of training.\nThis is invisible unless you're looking for it. Your loss curve looks fine. Your code doesn't crash. It's just mysteriously, consistently slow.\nassert_max_traces\nChex gives you a tripwire for this exact problem:\nimport chex\n\n@chex.assert_max_traces(n=2)\n@nnx.jit\ndef strict_train_step(model, optimizer, batch):\n    # ... training logic ...\n    return loss\nThis decorator tracks how many times the function has triggered XLA compilation. If it's compiled more times than\nn\nallows, Chex raises an error immediately, telling you exactly what's happening: your function is recompiling more than expected.\nA typical budget:\nn=1\nfor a function that should compile exactly once, or\nn=2\nif you expect one compilation for a \"warmup\" call plus one for the real shape. Anything beyond that usually signals a bug in your data pipeline; most commonly, batches of inconsistent size.\nThe fix is almost always the same: make sure your data loader produces consistently shaped batches, typically by dropping the last incomplete batch (\ndrop_remainder=True\nin TensorFlow Datasets, or the equivalent in Grain, which we'll cover in Week 8) or by padding it to match.\n# Clear the trace counter between tests if needed\nchex.clear_trace_counter()\nThe bulletproof NNX layer\nLet's combine everything into a real, production-style component: a self-validating layer that defends itself against bad input at every stage.\nThis is the pattern you'll find in serious production ML systems; layers that don't just compute math, but actively verify their own contracts.\nfrom flax import nnx\nimport chex\nimport jax\nimport jax.numpy as jnp\n\nclass BulletproofBlock(nnx.Module):\n    \"\"\"A linear + ReLU block that validates its own inputs and outputs.\"\"\"\n    \n    def __init__(self, in_features: int, out_features: int, rngs: nnx.Rngs):\n        self.in_features = in_features\n        self.out_features = out_features\n        self.linear = nnx.Linear(in_features, out_features, rngs=rngs)\n    \n    def __call__(self, x):\n        # Input contract\n        chex.assert_type(x, jnp.float32)\n        chex.assert_rank(x, 2)\n        chex.assert_axis_dimension(x, axis=1, expected=self.in_features)\n        \n        # The actual math\n        x = self.linear(x)\n        x = nnx.relu(x)\n        \n        # Output contract\n        chex.assert_rank(x, 2)\n        chex.assert_axis_dimension(x, axis=1, expected=self.out_features)\n        chex.assert_tree_all_finite(x)\n        \n        return x\nLet's test it:\nrngs = nnx.Rngs(jax.random.PRNGKey(0))\nsafe_layer = BulletproofBlock(in_features=64, out_features=128, rngs=rngs)\n\n# Correct input: should pass\ngood_data = jnp.ones((32, 64))\noutput = safe_layer(good_data)\nprint(f\"Success! Output shape: {output.shape}\")\n\n# Wrong input: should fail with a clear error\nbad_data = jnp.ones((32, 32))  # Wrong feature dimension\n\ntry:\n    output = safe_layer(bad_data)\nexcept Exception as e:\n    print(f\"Caught the expected error:\\n{e}\")\nExpected output:\nSuccess! Output shape: (32, 128)\nCaught the expected error:\n[Chex] Assertion assert_axis_dimension failed: expected axis 1 of shape (32, 32) to be 64\nNotice what this buys you. If you're building something like a medical imaging classifier, where a shape mismatch or silent NaN could mean a wrong diagnosis slipping through unnoticed, you don't want to\nhope\nyour tensors line up. You want the architecture itself to refuse to run on bad data.\nA practical debugging workflow\nHere's how to think about applying these tools in practice, roughly in order of how expensive they are:\nStatic checks everywhere, always\n:\nassert_shape\n,\nassert_type\n,\nassert_rank\non function boundaries. Cheap, catches most bugs, safe to leave in production.\nassert_tree_all_finite\nat gradient computation\n: Place it right after\nvalue_and_grad\n, before the optimizer update. This is your NaN firewall.\nchexify\nduring development only\n: Use it to catch value-based logic errors while building a new component. Strip it before long production runs.\nassert_max_traces\non your core training step\n: Set it once you know your expected trace count, and let it catch data pipeline regressions automatically.\nnnx.display()\nfor structural sanity\n: Not a Chex tool, but pairs well—use it to visually confirm your model's shape assumptions match what your Chex assertions expect.\nExercises\nRetrofit Week 4's training loop\n: Add\nchex.assert_tree_all_finite(grads)\nright after your\nnnx.value_and_grad\ncall in the OCR training step from Week 4.\nAdd a trace budget\n: Decorate your\ntrain_step\nwith\n@chex.assert_max_traces(n=2)\n. Run training normally and confirm it passes.\nSabotage your own pipeline\n: Intentionally force your data loader to yield a batch of size 16 on the final iteration of an epoch (instead of your usual 32). Watch\nassert_max_traces\ncatch the resulting recompilation. Then fix it by dropping the incomplete batch.\nBuild a NaN generator\n: Write a tiny function that deliberately divides by zero inside a\nloss_fn\n. Confirm that\nassert_tree_all_finite\ncatches the resulting NaN gradient before it reaches the optimizer.\nQuick reference\nimport chex\nimport jax.numpy as jnp\n\n# Shape and type contracts\nchex.assert_shape(x, (None, 12))          # None = any size allowed\nchex.assert_rank(x, 2)                     # Exactly 2 dimensions\nchex.assert_type(x, jnp.float32)           # Enforce dtype\nchex.assert_axis_dimension(x, axis=1, expected=12)\nchex.assert_equal_shape_prefix([a, b], prefix_len=1)  # Matching batch dims\n\n# NaN/Inf Hunting\nchex.assert_tree_all_finite(pytree)        # Works on nested structures\n\n# Runtime value assertions\n@chex.chexify\n@jax.jit\ndef fn(x):\n    chex.assert_equal(jnp.all(x >= 0), True)\n    return x\n\nchex.block_until_chexify_assertions_complete()\n\n# Recompilation detection\n@chex.assert_max_traces(n=2)\n@jax.jit\ndef fn(x):\n    return x\n\nchex.clear_trace_counter()  # Reset between tests\n\n# Comparing PyTrees\nchex.assert_trees_all_close(tree1, tree2, rtol=1e-5)\nchex.assert_trees_all_equal(tree1, tree2)\nWhat's next\nChex catches the bugs that announce themselves loudly: wrong shapes, wrong types, exploding gradients. But some bugs are quieter; your model trains, the loss goes down, but the predictions are subtly wrong. No shape error, no NaN, just flawed logic hiding somewhere in your forward pass.\nNext week, we go deeper into the compiled black box. We'll learn how to use\njax.debug.print\nand\njax.debug.breakpoint\nto inspect values\ninside\nJIT-compiled functions, how to temporarily disable JIT for standard\npdb\ndebugging, and how to profile your code to find performance bottlenecks that Chex can't catch.\nNext week\n:\nWhen JIT hides your errors: Debugging JAX\n#\nchex\n#\njax\n#\ndebugging",
  "metadata": {
    "title": "Catching bugs before they catch you",
    "description": "Here's a scenario every ML engineer eventually lives through: you kick off a training run before leaving the office. Three hours later, you check back in. The loss curve looks like a flat line at NaN.",
    "keywords": null,
    "author": null,
    "og:title": "Catching bugs before they catch you",
    "og:description": "Here's a scenario every ML engineer eventually lives through: you kick off a training run before leaving the office. Three hours later, you check back in. The loss curve looks like a flat line at NaN.",
    "og:url": "https://kambale.dev/chex",
    "og:site_name": "Wesley Kambale",
    "og:image": "https://cdn.hashnode.com/uploads/covers/61143e31119030192497a888/6a869976-edd6-4896-bb03-3ff7a54569b5.png",
    "og:image:width": "1200",
    "og:image:height": "630",
    "og:type": "article",
    "twitter:card": "summary_large_image",
    "twitter:creator": "https://twitter.com/WesKambale",
    "twitter:title": "Catching bugs before they catch you",
    "twitter:description": "Here's a scenario every ML engineer eventually lives through: you kick off a training run before leaving the office. Three hours later, you check back in. The loss curve looks like a flat line at NaN.",
    "twitter:image": "https://cdn.hashnode.com/uploads/covers/61143e31119030192497a888/6a869976-edd6-4896-bb03-3ff7a54569b5.png",
    "article:published_time": "2026-07-12T00:36:56.247Z",
    "article:modified_time": "2026-07-29T01:00:20.404Z",
    "article:author": "https://kambale.dev/@kambale"
  },
  "paywall": {
    "listed_paid": false,
    "detected": false,
    "marker": "",
    "public_only": false,
    "policy": "付费/会员内容只保留页面公开可见部分，不绕过付费墙"
  },
  "crawl": {
    "success": true,
    "status_code": 200,
    "error": ""
  },
  "code_blocks": [
    {
      "file": "attachments/5578770ba78b/code/body-000.txt",
      "chars": 116,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-001.txt",
      "chars": 125,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-002.txt",
      "chars": 154,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-003.txt",
      "chars": 160,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-004.txt",
      "chars": 294,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-005.txt",
      "chars": 126,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-006.txt",
      "chars": 85,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-007.txt",
      "chars": 121,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-008.txt",
      "chars": 652,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-009.txt",
      "chars": 123,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-010.txt",
      "chars": 484,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-011.txt",
      "chars": 449,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-012.txt",
      "chars": 205,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-013.txt",
      "chars": 272,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-014.txt",
      "chars": 98,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-015.txt",
      "chars": 146,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-016.txt",
      "chars": 144,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-017.txt",
      "chars": 77,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-018.txt",
      "chars": 906,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-019.txt",
      "chars": 467,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-020.txt",
      "chars": 150,
      "language": "text"
    },
    {
      "file": "attachments/5578770ba78b/code/body-021.txt",
      "chars": 899,
      "language": "text"
    }
  ],
  "images": [
    {
      "status": "ok",
      "url": "https://cdn.hashnode.com/uploads/covers/61143e31119030192497a888/6a869976-edd6-4896-bb03-3ff7a54569b5.png",
      "bytes": 66681,
      "reused": true,
      "local_path": "attachments/5578770ba78b/images/000.png",
      "src": "https://cdn.hashnode.com/uploads/covers/61143e31119030192497a888/6a869976-edd6-4896-bb03-3ff7a54569b5.png",
      "alt": "Catching bugs before they catch you",
      "downloaded": true
    }
  ],
  "audio": [],
  "videos": [],
  "links": [
    {
      "href": "https://kambale.dev/chex.md",
      "label": "View as Markdown",
      "type": "a"
    },
    {
      "href": "https://hashnode.com/@kambale",
      "label": "W",
      "type": "a"
    },
    {
      "href": "https://twitter.com/WesKambale",
      "label": "",
      "type": "a"
    },
    {
      "href": "https://linkedin.com/in/WesKambale",
      "label": "",
      "type": "a"
    },
    {
      "href": "https://kambale.dev/series/jax-series",
      "label": "Part of series JAX",
      "type": "a"
    },
    {
      "href": "https://kambale.dev/optax-optimizers",
      "label": "week 4",
      "type": "a"
    },
    {
      "href": "https://kambale.dev/tag/chex",
      "label": "# chex",
      "type": "a"
    },
    {
      "href": "https://kambale.dev/tag/jax",
      "label": "# jax",
      "type": "a"
    },
    {
      "href": "https://kambale.dev/tag/debugging",
      "label": "# debugging",
      "type": "a"
    }
  ],
  "repair_version": 2,
  "repaired_at": "2026-10-14T10:50:46Z",
  "source_url": "https://kambale.dev/chex",
  "papers": [],
  "actual_video_files": [],
  "body_chars": 19083,
  "expected_body_images": 1,
  "discovery_status": "ok",
  "delivery_status": "ok",
  "missing_reason": "",
  "raw_html": "attachments/5578770ba78b/pages/source.html",
  "local_html": "attachments/5578770ba78b/pages/article.html",
  "body_base_path": "attachments/5578770ba78b/pages/",
  "body_markdown_path": "attachments/5578770ba78b/pages/article.md"
}