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

Media Sorter App 1.0.1 release

This application wasn't in my plans... it was born out of my frustration trying to make my gallery displaying in an order I'd like to see it. The issue is the order was to be set visually, by watching the images and dragging them around. And here it is: Media Sorter App.

After opening a target folder, it displays all images and videos in a grid, allowing to drag and drop them around, changing their order. Clicking any thumbnail opens a big view (yes, videos are working, too).

Upon clicking the Apply button (bottom right corner) it does two things:

  • saves current order into a order.json file in a target folder (opening a folder with this file inside makes the app displaying them in that order)
  • copies a list of filenames in that order into a system clipboard, allowing it to be pasted somewhere - for example in the code of the gallery script 😉 which was the main goal of this application.

screenshot

It is written in Flutter, however I've been testing it only against Windows. If you want to check the sources and build it for yourself, you can get it from GitHub. Refer to README.md for instructions.

If you want just to grab a ready binary, there's a MSIX package ready to download and install. Version 1.0.1 can be downloaded here.


Share on Share on Share on

Sound analysis for visualization - revisited

Last time I was working on sound visualization, after testing with real-life data (yes, music 😉) and testing out various visualization shaders, I came to a conclusion that I approached it from a too scientific point of view. The result was fully proper spectrogram - but not so useful for visualization purposes. So, now I've returned to it - but this time focusing on achieving more visually appealing results, easier to read by a human. I wanted to make it similar to what Inigo Quilez is doing in his ShaderToy, but I couldn't find an exact way he is treating the data, so I had to come up with my own approach.

One thing still applies: the best way is to use a frequencies analysis, through FFT. Waveform itself can be useful, too (and that's why I'm still including it as a second row of my OpenGL texture), but here we will focus on a spectrogram, as there's not too much to talk about a waveform, it's a simple data.

So, let's start from taking a portion of an audio file:

import numpy as np
import librosa

def get_audio_part(audio, time_start=0.0, sample_rate=44100, num_samples=512):
    sample_start = int(time_start * sample_rate)
    sample_end = sample_start + num_samples

    # Handle padding if we reach the end of the audio
    if sample_end > len(audio):
        audio_part = audio[sample_start:]
        audio_part = np.pad(audio_part, (0, num_samples - len(audio_part)), 'constant')
    else:
        audio_part = audio[sample_start:sample_end]

    return audio_part

audio_, sample_rate_ = librosa.load("test_sound_01.mp3", mono=True, sr=None)
position = 0.0 # position in the audio file
signal = get_audio_part(audio_, position, sample_rate_, 2048)

Now we can perform a regular FFT analysis of frequencies. We will use Hann window filtering.

window = np.hanning(len(signal))
windowed_signal = signal * window
freqresp = np.fft.rfft(windowed_signal)
freqs = np.fft.rfftfreq(len(signal), 1/sample_rate_)

plt.figure(figsize=(12, 5))
plt.plot(freqs, np.abs(freqresp), color='#00aaff', linewidth=1.5)
plt.title("Frequency Spectrum (FFT Analysis)", fontsize=14, fontweight='bold')
plt.xlabel("Frequency (Hz)", fontsize=12)
plt.ylabel("Magnitude", fontsize=12)
plt.grid(True, linestyle='--', alpha=0.6)

plt.xlim(0, sample_rate_ / 2)
plt.tight_layout()
plt.show()

png

To better understand what's happening there, let's move it to dB scale:

magnitude_db = 20 * np.log10(np.abs(freqresp) + 1e-9)
plt.figure(figsize=(12, 5))
plt.plot(freqs, magnitude_db, color='#00aaff')
plt.title("Frequency Spectrum (dB Scale)")
plt.xlabel("Frequency (Hz)")
plt.ylabel("Magnitude (dB)")
plt.grid(True, alpha=0.3)
plt.show()

png

As we can see, the values range is huge. Keeping in mind we will be mapping them to an image, encoding the magnitude to pixel's brightness, we will get few frequencies bright, and most of the rest just pitch black.

So, we need to make it less scientific - and more visually pleasing. We'll rescale the values - flatten them, to make it more image-friendly.

We'll start from logarithmic scaling (adding 1.0 to avoid values going to negative infinity) and then remapping them to a 0..1 range:

magnitude = np.abs(freqresp)
magnitude = np.log10(magnitude + 1.0)
magrange = np.max(magnitude) - np.min(magnitude)
magnitude -= np.min(magnitude)
magnitude /= magrange

plt.figure(figsize=(12, 5))
plt.plot(freqs, magnitude, color='#ff5500')
plt.title("Magnitudes rescaled for better visibility, with flattened range.")
plt.xlabel("Frequency (Hz)")
plt.ylabel("Magnitude (rescaled)")
plt.grid(True, alpha=0.3)
plt.show()

png

Also, we will take only first 512 values from our FFT response. Remembering we took a 2048 window, FFT returned 1024 values, so our first 512 values will be representing 0..11025 Hz.

Let's build our final texture. It will be 512 pixels wide. In fact should be 1 pixel high, but here we will use 100px, to better see it. It will use only one channel, RED.

ℹ️ Note: When creating OpenGL texture, we have to keep in mind the texture has to be created only once (for instance, on music load), and then in each video frame just having the data being replaced. Also it shouldn't have any mip-mapping.

from PIL import Image

array = magnitude[:512]
arrayuint8 = array.astype(np.float64)
arrayuint8 = 255 * arrayuint8
img = Image.fromarray(arrayuint8.astype(np.uint8), mode='L')
zero = np.zeros(array.shape, dtype=np.uint8)
img_zero = Image.fromarray(zero, mode='L')
img = Image.merge(mode='RGB', bands=(img, img_zero, img_zero))
img = img.rotate(90, expand=True)
img = img.resize((512, 100))
display(img)

You can see an example of working texture below, in animated form:

img

In final visualizer, we will also add a second row of data, representing a waveform of the audio part, but that is pretty straightforward.


Share on Share on Share on

Goodbye, Wordpress!

After years of using Wordpress for my blog, I've decided to move to MkDocs. Why? For few reasons...

The main reason, which made me to make that effort, is the fact, that for the last few years most of my interaction with this blog was fighting with hackers, inserting their spam, mostly gambling related stuff. I was tired of cleaning up their mess and I've decided to move to something more secure and reliable.

Giving the fact, that I'm the only author here, I don't need this huge system, anyway. So I've started looking for some alternative solution. And I've found MkDocs, a static site generator. It works in Python and generates the static pages off the source files. And it's written in a way it allows for quite a big customization. I'm using it with Material for MkDocs theme, which is very nice and customizable.

Another thing is, I've been always preferring to write my posts in Markdown.

Right now I'm still working on the customization - and of course, I'm trying to move the content. Until I'm done, some content may be missing. Note, this is a great occassion to review some old stuff, which is outdated now - and remove it.

Stay tuned!


Share on Share on Share on

Creating a simple local RAG system

We'll build a simple RAG system using local only models. We will not use LangChain, which is introducing many bloated dependencies, is much slower than direct Transformers usage, is not error-free and its documentation is mostly misleading. We'll use only bare Transformers functions for that. As a vector database for storing our embeddings from document, we'll use Faiss, which is really efficient in similarity search. Note it sits in RAM, not on a disk and is very fast.

What is a RAG?

Retrieval-Augmented Generation (RAG) is an AI framework that improves Large Language Model (LLM) accuracy by retrieving data from external, trusted sources (documents, databases) rather than relying solely on training data. It enables up-to-date, specialized answers, reduces hallucinations, and avoids costly model retraining.

In simple words: it allows to have a LLM having a specialized knowledge without retraining it. We'll build here a simple version of it, allowing loading a single PDF files and then having a chat. We will use only local models, without using any cloud. This means few things: - it's completely free - it's completely private (no data exposing to internet) - it's weaker than cloud models.

Models of choice

It's up to you - and depends mostly on your hardware (GPU and its VRAM). I've used here google/gemma-2-9b-it as LLM and BAAI/bge-large-en-v1.5 for creating embeddings. It works without any issues on 12GB VRAM GPU - and it works with different languages. You can, for instance, have a source in Polish and ask questions in English (or vice-versa). Keep in mind in order to use models from HuggingFace, you have to have an account there and you have to accept the model's usage policy.

Some important parameters

When creating a RAG system, there're few parameters, that can have a big impact on how it's working. This includes: - LLM's temperature: it controls the randomness of LLM's output. The lower temperature, the more deterministic answers will be. - If LLM can do sampling: if sampling is set to off, LLM is using greedy sampling, so it just selects the tokens with the highest probability. If sampling is allowed, it will take one of the possible tokens. The choise is weighted, but it doesn't necessarily mean the highest probability will be chosen. - How many similar chunks to choose: during looking for similar chunks in the vector database, how many of them will be selected for the answer? In this example well working values are 3-5.

All in all, how you set them, depends mostly on the type of documents you want to work with: if it's some theory, reports, technicals, instructions, guides etc., then set it as I did below. If it's more loose texts, you may want to increase the temperature (let's say, up to 0.4) and turn sampling on. With even more informal texts you will also want to increase the number of similar chunks to be found (even above 10), but be careful, it will greatly increase the RAM usage.

Let's gather it up:

EMBEDDING_MODEL = "BAAI/bge-large-en-v1.5"
LLM_MODEL = "google/gemma-2-9b-it"
LLM_TEMPERATURE = 0.1
LLM_DO_SAMPLE = False
SIMILAR_CHUNKS_COUNT = 3

Building a vector database

Let's start from creating our vector database, which is our special knowledge, created from given PDF file. We will read the file and split the text into smaller chunks (remembering the page number for each chunk, so we can give exact citations in our answers). The chunks will be converted into embeddings, using SentenceTransformer and our embedding model.

reader = pypdf.PdfReader(pdf_path)
full_text = ""
pages_meta = []  # A list to track which page each character comes from.
for i, page in enumerate(reader.pages):
    page_text = page.extract_text()
    if page_text:
        full_text += page_text
        # For each character on the page, store its page number and source file. This is a bit memory-intensive
        # but allows for accurate source tracking later.
        pages_meta.extend([{'page': i + 1, 'source': pdf_path}] * len(page_text))

Having the text extracted, we'll split the full text into smaller chunks:

chunks = simple_text_splitter(full_text, chunk_size=800, chunk_overlap=150)

We'll create a metadata for each chunk by finding the page number corresponding to the middle of the chunk:

chunk_metadatas = []
char_count = 0
for chunk in chunks:
    mid_point = char_count + len(chunk) // 2
    if mid_point < len(pages_meta):
        chunk_metadatas.append(pages_meta[mid_point])
    else: # A fallback for the very last chunk.
        chunk_metadatas.append(pages_meta[-1])
    char_count += 800 - 150 # Move character counter forward by (chunk_size - chunk_overlap).

Now we can create embeddings for each text chunk:

embedding_model = SentenceTransformer(EMBEDDING_MODEL)
embeddings = embedding_model.encode(chunks, convert_to_tensor=True, show_progress_bar=True)
embeddings = embeddings.cpu().numpy().astype('float32') # FAISS requires float32 numpy arrays.

Normalize the embeddings to unit length. This is necessary for using the Inner Product (IP) as a measure of cosine similarity:

faiss.normalize_L2(embeddings)

Let's populate the FAISS vector store:

index = faiss.IndexFlatIP(embeddings.shape[1])
index.add(embeddings)  # Add the chunk embeddings to the index.

and pack all of this into a handy structure for later use:

return {
    "index": index,
    "chunks": chunks,
    "metadatas": chunk_metadatas,
    "embedding_model": embedding_model
}

Feeding this structure into function below (as a db parameter), we can have a very simple and straightforward searching mechanism:

def search_vector_db(db, query, k):
    query_embedding = db["embedding_model"].encode([query], convert_to_tensor=True)
    query_embedding = query_embedding.cpu().numpy().astype('float32')
    faiss.normalize_L2(query_embedding)

    distances, indices = db["index"].search(query_embedding, k)
    retrieved_chunks = [db["chunks"][i] for i in indices[0]]
    retrieved_metadatas = [db["metadatas"][i] for i in indices[0]]

    return retrieved_chunks, retrieved_metadatas

query is a query. k is the number of chunks to be found.

Before we can use this for searching, we have to have a query first, so let's setup a simple chat with LLM.

Chat with LLM

Loading the LLM is pretty straightforward, if we remember the notes we stated in the beginning (the temperature etc.). We will load quite a big model, but quantize it to 4-bit precision, significantly reducing the size in RAM:

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True, 
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

tokenizer = AutoTokenizer.from_pretrained(LLM_MODEL)
model = AutoModelForCausalLM.from_pretrained(
    LLM_MODEL,
    quantization_config=bnb_config,
    device_map="auto",
    low_cpu_mem_usage=True
)
pipe = pipeline(
    "text-generation",
    model=model,
    tokenizer=tokenizer,
    max_new_tokens=512,
    max_length=None,
    temperature=LLM_TEMPERATURE,
    repetition_penalty=1.1,
    do_sample=LLM_DO_SAMPLE,
    return_full_text=False
)
return pipe

Preparing the interactive loop to chat

Pretty much everything below will be closed inside the loop, so we can have a chat:

while True:
    query = input("\nAsk a question (type 'exit' or 'quit' to quit): ")
    if query.lower() in ['exit', 'quit']:
        break

Searching the vector database

First thing to do after getting a query from a user, is to find relevant chunks of text, along with their page numbers, which we will use in sources citation.

context_chunks, context_metadatas = search_vector_db(db, query, k=SIMILAR_CHUNKS_COUNT)
context = "".join(context_chunks)

Having the relevant parts of the text, we have to prepare the LLM's part of the job.

Preparing the prompt template

This part will differ slightly depending on the LLM model used. Some models expect different prompt templates, regarding the user -> assistant loop:

template = f"""<start_of_turn>user
You're a helpful assistant. Answer the question based only on the context below.
Answer using the same language the question was asked in.\n
Context:\n
{context}\n
Question: {query}<end_of_turn>
<start_of_turn>model
"""

If we don't formulate this properly, model can start hallucinating the dialog and start talking to itself. Note we have passed not the text, but our chunks that have been found after the user's question.

Finally we can pass it to LLM and get the answer.

result = llm_pipeline(template)
answer = result[0]['generated_text'].strip()
print(answer)

Sources citation

We can also quote the pages containing the relevant material in the PDF:

seen_pages = set() # Use a set to avoid printing duplicate page numbers.
for meta in context_metadatas:
    page_num = meta.get('page', 'N/A')
    if page_num not in seen_pages:
        print(f"  - Page: {page_num} (Source File: {meta.get('source')})")
        seen_pages.add(page_num)

Final thoughts

As you can see above, it's very simple, much simpler than one could think: it's not LLM looking for the answer. It's FAISS looking through all our chunks of text and finding the most suiting ones. LLM is given only those found ones and all it's doing is recapping the small portion of the text and formulating nice text. Simple as that.

Now, your task now is to put it into some UI, for example a simple Streamlit which is perfectly suiting this type of job. Just add some button to load the PDF, input and text - and voilà 😄


Share on Share on Share on

Diagramify - automatic diagram creation for Notion

I'm often writing descriptions and ideas on projects in Notion. And I thought: it'd be nice to have such system descriptions summarized in a diagram of proper kind, be it a flow diagram, decision diagram etc. And it'd be nice to have it done automagically.

So, I've built a simple Notion integration. It's made of 2 parts: a server, acting like a Notion MCP server, connecting to api.notion.com, and a client using this server. That way I could omit using a full-blown official Notion MCP server, requiring full OAuth authorization, playing with tokens etc. It's just a simple tool running on my own local machine, connecting directly to API.

How it works?

The server part is mimicking what official MCP server is doing, but it's adapted to my needs, like if it gets a page, which is long, it gets care of getting all the parts, etc. That way the client itself is much easier to write and maintain. Both server and client are written in Python. All you need to do is to create an internal integration on Notion Integrations page. From there, take your Notion token and put it in .env file:

NOTION_TOKEN = "YOUR_NOTION_TOKEN"

Server

Server is using Starlette and MCP modules as its main core, using SSE Transport. And yes, it should be updated to use HTTP Stream Transport instead, but I've left that for some day. Now, the server has a number of tools the client can use. My list of tools is different from the original Notion MCP server, as it's suited for my task. For this simple integration it's: - list_accessible_pages: gets all the pages that were connected to our integration in Notion UI; connecting a page to an integration - get_notion_object: gets the object and its info. If it's a big object, get all the blocks. - upsert_mermaid_block: inserts or updates the Mermaid block.

Client

Now, how it's working? As the core functionality is automagic diagram insertion, I'm just using Gemini 2.5 Flash AI model to generate the diagram, feeding it with the whole text of the page. Inserted diagram is timestamped, so the client can know if the page was updated afterwards and needs refreshing of the diagram.

The exact flow of the client is: - get the list pages it has access to; - download whole page; - search for a signature with a timestamp; - if the signature doesn't exist, or the timestamp is older than the update date of the page: insert or update the mermaid block, the code itself comes from Gemini.

Simple as that.

There's also one thing to consider, while creating Mermaid diagrams with Gemini: while it's really good at it, it can sometimes do syntax errors, especially in complicated diagrams. That's why my prompt lists few rules.

Another thing is a hard limit of a single block length in Notion. It's only 2000 characters. It sounds as enough, but for large complicated diagrams it can be too small. Hence my prompt says exactly that and adds "if it's too long, simplify the diagram and omit less important elements".

Automating the run

I'm using Windows. For such tasks I like using the PM2. All it needs is creating a simple config file:

module.exports = {
    apps : [
    {
        name: "notion-server",
        script: "notion_server.py",
        interpreter: "./PythonEnv/python.exe",
        autorestart: true,
        watch: false,
        env: {
            NODE_ENV: "production",
            PYTHONIOENCODING: "utf-8",
            PYTHONUTF8: "1"
        }
    },
    {
        name: "notion-worker",
        script: "notion_client_local.py",
        interpreter: "./PythonEnv/python.exe",
        autorestart: true,
        restart_delay: 300000,
        watch: false,
        env: {
            NODE_ENV: "production",
            PYTHONIOENCODING: "utf-8",
            PYTHONUTF8: "1"
        }
    }
  ]
}

With this config, it will take care of running the server and running the client every 5 minutes.

The sources

All the sources are available on GitHub, just keep in mind it's in Polish (both comments and Gemini's prompt), but I think you can easily get the grasp of what it's doing. The code is really simple.


Share on Share on Share on

Diagramify - automatic diagram creation for Notion

I'm often writing descriptions and ideas on projects in Notion. And I thought: it'd be nice to have such system descriptions summarized in a diagram of proper kind, be it a flow diagram, decision diagram etc. And it'd be nice to have it done automagically.

So, I've built a simple Notion integration. It's made of 2 parts: a server, acting like a Notion MCP server, connecting to api.notion.com, and a client using this server. That way I could omit using a full-blown official Notion MCP server, requiring full OAuth authorization, playing with tokens etc. It's just a simple tool running on my own local machine, connecting directly to API.

How it works?

The server part is mimicking what official MCP server is doing, but it's adapted to my needs, like if it gets a page, which is long, it gets care of getting all the parts, etc. That way the client itself is much easier to write and maintain. Both server and client are written in Python. All you need to do is to create an internal integration on Notion Integrations page. From there, take your Notion token and put it in .env file:

NOTION_TOKEN = "YOUR_NOTION_TOKEN"

Server

Server is using Starlette and MCP modules as its main core, using SSE Transport. And yes, it should be updated to use HTTP Stream Transport instead, but I've left that for some day. Now, the server has a number of tools the client can use. My list of tools is different from the original Notion MCP server, as it's suited for my task. For this simple integration it's: - list_accessible_pages: gets all the pages that were connected to our integration in Notion UI; connecting a page to an integration - get_notion_object: gets the object and its info. If it's a big object, get all the blocks. - upsert_mermaid_block: inserts or updates the Mermaid block.

Client

Now, how it's working? As the core functionality is automagic diagram insertion, I'm just using Gemini 2.5 Flash AI model to generate the diagram, feeding it with the whole text of the page. Inserted diagram is timestamped, so the client can know if the page was updated afterwards and needs refreshing of the diagram.

The exact flow of the client is: - get the list pages it has access to; - download whole page; - search for a signature with a timestamp; - if the signature doesn't exist, or the timestamp is older than the update date of the page: insert or update the mermaid block, the code itself comes from Gemini.

Simple as that.

There's also one thing to consider, while creating Mermaid diagrams with Gemini: while it's really good at it, it can sometimes do syntax errors, especially in complicated diagrams. That's why my prompt lists few rules.

Another thing is a hard limit of a single block length in Notion. It's only 2000 characters. It sounds as enough, but for large complicated diagrams it can be too small. Hence my prompt says exactly that and adds "if it's too long, simplify the diagram and omit less important elements".

Automating the run

I'm using Windows. For such tasks I like using the PM2. All it needs is creating a simple config file:

module.exports = {
    apps : [
    {
        name: "notion-server",
        script: "notion_server.py",
        interpreter: "./PythonEnv/python.exe",
        autorestart: true,
        watch: false,
        env: {
            NODE_ENV: "production",
            PYTHONIOENCODING: "utf-8",
            PYTHONUTF8: "1"
        }
    },
    {
        name: "notion-worker",
        script: "notion_client_local.py",
        interpreter: "./PythonEnv/python.exe",
        autorestart: true,
        restart_delay: 300000,
        watch: false,
        env: {
            NODE_ENV: "production",
            PYTHONIOENCODING: "utf-8",
            PYTHONUTF8: "1"
        }
    }
  ]
}

With this config, it will take care of running the server and running the client every 5 minutes.

The sources

All the sources are available on GitHub, just keep in mind it's in Polish (both comments and Gemini's prompt), but I think you can easily get the grasp of what it's doing. The code is really simple.


Share on Share on Share on