Skip to content

Tech

Running Whisper on GPU in Rust with ONNX and DirectML

Local AI is experiencing a golden age. Running multi-billion parameter language models, vision decoders, and automatic speech recognition (ASR) pipelines locally on consumer hardware has shifted from an exotic science experiment to a standard feature in modern desktop software.

If you are developing for Windows in Rust, however, there is a painful rite of passage awaiting you the moment you attempt to turn on GPU acceleration: the C++ wrapper and CUDA build nightmare.

Whether you are binding to whisper.cpp, llama.cpp, or stable-diffusion.cpp, the story is always the same. What begins as a clean cargo build rapidly spirals into a labyrinth of external CMake scripts, MSVC toolchain conflicts, Clang environment flags (LIBCLANG_PATH), and gigabytes of NVIDIA CUDA SDKs. And even if you conquer your local build environment, you face an insurmountable distribution wall: your end-users cannot be expected to install 3 GB of CUDA runtimes, and anyone rocking an AMD Radeon or Intel Arc GPU is abruptly locked out.

It does not have to be this way.

By bypassing the fragile C++ FFI layers and coupling ONNX Runtime (ort) with Microsoft's DirectML execution provider, we can achieve high-performance, vendor-agnostic DirectX 12 GPU acceleration in pure Rust. Here is how it works, the architectural pipeline for models like Whisper, and the hard-earned production lessons you only uncover after shipping.


1. The Desktop AI Dilemma: The C++ Toolchain Trap

When building native desktop applications - such as audio/video recorders, local voice assistants, media transcribers, or any others - shipping AI inference requires three non-negotiables:

  1. Zero-friction installation: The user runs an installer or unzips a binary; the feature must just work.
  2. Deterministic compilation: The continuous integration (CI) pipeline and developer machines shouldn't break when a path variable moves.
  3. Universal hardware acceleration: Hardware is diverse. Alienating AMD, Intel, and Qualcomm users is unacceptable.

The dominant approach in the open-source ecosystem has been writing Rust FFI wrappers around popular C/C++ inference engines (such as whisper-rs wrapping whisper.cpp). While these libraries are heroic technical feats, packaging them on Windows exposes the fundamental friction between the Rust and C++ ecosystems.

The Build Matrix from Hell

To compile a native C++ ML engine with CUDA support on Windows, your build instructions often resemble this:

1. Install Visual Studio Build Tools with C++ and Clang components.
2. Install CMake 3.26+ and verify it is in your system PATH.
3. Download and install the NVIDIA CUDA Toolkit (3.5 GB).
4. Run `where.exe clang` and configure `LIBCLANG_PATH` pointing to the exact MSVC Clang bin.
5. In your shell, restart environment variables and invoke `cargo build --features cuda`.

If the user has MinGW/MSYS2 in their PATH, CMake might pick up gcc instead of cl.exe. If LIBCLANG_PATH points to an x86 directory instead of x64, bindgen explodes with hundreds of missing type definitions.

Worse, CUDA is proprietary to NVIDIA. If your desktop app has users running AMD Radeon graphics, Intel Iris Xe / Arc, or modern Qualcomm Snapdragon X Elite chips, CUDA is useless. You are either forced to maintain separate OpenCL / Vulkan / ROCm backends through convoluted C++ build flags, or condemn those users to slow CPU threads that pin all cores and drain laptop batteries.


2. Enter DirectML: True Vendor-Agnostic GPU Acceleration

Microsoft introduced DirectML as a low-level, high-performance DirectX 12 library specifically designed for machine learning. Instead of writing custom compute shaders for every linear algebra operation or binding to vendor-specific APIs, DirectML abstracts tensor operations across the entire DirectX 12 ecosystem.

graph TD
    A["Rust Application Logic"] --> B["ort (ONNX Runtime Rust Bindings)"]
    B --> C["DirectML Execution Provider"]
    C --> D["Direct3D 12 Compute"]
    D --> E["NVIDIA GeForce"]
    D --> F["AMD Radeon"]
    D --> G["Intel Arc / Iris"]

Why DirectML Wins on Windows

  • Universal Hardware Support: Any GPU with DirectX 12 Feature Level 11_0 or higher is supported out of the box. This includes NVIDIA, AMD, Intel, and Qualcomm Adreno/NPUs.
  • Zero Driver/SDK Installs for Users: DirectML communicates directly with standard Windows WDDM display drivers. End-users do not need to install CUDA, cuDNN, or developer toolkits.
  • Trivial Redistribution: You don't need a sprawling C++ compiler toolchain. You simply bundle two DLLs alongside your Rust application binary: onnxruntime.dll and DirectML.dll.

3. The Rust Setup: ort + DirectML

In the Rust ecosystem, the ort crate (v2.0+) provides safe, ergonomic, and blazingly fast bindings to ONNX Runtime.

Cargo.toml Dependencies

[dependencies]
ort = { version = "2.0.0-rc.13", features = ["directml"] }
ndarray = "0.17"
tokenizers = "0.23"
anyhow = "1.0"
tracing = "0.1"

When building with the directml feature, ort links against the ONNX Runtime DirectML binaries. In production, you place onnxruntime.dll and DirectML.dll right next to your final executable in target/release/ or your installer package.

The Dual-GPU Trap: Adapter Enumeration

One of the most insidious bugs encountered when deploying DirectML on Windows laptops is the default adapter selection.

Most modern laptops have two GPUs: 1. An integrated GPU (iGPU) built into the CPU (e.g., Intel UHD Graphics or AMD Radeon 780M). 2. A discrete GPU (dGPU) designed for heavy compute (e.g., NVIDIA RTX 4070).

By default, DirectML queries the DXGI adapter list and selects device index 0. On many Windows laptops, device 0 is the power-saving integrated GPU! If you initialize your session blindly, your app will run on the weak iGPU, causing slow inference and heating up the CPU die, while the high-powered discrete GPU sits completely idle.

To prevent this, explicitly configure the DirectMLExecutionProvider in Rust:

use anyhow::{anyhow, Result};
use ort::execution_providers::DirectMLExecutionProvider;
use ort::session::Session;
use std::path::Path;
use tracing::info;

pub fn create_directml_session(model_path: &Path, preferred_device_id: i32) -> Result<Session> {
    info!("Initializing DirectML Execution Provider on GPU device {}", preferred_device_id);

    // Explicitly target the discrete adapter ID (0 or 1 depending on DXGI enum)
    let directml_ep = DirectMLExecutionProvider::default()
        .with_device_id(preferred_device_id);

    let session = Session::builder()?
        .with_execution_providers([directml_ep])?
        .commit_from_file(model_path)
        .map_err(|e| anyhow!("Failed to load ONNX model via DirectML: {:?}", e))?;

    info!("DirectML session successfully initialized on GPU");
    Ok(session)
}

4. The End-to-End Whisper Pipeline in Rust

Whisper is an encoder-decoder transformer architecture. Unlike standard text embeddings, audio models require strict mathematical preprocessing before any tensor reaches the GPU.

graph TD
    A["Raw Audio: MP4 / WAV / MKV"] -->|"Resampled via ffmpeg-next / hound"| B["16 kHz Mono Float32 PCM"]
    B -->|"FFT: Hann window, 400 frame, 160 hop"| C["Log-Mel Spectrogram (80 bins)"]
    C -->|"30-second fixed window"| D["ndarray::Array3 [1, 80, 3000]"]
    D --> E["ort::Session (DirectML GPU)"]
    E --> F["Token IDs"]
    F -->|"tokenizers BPE Decode"| G["Final Transcript Text"]

Step 1: Preprocessing & Mel-Spectrogram Extraction

Whisper expects audio formatted strictly as 16,000 Hz, single-channel (mono), 32-bit floating-point PCM between [-1.0, 1.0].

From these raw samples, we compute an 80-channel (or 128-channel for Whisper large-v3) log-mel spectrogram using:

  • A window size of 400 samples (25 ms) with a Hann window function.
  • A hop length of 160 samples (10 ms).
  • Triangular mel filterbank matrices mapping the FFT bins onto auditory frequencies.
  • Logarithmic scaling: \(S = \log_{10}(\max(\text{mel_spectrum}, 10^{-5}))\).

The result is shaped into an ndarray::Array3<f32> with dimensions [batch_size, n_mels, n_frames]—specifically [1, 80, 3000] for a 30-second audio slice.

Step 2: Feeding DirectML Tensors in Rust

With the ort crate, converting an ndarray slice into an ONNX GPU tensor and executing the model is clean and expressive:

use anyhow::Result;
use ndarray::Array3;
use ort::session::Session;
use ort::value::Tensor;

pub fn run_whisper_inference(
    session: &mut Session,
    mel_spectrogram: &Array3<f32>,
) -> Result<Vec<i64>> {
    // 1. Create a zero-copy tensor view from the contiguous ndarray buffer
    let input_tensor = Tensor::from_array(session.allocator(), mel_spectrogram)?;

    // 2. Dispatch inference through DirectML to the DirectX 12 hardware queue
    let outputs = session.run(ort::inputs![
        "input_features" => input_tensor
    ]?)?;

    // 3. Extract the generated token sequences from the output graph
    let output_tensor = outputs["sequences"].extract_tensor::<i64>()?;
    let token_ids: Vec<i64> = output_tensor.view().iter().copied().collect();

    Ok(token_ids)
}

Step 3: Decoding with Pure Rust Tokenizers

Instead of shelling out to Python or linking C++ text decoders, we use Hugging Face’s official tokenizers crate in Rust:

use tokenizers::Tokenizer;

pub fn decode_tokens(tokenizer: &Tokenizer, tokens: &[u32]) -> String {
    tokenizer
        .decode(tokens, true)
        .unwrap_or_else(|_| String::from("[Decode Error]"))
}

5. Battle-Hardened Production Gotchas

When you transition from a prototype to a real desktop application, you quickly encounter quirks that benchmark tutorials never mention.

1. The Direct3D 12 Shader Warmup Hitch

DirectML does not use pre-compiled machine-code binaries for every possible GPU architecture like CUDA does with PTX/SASS. Instead, DirectML compiles HLSL compute shaders into DirectX 12 Pipeline State Objects (PSOs) at runtime, tailored specifically to your exact GPU driver and tensor input shapes.

  • The Problem: The very first time session.run() is called, DirectML pauses to compile all operator shaders. On mid-range hardware, this first-run warmup can take 2 to 5 seconds. If this happens on the UI thread when the user clicks "Start Transcribing", the application appears frozen.
  • The Production Fix: Pre-warm the session during startup or model loading on a background thread with a synthetic dummy tensor of zeros:
pub fn warmup_session(session: &mut Session) -> Result<()> {
    let dummy_mel = ndarray::Array3::<f32>::zeros((1, 80, 3000));
    let dummy_tensor = Tensor::from_array(session.allocator(), &dummy_mel)?;

    // Executes shader compilation in the background before the user touches anything
    let _ = session.run(ort::inputs!["input_features" => dummy_tensor]?);
    Ok(())
}

Once the PSOs are compiled and cached in DirectX memory, all subsequent inference calls run at full hardware speed with zero hitching.

2. Fixed vs. Dynamic Tensor Shapes

DirectML optimizes shaders aggressively around fixed buffer strides. If you pass variable-length audio chunks (e.g. 3.4 seconds, then 11.2 seconds, then 24.1 seconds), DirectML may be forced to recompile or switch shader pipelines constantly.

Always pad your mel-spectrogram arrays to the constant 30-second window (3000 frames). Memory allocation on modern GPUs is cheap; shader recompilation churn is not.

3. The Anti-Hallucination Shield: Silence & Voice Activity Detection (VAD)

Whisper’s autoregressive decoder has a notorious Achilles' heel: ambient silence.

When fed audio containing long pauses, room reverb, or microphone hiss with no actual speech, the model’s attention mechanisms wander. It begins hallucinating phrases like "Thank you for watching", "Please subscribe", or getting trapped in infinite repetition loops:

[00:01:14] ...
[00:01:17] Thank you.
[00:01:20] Thank you.
[00:01:23] Thank you.
[00:01:26] Thank you very much.

In a production desktop recorder or transcription tool, you cannot feed raw, unsegmented audio streams directly into Whisper. You need an energy-based Voice Activity Detection (VAD) stage prior to inference:

/// Scans audio samples to detect regions with speech activity above an RMS threshold
pub fn detect_speech_regions(samples: &[f32]) -> Vec<(usize, usize)> {
    const SAMPLE_RATE: usize = 16000;
    const FRAME_MS: usize = 30;
    const FRAME_SIZE: usize = SAMPLE_RATE * FRAME_MS / 1000; // 480 samples
    const ENERGY_THRESHOLD: f32 = 0.008;
    const MIN_SPEECH_DURATION_MS: usize = 250;
    const SILENCE_PADDING_MS: usize = 300;

    let mut regions = Vec::new();
    let mut in_speech = false;
    let mut speech_start = 0;
    let mut silence_frames = 0;

    for (i, frame) in samples.chunks(FRAME_SIZE).enumerate() {
        let rms = (frame.iter().map(|s| s * s).sum::<f32>() / frame.len() as f32).sqrt();

        if rms >= ENERGY_THRESHOLD {
            if !in_speech {
                in_speech = true;
                speech_start = i * FRAME_SIZE;
            }
            silence_frames = 0;
        } else if in_speech {
            silence_frames += 1;
            // End segment after sustained silence
            if silence_frames * FRAME_MS > SILENCE_PADDING_MS {
                in_speech = false;
                let speech_end = (i * FRAME_SIZE).min(samples.len());
                if (speech_end - speech_start) * 1000 / SAMPLE_RATE >= MIN_SPEECH_DURATION_MS {
                    regions.push((speech_start, speech_end));
                }
            }
        }
    }

    regions
}

By filtering out silence intervals before calling the ONNX session:

  1. Hallucinations drop to zero: Whisper only ever receives actual vocal harmonics.
  2. Inference time plummets: A 60-minute conference recording with 20 minutes of silence runs in 33% less GPU time.
  3. Multi-Track Dialogue Alignment: In a multi-party call (e.g. microphone track vs remote system audio), each track's speech regions can be processed independently and chronologically merged into a natural dialogue transcript:
    [00:00:02] [Host]: Good morning everyone, let's review the architecture.
    [00:00:06] [Guest]: Thanks for having me, can everyone see my slides?
    

6. The Verdict

Metric whisper.cpp / whisper-rs (CUDA) ort + DirectML (Rust)
Toolchain Dependencies MSVC + CMake + Clang + CUDA SDK Standard cargo build
GPU Vendor Support NVIDIA GeForce / RTX only NVIDIA, AMD, Intel, Qualcomm
End-User Prerequisites Matching NVIDIA Display Drivers & CUDA DLLs Standard Windows DirectX 12 drivers
Redistribution Size Varies; complex runtime DLL matrix Just onnxruntime.dll + DirectML.dll
Relative Throughput Baseline (1.0x) ~0.85x – 0.95x of native CUDA
Developer Sanity Low (fragile C++ FFI & environment flags) High (pure Rust crate ecosystem)

Is native CUDA slightly faster on a top-tier RTX 4090? Yes, by approximately 5% to 15% in raw compute throughput.

But in desktop engineering, portability and deployment simplicity trumps microscopic benchmark wins every day of the week. DirectML lets you ship a single Rust binary that boots instantly, accelerates seamlessly on whatever GPU the user possesses, and never breaks because an environmental variable was missing on a CI worker.

If you are shipping local AI models in Rust on Windows today, step out of the CUDA build trap. DirectML is the pragmatic path forward.


Share on Share on Share on

Twitch-TTS 1.0.5

Nowa wersja programu Twitch-TTS 1.0.5.

Do pobrania tutaj.

Zmiany

1.0.5

  • Edycja aliasów fonetycznych
  • Nowa opcja filtrowania !komend

1.0.4

  • Naprawione ustawienie maksymalnej długości wiadomości
  • Naprawione ustawienie maksymalnej ilości powtórzeń litery w słowie
  • Naprawione ustawienie SpeakerID

1.0.3

  • Plik konfiguracyjny zapisywany jest teraz w katalogu $HOME/.twitch-tts/config.yaml
  • Dodane ustawienie głośności audio
  • Nowe domyślne aliasy fonetyczne dla słów ok oraz stream

Share on Share on Share on

Why Your Daily Standup Is Broken (And How to Fix It)

It is 9:30 AM. Eight developers join a video call or gather around a whiteboard. One by one, they take turns reciting the holy trinity of agile ritualism:

"Yesterday I worked on ticket 104. Today I'll continue working on ticket 104 and then I'll start a ticket 109. No blockers."

While one person speaks, six others stare at their shoes, check mails, or mentally rehearse what they are going to say when their turn comes. The person facilitating - usually an Engineering Manager or Scrum Master - nods, jots down a note, and moves to the next name on the roster.

Fifteen minutes later, the call ends. Everyone returns to their desks having learned virtually nothing that helps them ship software faster.

The uncomfortable truth: The Daily Standup was designed as a quick, tactical huddle for a sports team to coordinate the next play. In most software organizations, it has degenerated into status reporting theater - a daily exercise in proving to management that you were busy yesterday.

When standup becomes a roll call, it wastes time, drains energy, and masks the exact bottlenecks it was created to uncover.

Here is why your standup is broken - and how to turn it back into a high-impact engineering tool.


The Root Cause: "Walking the People" vs. "Walking the Board"

The fundamental defect in most broken standups lies in how the conversation is structured.

The Anti-Pattern: Walking the People

Most teams go person-by-person in alphabetical or random order. This forces the conversation to focus on individual activity rather than system flow:

graph TD
    subgraph Status Theater
        M[Manager / Facilitator]
        D1[Dev 1: 'Yesterday I worked on...'] --> M
        D2[Dev 2: 'Yesterday I worked on...'] --> M
        D3[Dev 3: 'Yesterday I worked on...'] --> M
        D4[Dev 4: 'Yesterday I worked on...'] --> M
    end

When you walk the people:

  1. The audience is the manager: Developers report to the authority figure instead of coordinating with each other.
  2. Accountability replaces collaboration: People feel pressured to list trivial tasks just to prove they worked eight hours.
  3. Flow is completely ignored: A critical pull request might be sitting unreviewed for four days in the "Review" column, but because the assignee says "no blockers," nobody talks about it.

The Fix: Walking the Board (Right to Left)

High-performing teams don't care about what individuals did for eight hours yesterday; they care about moving work across the finish line.

Instead of asking people for updates, you open the sprint board and walk the columns from right to left (closest to Done first):

graph LR
    subgraph Tactical Flow
        direction RL
        DONE[4. Done] -.-> QA[3. QA / Verification]
        QA --> REV[2. Code Review]
        REV --> WIP[1. In Progress]
    end

By starting at the right-most column (e.g., In Testing / Verification, then In Code Review, then In Progress), you enforce the most important principle of lean delivery:

"Stop Starting, Start Finishing."

For every ticket closest to Done, the team asks a single question:

"What do we need to do as a team to move this card into 'Done' today?"

If a PR needs a review, someone grabs it right there. If a feature is stuck in QA because an environment is broken, developers volunteer to fix it before picking up a new ticket.


Signal vs. Noise: The Standup Matrix

🔴 Status Reporting Theater 🟢 High-Flow Tactical Huddle
Order: Dev 1 to Dev 2 to Dev 3 (Roster order) Order: Right-to-Left on the Board (Closest to Done first)
Primary Question: "What did you do yesterday?" Primary Question: "What is stopping this ticket from shipping today?"
Target Audience: Manager / Scrum Master Target Audience: Teammates collaborating on delivery
Dynamic: Serial 1-on-1 conversations with passive observers Dynamic: Active swarming around bottlenecks and handoffs
Focus: Individual utilization and busywork Focus: Cycle time, WIP limits, and team throughput
Blocker Detection: Reactive ("No blockers from me") Blocker Detection: Proactive ("This ticket has been in review for 3 days, who can look at it?")

5 Practical Rules to Fix Your Standup Tomorrow

1. Ban the Classic Three Questions

Ditch the textbook Scrum questions (What did I do yesterday? What will I do today? Any blockers?). They almost always trigger defensive status monologues.

Replace them with two board-centric questions:

  1. "Is any ticket stuck, ageing, or blocked?"
  2. "What can we push across the finish line today?"

2. Enforce the "16th Minute" (Take Deep Dives Offline)

The single biggest reason standups drag on to 30 or 40 minutes is technical rabbit-holing. Two senior engineers start debating database schema migrations, while six other people wait in silence.

Adopt the 16th Minute Rule:

  • Any discussion requiring more than 60 seconds of back-and-forth is immediately tagged: "Let's take that to the 16th minute."
  • Finish the standup within 10–12 minutes.
  • Release everyone whose presence is not required.
  • The 2–3 relevant people stay on the call for the specific technical drill-down.

3. Respect WIP (Work In Progress) Limits

If you have 5 developers on your team and your board shows 11 tickets in "In Progress", your problem isn't coding speed - it's multitasking and uncompleted work.

When you walk the board right-to-left, make WIP visible: - If someone is about to pull a new ticket from To Do, ask if they can instead review an open PR or help test a feature nearing completion. - A ticket sitting in review is zero value delivered. Value is only realized when software hits production.

4. Rotate the Facilitator

If the Engineering Manager or Scrum Master always drives the standup, developers will naturally treat it as a status report to their boss.

Rotate the driver daily or weekly across all team members - including junior engineers. When a developer shares their screen and walks the board, the dynamic shifts from top-down inspection to peer-to-peer collaboration.

5. Go Async for the Monotonous Days

If your team's board is healthy, pull requests are reviewed within hours, and everyone communicates proactively on Slack or Teams, you don't need a live meeting every single day.

Try making standups async on Tuesdays and Thursdays:

  • A simple automated bot or a designated Teams thread where engineers post only blockers or cross-team requests.
  • Save live standups for days where real-time coordination is essential (e.g., sprint kickoff days, release cutoffs, or complex integration phases).

The Takeaway

A daily standup is not a management surveillance tool. Your tracking software, git logs, and DORA metrics already tell you what is happening.

The only justification for interrupting everyone's morning focus is real-time tactical alignment. If your standup doesn't help the team unblock work and finish tasks faster, change the format - or kill the meeting.


Share on Share on Share on

The AI Review Paradox: Generation vs. Comprehension

We were promised a revolution in developer productivity: type a few sentences into a prompt or let an assistant autocomplete your thoughts, and watch hundreds of lines of code appear in seconds.

And it works. Engineers are producing code faster than at any point in software history.

There is just one inconvenient problem: Writing code was never the primary bottleneck in software engineering.

The real bottleneck has always been reading, validating, reasoning about, and maintaining the code. By supercharging code generation without scaling our ability to verify it, we haven't eliminated engineering friction - we’ve merely pushed the bottleneck directly onto the code review process.

Welcome to the AI Review Paradox.


The Generation vs. Comprehension Asymmetry

Writing code is active creation; reviewing code is forensic analysis.

When an engineer writes code by hand, the pacing is naturally throttled. They think through data structures, wrestle with edge cases, rewrite helper functions, and build a mental model of the change. By the time they submit a Pull Request, they usually understand why every line is there.

graph TD
    subgraph Traditional Flow
        A[Problem] --> B[Mental Model]
        B --> C[Careful Implementation]
        C --> D[PR<br/>Author understands 100%]
    end

With AI assistants, that cognitive pipeline breaks down:

graph TD
    subgraph AI-Accelerated Flow
        E[Problem] --> F[Prompt]
        F --> G[Instant Code]
        G --> H[PR<br/>Author understands ~60%<br/>Reviewer must decipher 100%]
    end

It takes 30 seconds to generate an entire service layer with error handling, logging, and database queries. But it still takes 30 to 45 minutes for a teammate to verify whether that logic handles race conditions, adheres to domain boundaries, and respects existing architectural patterns.

When output speed increases by 5x but review capacity stays flat, the math simply stops working.


The "Looks Good To Me" Trap

When reviewers are flooded with massive diffs that look syntactically pristine, human psychology takes over.

AI-generated code has a distinct characteristic: it looks exceptionally polished on the surface. It features clean variable names, well-placed comments, sensible formatting, and believable test suites.

This creates a dangerous cognitive bias. Reviewers instinctively associate clean syntax with correct logic. Confronted with a 600-line diff on a busy afternoon, the cognitive load is overwhelming. Instead of meticulously tracing execution paths, reviewers skim the diff, see that the CI pipeline is green, and leave the dreaded:

“Looks good, merge it! 🚀”

This is how subtle bugs, security vulnerabilities, and architectural drift slip into production undetected.


The Illusion of Quality in AI-Generated Code

The risk isn't that AI writes completely broken code that crashes immediately - unit tests and CI usually catch blatant syntax errors. The real risk lies in what AI code gets subtly wrong:

  1. Plausible but Flawed Invariants: The code looks logically sound until you realize it makes subtle, incorrect assumptions about your business domain that no generic LLM could possibly know.
  2. Happy-Path Test Syndrome: AI is remarkably good at generating unit tests that test that the generated code works, rather than testing the edge cases where the code might fail. It writes tests that mirror the implementation, creating a false sense of test coverage.
  3. Architectural Inconsistency: Left unchecked, different engineers prompt AI to solve similar problems in completely different styles - introducing three different HTTP clients, two state management paradigms, and bespoke utility functions that duplicate existing codebase tools.
  4. The "Disowned Code" Dilemma: When an outage hits at 2 AM, the author who generated 400 lines of code with a single prompt often struggles to explain the exact mechanics of the fix, because they never truly built the mental model in the first place.

Pragmatic Rules for Engineering Teams

We cannot - and should not - put the genie back in the bottle. AI coding assistants are powerful tools when used with discipline. But engineering teams need new rules of engagement to survive the volume:

1. The author owns 100% of the code

The foundational rule: "The AI wrote it" is never an acceptable explanation. If an engineer cannot explain line-by-line how an algorithm works, why a specific concurrency primitive was chosen, or what happens during a network timeout, the PR is not ready for review.

2. Radical PR size limits

When writing code is effortless, PR sizes balloon. Teams must enforce strict diff caps (e.g., maximum 200–300 lines of change per PR, excluding generated lockfiles/schemas). If a feature requires 1000 lines, break it down into atomic, reviewable increments.

3. Review the tests before the code

When reviewing an AI-assisted PR, flip your usual review process:

  • Start with the test cases.
  • Look specifically for missing negative tests, boundary conditions, and null/empty state handling.
  • If the tests only verify the happy path, reject the PR before spending time reviewing the implementation.

4. Shift-Left: align on design, not just diffs

If an engineer spends 10 minutes discussing the interface design and architectural approach with a peer before prompting an LLM to generate the implementation, the resulting code will be much cleaner, more cohesive, and faster to review.


The bottom line: velocity isn't typing speed

The ultimate goal of software engineering has never been to maximize the rate of keystrokes. It has always been to deliver reliable, maintainable business value with minimum complexity.

AI is a powerful force multiplier. But if your team uses it merely to generate larger volumes of code without raising the bar on comprehension and review rigor, you aren't increasing velocity - you're just accelerating your path to technical debt.


Share on Share on Share on

Beyond OOP: Entity Component System schema for high performance

Object-Oriented Programming (OOP) taught a generation of software engineers to model complex domains by creating class hierarchies. In games and real-time systems, we start with simple abstractions: a base NPC class extended by Player, EasyEnemy, Companion, or ToughEnemy.

Over time, this model breaks down. A Companion might need inventory management from Player and pathfinding from ToughEnemy. Single inheritance forces you to choose between code duplication or pushing specialized methods up into a bloated "God Class."

Beyond code cleanliness, OOP suffers from a silent killer: poor CPU cache locality. Storing polymorphic objects as heap-allocated pointers (std::vector<std::unique_ptr<NPC>>) means your CPU spends more cycles chasing pointers through RAM than performing calculations.

Entity Component System (ECS) replaces deep inheritance trees with Data-Oriented Design (DOD):

  • Entity: A lightweight ID handle (no data, no logic).
  • Component: Plain-Old-Data (POD) structs stored contiguously in memory.
  • System: Stateless functions that operate over flat arrays of components.

1. Concrete Mapping: OOP vs. ECS

Instead of defining class types, entities are composed at runtime by attaching data components:

Entity Type Position Velocity Health Inventory AIBehavior
Player
EasyEnemy
ToughEnemy
Companion

Notice how Companion simply reuses Inventory and AIBehavior without touching Player or inheriting from a rigid base class.

2. Sample C++17 ECS Implementation

This minimal implementation uses sparse sets to store component data in contiguous vectors, enabling O(1) lookup while keeping iteration memory-dense and CPU cache-friendly.

#include <iostream>
#include <vector>
#include <unordered_map>
#include <typeindex>
#include <memory>
#include <cstdint>
#include <cassert>

using Entity = std::uint32_t;
constexpr Entity NULL_ENTITY = 0xFFFFFFFF;

// ============================================================================
// 1. DATA COMPONENTS (POD - Pure Data)
// ============================================================================
struct Position { float x{0.0f}, y{0.0f}; };
struct Velocity { float dx{0.0f}, dy{0.0f}; };
struct Health   { int current{100}, max{100}; };

struct Inventory {
    std::vector<int> itemIDs;
    int capacity{10};
};

enum class AIType { Easy, Tough, Companion };
struct AIBehavior {
    AIType type{AIType::Easy};
    float aggroRadius{10.0f};
};

// ============================================================================
// 2. SPARSE SET COMPONENT POOL
// ============================================================================
class IPool {
public:
    virtual ~IPool() = default;
    virtual void Remove(Entity entity) = 0;
};

template <typename T>
class ComponentPool : public IPool {
public:
    void Insert(Entity entity, T component) {
        if (entity >= m_Sparse.size()) {
            m_Sparse.resize(entity + 1, NULL_ENTITY);
        }
        m_Sparse[entity] = static_cast<Entity>(m_DenseData.size());
        m_DenseEntities.push_back(entity);
        m_DenseData.push_back(component);
    }

    void Remove(Entity entity) override {
        if (!Has(entity)) return;

        // Swap with the last element to maintain contiguous memory
        Entity indexToRemove = m_Sparse[entity];
        Entity lastEntity = m_DenseEntities.back();

        m_DenseData[indexToRemove] = m_DenseData.back();
        m_DenseEntities[indexToRemove] = lastEntity;

        m_Sparse[lastEntity] = indexToRemove;
        m_Sparse[entity] = NULL_ENTITY;

        m_DenseData.pop_back();
        m_DenseEntities.pop_back();
    }

    bool Has(Entity entity) const {
        return entity < m_Sparse.size() && m_Sparse[entity] != NULL_ENTITY;
    }

    T& Get(Entity entity) {
        assert(Has(entity) && "Entity does not have requested component!");
        return m_DenseData[m_Sparse[entity]];
    }

    // Direct access to contiguous memory for high-speed cache execution
    std::vector<T>& GetData() { return m_DenseData; }
    const std::vector<Entity>& GetEntities() const { return m_DenseEntities; }

private:
    std::vector<Entity> m_Sparse;          // Entity ID -> Index in Dense vector
    std::vector<Entity> m_DenseEntities;  // Index -> Entity ID
    std::vector<T>      m_DenseData;      // Contiguous Component Data
};

// ============================================================================
// 3. REGISTRY (Entity & Component Manager)
// ============================================================================
class Registry {
public:
    Entity CreateEntity() {
        return m_EntityCounter++;
    }

    template <typename T>
    void AddComponent(Entity entity, T component) {
        GetPool<T>()->Insert(entity, component);
    }

    template <typename T>
    T& GetComponent(Entity entity) {
        return GetPool<T>()->Get(entity);
    }

    template <typename T>
    bool HasComponent(Entity entity) {
        return GetPool<T>()->Has(entity);
    }

    template <typename T>
    ComponentPool<T>* GetPool() {
        std::type_index typeKey = typeid(T);
        auto it = m_Pools.find(typeKey);
        if (it == m_Pools.end()) {
            it = m_Pools.emplace(typeKey, std::make_unique<ComponentPool<T>>()).first;
        }
        return static_cast<ComponentPool<T>*>(it->second.get());
    }

private:
    Entity m_EntityCounter{0};
    std::unordered_map<std::type_index, std::unique_ptr<IPool>> m_Pools;
};

// ============================================================================
// 4. STATELESS SYSTEMS
// ============================================================================
namespace MovementSystem {
    void Update(Registry& registry, float dt) {
        auto* posPool = registry.GetPool<Position>();
        auto* velPool = registry.GetPool<Velocity>();

        // Cache-friendly loop over contiguous memory buffers
        const auto& entities = velPool->GetEntities();
        const auto& velocities = velPool->GetData();

        for (size_t i = 0; i < entities.size(); ++i) {
            Entity entity = entities[i];
            if (posPool->Has(entity)) {
                auto& pos = posPool->Get(entity);
                const auto& vel = velocities[i];

                pos.x += vel.dx * dt;
                pos.y += vel.dy * dt;
            }
        }
    }
}

namespace AISystem {
    void Update(Registry& registry) {
        auto* aiPool = registry.GetPool<AIBehavior>();
        const auto& entities = aiPool->GetEntities();
        auto& aiData = aiPool->GetData();

        for (size_t i = 0; i < entities.size(); ++i) {
            Entity e = entities[i];
            switch (aiData[i].type) {
                case AIType::Easy:
                    std::cout << "[AI] Entity " << e << " (EasyEnemy): Wandering casually.\n";
                    break;
                case AIType::Tough:
                    std::cout << "[AI] Entity " << e << " (ToughEnemy): Aggressively flanking player.\n";
                    break;
                case AIType::Companion:
                    std::cout << "[AI] Entity " << e << " (Companion): Following player and offering support.\n";
                    break;
            }
        }
    }
}

// ============================================================================
// 5. EXECUTION & VERIFICATION
// ============================================================================
int main() {
    Registry registry;

    // 1. Create Player
    Entity player = registry.CreateEntity();
    registry.AddComponent(player, Position{0.0f, 0.0f});
    registry.AddComponent(player, Velocity{1.5f, 0.0f});
    registry.AddComponent(player, Health{100, 100});
    registry.AddComponent(player, Inventory{{101, 102}, 20});

    // 2. Create Tough Enemy
    Entity toughEnemy = registry.CreateEntity();
    registry.AddComponent(toughEnemy, Position{10.0f, 5.0f});
    registry.AddComponent(toughEnemy, Velocity{-0.5f, -0.5f});
    registry.AddComponent(toughEnemy, Health{250, 250});
    registry.AddComponent(toughEnemy, Inventory{{201}, 5});
    registry.AddComponent(toughEnemy, AIBehavior{AIType::Tough, 15.0f});

    // 3. Create Companion
    Entity companion = registry.CreateEntity();
    registry.AddComponent(companion, Position{1.0f, 0.0f});
    registry.AddComponent(companion, Velocity{1.2f, 0.0f});
    registry.AddComponent(companion, Health{150, 150});
    registry.AddComponent(companion, Inventory{{301, 302, 303}, 15});
    registry.AddComponent(companion, AIBehavior{AIType::Companion, 8.0f});

    std::cout << "=== INITIAL STATE CREATED ===\n\n";

    // Simulate 1 Frame tick
    float dt = 0.016f; // ~60 FPS

    std::cout << "--- Executing AISystem ---\n";
    AISystem::Update(registry);

    std::cout << "\n--- Executing MovementSystem ---\n";
    MovementSystem::Update(registry, dt);

    std::cout << "\nPlayer Position after movement: (" 
              << registry.GetComponent<Position>(player).x << ", " 
              << registry.GetComponent<Position>(player).y << ")\n";

    return 0;
}

3. Why ECS Extends Beyond Game Engines

While ECS originated in video games to solve object composition and frame-budget limits, its underlying paradigm - Data-Oriented Design (DOD) - is equally critical in other performance-sensitive domains:

  • Robotics & Autonomous Systems Modern robots process dozens of heterogeneous sensors (LiDAR points, IMU telemetry, camera frames, motor feedback) at high frequencies. Modeling a robot platform via OOP inheritance leads to synchronization lock-ups. ECS allows sensor data to stream into continuous arrays where perception, planning, and motor-control systems run as parallel data pipelines.

  • High-Frequency Financial Systems Order-matching engines and market-data aggregators process millions of financial instruments per second. Using ECS, order entities contain dynamic state tags (Active, MarginCall, PendingCancel). Systems iterate through contiguous pools of bid/ask values without pointer indirection, minimizing instruction cache misses and latency spikes.

  • CAD & Mechanical Simulations Engineering applications must simulate millions of structural nodes subject to heat, tension, and fluid dynamics. By modeling nodes as entities with components like ThermalState, Vector3DForce, or MaterialStress, finite-element solvers sweep through contiguous arrays using SIMD vector instructions for optimal hardware usage.

Key Takeaways

  1. Composition over Inheritance: Eliminate monolithic base classes. Add or remove behaviors at runtime simply by attaching or detaching components.
  2. CPU Cache Optimization: Storing components in flat arrays allows hardware prefetchers to load memory lines efficiently, eliminating O(N) pointer chasing.
  3. Stateless Logic: Systems remain clean and decoupled - they don't care what an entity is, only that it has the components required for processing.

Share on Share on Share on

Twitch-TTS 1.0.2

Każdy streamer wie, jak ważna jest interakcja z widzami na żywo. Odczytywanie wiadomości z czatu za pomocą syntezatora mowy (Text-to-Speech) to świetny sposób na budowanie zaangażowania, zwłaszcza podczas dynamicznej rozgrywki, gdy nie ma czasu na zerkanie na drugi monitor. Niestety, większość dostępnych rozwiązań opiera się na chmurze, płatnych subskrypcjach API (Google Cloud, Amazon Polly) lub ociężałych aplikacjach webowych.

Dlatego powstał Twitch TTS – w 100% lokalna, szybka i w pełni darmowa aplikacja desktopowa dla systemu Windows, której pierwsze oficjalne wydanie v1.0.2 jest już dostępne na GitHubie.


Czym jest Twitch TTS?

Twitch TTS to narzędzie dla twórców na platformie Twitch, które w czasie rzeczywistym przekształca wiadomości z czatu oraz nagrody za punkty kanału (Channel Points) na mowę przy użyciu nowoczesnych sieci neuronowych.

Kluczową cechą programu jest całkowita praca w trybie offline (on-device):

  • Zero opłat i subskrypcji: Brak konieczności płacenia za zewnętrzne usługi syntezy w chmurze.
  • Prywatność: Żadne dane ani treści wiadomości nie opuszczają Twojego komputera.
  • Minimalne opóźnienia: Dźwięk generowany jest bezpośrednio w pamięci RAM i natychmiast kierowany na wybraną kartę dźwiękową.

Autoryzacja 1-Click OAuth i konfiguracja konta Twitch


Najważniejsze możliwości i funkcje

Twitch TTS został zaprojektowany tak, aby zapewnić streamerowi pełną kontrolę nad dźwiękiem i moderacją w trakcie transmisji.

1. Błyskawiczna integracja z Twitch (1-Click OAuth)

Koniec ze żmudnym kopiowaniem tokenów czy konfiguracją botów. Aplikacja posiada wbudowany, bezpieczny mechanizm autoryzacji – jedno kliknięcie otwiera przeglądarkę, a po zalogowaniu Twitch TTS automatycznie łączy się z czatem przez WebSocket (EventSub). Możesz wybrać, czy program ma czytać wszystkie wiadomości, czy tylko wybrane nagrody za punkty kanału.

2. Zaawansowane filtry i moderacja czatu

Czat na żywo bywa nieprzewidywalny, dlatego program wyposażono w rozbudowany potok czyszczenia tekstu:

  • Cenzura wulgaryzmów: Automatyczna podmiana niepożądanych słów na kulturalny dźwięk "piiiiip". Lista słów zawarta w pliku profanity_words.txt w katalogu programu.
  • Aliasy fonetyczne: Możliwość zdefiniowania poprawnej wymowy skomplikowanych nicków widzów lub słów (np. utak3r -> utaker, stream -> strim).
  • Ochrona antyspamowa: Automatyczne skracanie powtarzających się znaków (np. siemaaaa -> siemaaa) oraz odrzucanie linków URL.
  • Czarna lista botów: Łatwe ignorowanie popularnych botów (Nightbot, StreamElements itp.).

Zarządzanie filtrami, słownikiem wulgaryzmów i aliasami

3. Inteligentna kolejka (Anti-Raid / Drop Oldest)

Podczas nagłego najazdu widzów (raid) lub wzmożonej aktywności na czacie tradycyjne boty TTS potrafią "zapętlić się" na kilkadziesiąt minut. Twitch TTS wykorzystuje bufor z polityką Drop Oldest – jeśli wiadomości napływają szybciej, niż syntezator jest w stanie je odczytać, najstarsze są bezpiecznie pomijane, a stream nie ma opóźnień.

4. Speech Lab – Laboratorium fonetyczne

Wbudowana zakładka testowa pozwala na żywo sprawdzić każdy etap przetwarzania tekstu przed uruchomieniem go na streamie: od surowego tekstu, przez aliasy i filtry, aż po finalną postać trafiającą do syntezatora. Umożliwia także regulację tempa mowy oraz eksport wygenerowanego audio do pliku .wav.

Laboratorium mowy i podgląd przetwarzania tekstu w czasie rzeczywistym

5. Monitor Live i pełna integracja z OBS Studio

Główny pulpit (Live Monitor) wyświetla historię wszystkich przeczytanych wypowiedzi wraz ze statusem, regulacją głośności oraz przyciskami szybkiej reakcji (Mute, Skip, Replay, Add Alias).

Dźwięk może być kierowany bezpośrednio do słuchawek lub na wirtualne kable audio (np. VB-Audio Virtual Cable, Voicemeeter), skąd z łatwością dodasz go jako osobne źródło dźwięku w OBS Studio lub Streamlabs. Specjalny mechanizm Paddingu (bufora ciszy) gwarantuje, że wirtualne karty audio nie utną końcówek wypowiedzi.

Pulpit Live Monitor podczas transmisji na żywo


Pod maską: Nowoczesny i wydajny stos technologiczny

Aplikacja powstała z naciskiem na maksymalną wydajność, stabilność i niskie zużycie zasobów:

  • Rust: Język programowania gwarantujący bezpieczeństwo pamięci, brak narzutu odśmiecacza pamięci (GC) oraz wysoką wydajność. Asynchroniczny backend oparty o runtime Tokio zapewnia bezproblemową obsługę strumieni WebSocket i przetwarzania audio w tle.
  • Slint: Nowoczesny, deklaratywny framework GUI. Interfejs aplikacji jest lekki, responsywny, renderuje się płynnie i zużywa zaledwie ułamek zasobów procesora i pamięci RAM.
  • Piper TTS: Szybki, lokalny silnik neuronowej syntezy mowy bazujący na modelach ONNX, oferujący naturalnie brzmiące głosy bez konieczności posiadania dedykowanej karty graficznej.

Pobierz pierwsze wydanie (v1.0.2)

Pierwsze oficjalne wydanie aplikacji jest już gotowe do pobrania na platformie GitHub:

📦 Pobierz: Twitch-TTS v1.0.2 na GitHub Releases
💻 Kod źródłowy: utak3r/Twitch-TTS na GitHub
📄 Licencja: MIT (otwarte oprogramowanie)

W repozytorium dostępny jest gotowy instalator .msi dla systemu Windows oraz pełna dokumentacja instalacji i konfiguracji. Zapraszam do testowania, zgłaszania uwag oraz współtworzenia projektu!

Program przeszedł już chrzest bojowy u jednego streamera (dzięki @masi4m_ za testy!) - działał przez kilka godzin bez problemu :)


Share on Share on Share on

The Abstraction Tax

Junior developers write code that barely works.

Mid-level developers write clean code that solves today's problem.

Senior developers - or at least engineers entering that transitional phase of their careers - sometimes write five layers of interfaces, abstract factories, dynamic configuration engines, and custom event buses for a feature that will literally never change again.

It is a rite of passage, but also a dangerous trap. We have all opened a pull request or stepped into a codebase expecting to fix a two-line bug, only to find ourselves navigating three abstract classes, two strategy interfaces, a generic repository wrapper, and a dynamic dependency injection setup - all just to append a timestamp to a database record.

How did we get here? And more importantly, how do we stop confusing structural complexity with high-quality software engineering?


Why Smart Engineers Over-Engineer

Over-engineering rarely comes from malice or incompetence. In fact, it almost always stems from good intentions combined with a few subtle cognitive traps:

  1. Pattern Worship & Resume-Driven Development: After mastering classic design patterns (Gang of Four, Clean Architecture, DDD), there is a powerful urge to use them everywhere. Applying a complex pattern feels like "doing real engineering" - and looks great on a CV.
  2. Fear of Future Change (Speculative Generality): "What if we switch from PostgreSQL to MongoDB next month? What if we swap Stripe for PayPal and Adyen simultaneously?" We build elaborate provider abstractions today for architectural shifts that never happen tomorrow.
  3. Confusing Flexibility with Quality: We mistake configurable, indirect code for robust code. However, every option, toggle, and generic type parameter exponentially increases the state space you must test, reason about, and maintain.
  4. The Intellectual Boredom Factor: Solving the actual business problem is often straightforward. Building an extensible, plugin-based meta-framework to solve it is far more intellectually stimulating.

The Hidden Cost: The Abstraction Tax

Every layer of indirection you add comes with a price tag that the whole team pays continuously over time.

The Abstraction Tax: The mental energy required for a developer to trace execution through layers of indirection before they can understand what the code actually does.

When a codebase succumbs to over-engineering:

  • Debugging becomes a nightmare: Stack traces jump across six files of pass-through wrappers and indirection layers.
  • Onboarding slows to a crawl: New team members spend weeks learning custom architectural meta-conventions instead of core business domain rules.
  • Refactoring becomes harder, not easier: Ironically, hyper-generalized code is often so rigid in its abstractions that changing a fundamental requirement breaks the entire class hierarchy.
  • Performance takes a silent hit: In systems languages like C++, unneeded dynamic polymorphism (virtual dispatch, vtable lookups) and unnecessary heap allocations prevent compiler inlining and pollute the instruction cache.

A Tale of Two Implementations

Let’s examine a concrete scenario in C++20. Suppose we need a service that fetches user profile data from an external HTTP API and saves it to a local cache.

The Over-Engineered Approach

#include <memory>
#include <string>
#include <format>

// Domain structures
struct UserProfileRaw { std::string id; std::string full_name; };
struct UserProfile    { std::string id; std::string name; };

// 1. Interface for the API Client
template <typename T>
class IUserDataProvider {
public:
    virtual ~IUserDataProvider() = default;
    virtual T fetchPayload(const std::string& id) = 0;
};

// 2. Strategy interface for caching
template <typename T>
class ICacheStrategy {
public:
    virtual ~ICacheStrategy() = default;
    virtual void save(const std::string& key, const T& data) = 0;
};

// 3. Abstract Base Orchestrator
template <typename TInput, typename TOutput>
class BaseUserOrchestrator {
protected:
    std::shared_ptr<IUserDataProvider<TInput>> provider;
    std::shared_ptr<ICacheStrategy<TOutput>> cache;

public:
    BaseUserOrchestrator(
        std::shared_ptr<IUserDataProvider<TInput>> prov,
        std::shared_ptr<ICacheStrategy<TOutput>> csh
    ) : provider(std::move(prov)), cache(std::move(csh)) {}

    virtual ~BaseUserOrchestrator() = default;
    virtual TOutput process(const std::string& id) = 0;
};

// 4. Concrete Strategy Implementation (Redis)
template <typename T>
class RedisCacheStrategy : public ICacheStrategy<T> {
public:
    void save(const std::string& key, const T& data) override {
        redisClient::set(key, data);
    }
};

// 5. Concrete Provider Implementation (HTTP)
class ExternalHttpUserProvider : public IUserDataProvider<UserProfileRaw> {
public:
    UserProfileRaw fetchPayload(const std::string& id) override {
        return httpClient::get<UserProfileRaw>(std::format("/users/{}", id));
    }
};

// 6. Concrete Service Implementation
class UserProfileOrchestrator : public BaseUserOrchestrator<UserProfileRaw, UserProfile> {
public:
    using BaseUserOrchestrator::BaseUserOrchestrator;

    UserProfile process(const std::string& id) override {
        UserProfileRaw raw = provider->fetchPayload(id);
        UserProfile profile = mapToDomain(raw);
        cache->save(std::format("user:{}", id), profile);
        return profile;
    }

private:
    UserProfile mapToDomain(const UserProfileRaw& raw) {
        return UserProfile{ .id = raw.id, .name = raw.full_name };
    }
};

The Cost: Three class templates, two virtual interfaces, an abstract base class, heap allocations via std::shared_ptr, and runtime dispatch overhead through vtables - all for a single fetch and cache operation.

The Pragmatic Approach

#include <string>
#include <format>

struct UserProfileRaw { std::string id; std::string full_name; };
struct UserProfile    { std::string id; std::string name; };

// Clean, direct, procedural execution
UserProfile getUserProfile(const std::string& userId) {
    auto raw = httpClient::get<UserProfileRaw>(std::format("/users/{}", userId));

    UserProfile profile{
        .id = raw.id,
        .name = raw.full_name
    };

    redisClient::set(std::format("user:{}", userId), profile);
    return profile;
}

The Value: Ten lines of code. Zero virtual calls, zero dynamic allocations, perfect inlining potential for the compiler, and complete clarity for anyone reading the code.

If - and only if - you later introduce a second data provider or alternative cache engine, you can extract an interface or introduce a template concept in 60 seconds. Until that day comes, the extra abstraction is pure dead weight.


Signal vs. Noise: Architectural Health Checklist

⚙️ Pragmatic Engineering 🤖 Over-Engineered Architecture
Interfaces introduced when 2+ active implementations exist. Interfaces with only 1 implementation created "just in case".
Duplication tolerated until patterns emerge (Rule of Three). Abstract base classes created before a second subclass exists.
Direct function calls and explicit dependency passing. Custom internal frameworks, event buses, or meta-config engines.
Code designed to be easily replaced or deleted. Code designed to be "infinitely extensible".
Embraces C++ Zero-Overhead Principle. Introduces virtual dispatch and heap allocation indiscriminately.

Rules to Stay Pragmatic

1. Embrace AHA over Premature DRY

Don't extract a shared abstraction the second time you see similar code. Wait until the third distinct use case. As Kent C. Dodds popularized: Avoid Hasty Abstractions (AHA). Duplication is far cheaper than the wrong abstraction.

2. Concrete First, Abstract Later

Write the simplest procedural or functional implementation that works. Get it running and covered by tests. If structural patterns emerge naturally during code review or feature expansion, refactor toward abstractions then. Refactoring concrete code into abstractions is easy; unwinding bad abstractions is painful.

3. YAGNI (You Aren't Gonna Need It)

If a capability isn't required by today's user story or immediate roadmap, do not write code for it. Omit optional parameters, fallback adapters, and plugin architectures designed for speculative futures.

4. Optimize for Deletability

Great code isn't code that can be extended indefinitely without touching it. Great code is code that can be understood in 5 minutes and completely replaced in an hour without breaking unrelated parts of the system.


The Takeaway

True senior engineering capability is not measured by your ability to construct complex, highly generic abstractions that require an architectural diagram to navigate.

It is demonstrated by solving complex domain problems with code so simple, explicit, and direct that a junior developer joining the team can look at it and say: "Oh, that makes total sense."

Before you submit your next Pull Request, ask yourself:

"Am I building this abstraction for tomorrow's reality, or just today's intellectual satisfaction?"


Share on Share on Share on

Risk Management in Software Development

Recently, I wrote about how to measure developer progress without the drama. Today, let's tackle the other side of the same coin: risk management.

At first glance, measuring progress and managing risk might look like two separate items on an engineering manager’s checklist. But in reality, they are deeply connected.

Measuring progress is your radar; risk management is your steering wheel. – Without the radar, you won’t see the iceberg coming. Without the steering wheel, you can see it clear as day and still crash right into it.

The primary goal of both tracking progress and managing risk isn't to micromanage developers or blindly check off Jira tickets. It’s about spotting course deviations as early as possible so you can deliver real, working value.

We've already discussed how to measure progress. So, how do you handle risk? The key is treating it as a living, breathing daily ritual for the team - not a static document created at project kickoff and promptly forgotten on Confluence.

1. Spotting and Categorizing Risks

In software engineering, risks generally fall into three main buckets:

  • Technical: Sprawling tech debt, unstable 3rd-party APIs, unverified performance bottlenecks, or tricky data migrations.
  • People & Team: High Bus Factor (critical domain knowledge trapped with single individuals), team turnover, burnout, or key skill gaps.
  • Process & Business: Scope creep, vague requirements, client or Product Owner approval delays, and shifting compliance or legal mandates.

2. Preventive Moves: Mitigating Risk Proactively

Run Spikes & Proofs of Concept (POCs)

When tackling tasks wrapped in uncertainty or unknown tech, don't rely on wild estimates. Schedule a quick 1- to 2-day Spike to build a Proof of Concept (POC). This short research task uncovers hidden gotchas, clarifies requirements, and prevents deep underestimations before they wreck your sprint.

Address the "Bus Factor" and Decision Bottlenecks

The single point of failure - often colloquially (and dramatically) called the Bus Factor (how many team members need to get hit by a bus before the project grinds to a halt?) - is frequently ignored until it's too late. A common variation is the decision bottleneck, where everyone waits on one key dev to approve every pull request or technical detail.

  • How to spot it: Build a quick Skills Matrix. It instantly highlights missing capabilities and knowledge silos across the team.
  • How to fix it: Be intentional about knowledge sharing. For software teams, nothing beats pair programming and cross-training on unfamiliar modules.

Keep a Living Risk Register

Maintain a lightweight Risk Register (e.g., in Confluence or a dedicated Jira board). Don't leave it in a virtual drawer - review and update it regularly during your sprint retrospectives or reviews. This simple habit eliminates nasty surprises right before launch.

Buffer & Scope Stripping

Define your MVP upfront and clearly separate core requirements from "nice-to-haves." When planning sprints, never load your team up to 100% capacity. Leave an explicit 20% safety margin (plan at 70–80% velocity). As Murphy’s Law reminds us: If something can go wrong, it will.

Quick Mitigation Summary

Mechanism How it Works in the Team What it Mitigates
Spike / POC (Proof of Concept) Short (1–2 day) research task before estimating complex modules. Technical uncertainty & major estimation blunders.
Cross-training & Pair Programming Intentional knowledge sharing across modules with non-authors. High Bus Factor & single-person bottlenecks.
Living Risk Register Lightweight table reviewed routinely during retros or reviews. Nasty surprises right before release.
Buffer & Scope Stripping Planning at 70–80% capacity while explicitly tagging MVP vs. Nice-to-Haves. Missed critical deadlines & burnout.

3. How Progress Tracking & Risk Management Work Together

Early Warning Systems (Leading Indicators)

Progress metrics are your best early indicators of rising risk.

  • Progress Signal: You notice a sudden spike in Cycle Time (tasks jumping from 3 days to 8) alongside surging Work in Progress (WIP).
  • Risk Action: This signals an underlying technical issue (e.g., hidden tech debt, complex API dependencies) or a team roadblock (e.g., code review bottlenecks). You can intervene before it derails the milestone.

Feedback Loops & Scope Control

Both tools exist to help you make scope decisions based on hard data rather than gut feelings.

  • Progress Signal: A Burnup Chart shows that at current velocity, the team won't ship all planned features by launch day.
  • Risk Action: Trigger your mitigation protocol: execute Scope Stripping. Pare down features to the essential MVP and push non-critical items to the backlog.

Empiricism vs. "Hope Strategy"

One of software development's biggest traps is the illusion of progress (a.k.a. the notorious "99% done" syndrome).

  • The Shared Principle: Neither progress nor risk reduction should rely on optimistic status updates in Jira ("I'm almost done!"). Both rely strictly on tested, working code deployed to integration or staging environments.

Technical Debt & Code Quality

Unaddressed tech debt is a classic risk that directly drags down velocity and stability over time.

  • Progress Signal: Key DORA metrics like Change Failure Rate (percentage of deployments causing outages) and Mean Time to Restore (MTTR) begin to climb.
  • Risk Action: Rising failure rates mean system stability is critically endangered. The team must pause new feature delivery to focus on refactoring, automated testing, and reliability spikes.

Task Bottlenecks & Knowledge Silos

  • Progress Signal: Tasks assigned to a specific individual or module sit in In Review or In Progress significantly longer than others.
  • Risk Action: The metrics surface a high Bus Factor risk. The team immediately mitigates this by pairing developers or reallocating review responsibilities.

Share on Share on Share on

Stop Over-Engineering Your LLM Apps in Production

Not too long ago, if you mentioned using LangChain in a production-ready enterprise system, seasoned developers would probably look at you with a mix of pity and concern. And rightly so. For a long time, the ecosystem felt like a playground for hobbyists and weekend hackers. Breaking changes were introduced on what felt like a daily basis, APIs shifted beneath your feet overnight, and the documentation was, to put it politely, a treasure hunt where the map was frequently written in an extinct language. And yet, there were tons of companies doing exactly this in a production-ready environment. Or should I say: "production"-"ready"...

But things have changed. A few months ago, the ecosystem finally hit its 1.0 milestone. The tools have officially matured, and they are ready for prime time.

However, "ready for use" doesn’t mean "use it everywhere." As the ecosystem grows up, we as developers need to grow up too. It’s time to talk about how to write this code safely, and more importantly, when to actually deploy it.

How: The Art of Dodging Outdated Code

Let’s start with the mechanics. Writing modern LangChain or LangGraph code requires a specific kind of discipline because the internet is currently gaslighting you.

Because the stable 1.0 architecture is still relatively young, the web is absolutely flooded with old tutorials, medium articles, and GitHub repos showcasing legacy syntax. If you blindly copy-paste code from a 2025 tutorial, your linter is going to throw a tantrum.

This brings us to a major caveat: Be extremely careful with AI coding assistants here. Tools like GitHub Copilot or ChatGPT are heavily trained on that massive ocean of outdated, pre-1.0 code. If you ask an LLM to generate a complex LangGraph workflow, it will confidently hallucinate deprecated methods and mismatched abstractions.

The strategy here is simple but demanding:

  • Make the official documentation your source of truth. Treat it like your primary manual.
  • Monitor your AI tools closely. Use them for boilerplate, but constantly double-check their outputs against the latest API references.
  • Avoid random internet tutorials unless you verify they were written after the stable release.

When: Resist the Urge to Over-Engineer

Now for the controversial part. Just because you can build something with LangChain doesn’t mean you should.

Right now, tech companies are suffering from a collective case of FOMO (Fear Of Missing Out). Teams are throwing LangChain and LangGraph at literally every problem, treating them like a magic fairy dust that makes software inherently better. Spoiler alert: it doesn’t.

Let's break down where these tools actually shine, and where they become a financial and performance liability.

The Sweet Spot: When LangGraph is a Lifesaver

Where LangGraph absolutely crushes it is in complex, multi-agent systems. If you are building a system where multiple specialized agents need to collaborate, loop back to previous steps, manage complex parallel workflows (concurrency), and maintain a robust state across a long-running conversation, writing that from scratch is a nightmare.

Yes, state management and multi-agent routing require a lot of coordination and inevitably consume more tokens to keep all agents aligned. But in this case, the overhead is justified. LangGraph handles the heavy lifting beautifully, cutting down on boilerplate and reducing the bugs that naturally creep into complex asynchronous systems. Here, you are paying a token premium for actual, necessary architectural heavy lifting.

The Trap: The Hidden Token Tax of Simple RAG

On the flip side, I constantly see developers pulling in the entire LangChain framework just to build a simple chatbot with a basic RAG (Retrieval-Augmented Generation) pipeline. This is where the "framework tax" hits you directly in the wallet.

When you use high-level abstractions, you often lose visibility into what is actually being sent over the wire. Many pre-built chains and agents come with hidden prompt wrappers, verbose formatting instructions, and aggressive history-management strategies running under the hood.

The Token Reality Check: A simple, natively written API call sends exactly what you tell it to send - nothing more, nothing less. LangChain’s abstract wrappers can easily bloat your context window with hundreds of hidden tokens per request just to format systemic instructions you didn't explicitly ask for.

If your app just takes a user query, fetches three relevant documents from a vector database, and stuffs them into a prompt template to send to the LLM, you do not need a framework. Pulling in a massive ecosystem for a simple API wrapper introduces unnecessary performance overhead, adds bloated dependencies, and silently inflates your LLM API bill. Writing native code using the raw LLM client gives you total control over every single token entering and leaving your system. It's faster, cleaner, and infinitely cheaper to run at scale.

The Bottom Line

Just keep in mind: LangChain and LangGraph obey the exact same rules as any other software library in history. They are architectural tools designed to solve specific problems, not a fashion statement. They aren't a cool pair of sneakers you wear just because everyone else is wearing them.

Every abstraction layer you add comes with a cost - both in code maintainability and literal token expenses. Use them when your system’s complexity demands them. Keep it simple and raw when it doesn’t. Your production budget - and your teammates who have to maintain your code - will thank you for it.


Share on Share on Share on

How to Measure Devs Without the Drama

A friend recently asked me:

"How do I measure my team's performance without breaking either the product or the team?"

At first, I thought the answer was obvious.

Then I remembered how many engineering teams I've seen optimizing for the wrong things. Managers tracked story points, commit counts, or bugs closed. Dashboards looked great. The product didn't. Worse, the metrics created incentives that turned developers and QA into adversaries instead of teammates.

The golden rule of engineering management is simple: people optimize for how they're measured (Goodhart's Law). Pick the wrong metrics, and your team will optimize them perfectly — even if the product suffers. The challenge isn't collecting more data. It's separating the few metrics that signal real engineering health from the many that create noise.

The Ultimate Cheat Sheet: Signal vs. Noise

🟢 The Pure Signal 🔴 The Distracting Noise
Cycle Time: How fast an idea becomes live code. Velocity & Story Points: Purely internal tool for tasks estimation.
Deployment Frequency: Shipping small and shipping often. Lines of Code: Encourages bloated, messy software.
Change Failure Rate: How often things blow up. Bug Count per Capita: Turns Dev vs. QA into a civil war.
Mean Time to Restore (MTTR): How fast you fix the blow-ups. Commit Volume: Rewards messy, fragmented work.

What to Actually Watch

Modern engineering leaders rely on DORA metrics. They don't look at how hard individuals are typing, instead, they measure the fluid mechanics of the delivery pipeline and system stability.

Cycle Time

The clock starts when a dev types their first line of code and stops when it hits production. Shorter cycle times mean your tasks are small, your code review process is crisp, and your pipeline is free of bureaucratic roadblocks.

Change Failure Rate & MTTR

This is the ultimate playground where Dev and QA meet. What percentage of releases trigger immediate rollbacks or emergency patches? When a fire does break out, how quickly can the team put it out? High stability means your QA safeguards are robust and your monitoring is sharp.

Escaped Defects

The bugs that slipped through the cracks and were caught by your actual users. If this number spikes, it is a clear sign that your testing strategy needs an upgrade (e.g., missing automated regression tests), not that your QA team isn't trying.

Mute the Noise: The Vanity Metrics to Delete

Some metrics feel comfortable because they are easy to plot on a chart, but they actively incentivize terrible behavior.

Take Velocity (Story Points). Story points are designed for internal sprint planning and delivery estimation, not as management currency. If you demand a higher velocity, your team will simply start estimating a 2-point task as a 5-point task. Voilà! Productivity magically "doubles" on paper, while output stays exactly the same.

Similarly, tracking the number of bugs fixed or found per person is a recipe for disaster. If you reward QA for finding bugs, they will log every single missing pixel or typo as an individual critical ticket. If you judge Devs on bugs closed, they will spend their afternoons arguing with QA that a broken button is "actually a feature, not a bug."

Pro-Tip on Team Synergy:

Keep an eye on the "Bounce Rate"—how often a task fails QA and gets kicked back to development. A high bounce rate points to poor communication during refinement, meaning Devs and QAs are not aligning on requirements before the code is even written.

The Takeaway

Stop micro-managing tickets and start managing the flow. Your goal as a manager isn't to make sure everyone looks busy - it's to ensure that high-quality, stable software moves smoothly from a developer’s brain out to the real world. When Dev and QA share ownership of the entire pipeline instead of playing the blame game, performance takes care of itself.


Share on Share on Share on