I tried to build a SaaS. I’m shipping tiny libraries instead.

I tried to build a SaaS. I’m shipping tiny libraries instead.

For 7 days I poured energy into ChatProof — a testing framework for AI chat UIs. The problem was real: every team building Claude/GPT wrappers hits the same streaming-render bugs, and existing e2e tools weren’t built for non-deterministic LLM output. I had a competitive analysis. I had a PRD. I even had a name.

Then I admitted something to myself: product/market work just isn’t my strength.

I’m a builder. I love writing TypeScript. I love designing APIs. I love npm test going green. I do not love writing cold DMs. I do not love figuring out which subreddit my ICPs hang out in. I do not love crafting positioning documents.

Once I named it, the pivot was obvious.

The pivot

Instead of one ambitious SaaS, I’d ship tiny zero-deps libraries that solve specific Anthropic SDK pain points I personally hit. Each one:

  • < 500 lines of TypeScript
  • Zero runtime dependencies
  • MIT licensed
  • Published to npm
  • 1–2 weeks of work max

The goal isn’t to build a unicorn. It’s to leave a GitHub trail that demonstrates I can ship real code, get organic distribution through npm + search, and let the work compound over months.

The 5 days

Day 1 — claude-stream-collector

Consume Anthropic Messages streaming events into a typed CollectedResult. Handles tool_use input JSON delta accumulation (the SDK leaves this to you) and merges cache token usage across message_start + message_delta (the SDK overwrites). ~120 lines, 6 tests.

Day 3 — claude-retry

Smart retry for Anthropic API calls. Respects retry-after headers (which the SDK’s built-in retry doesn’t always honor), exponential backoff with jitter, custom retry predicates, AbortSignal support. ~180 lines, 25 tests.

Day 5 — claude-pricing

Calculate Claude API cost from token usage. Bundled pricing table with smart model-alias resolution (strips date suffixes), batch API discount, session CostTracker with budget alerts. ~180 lines, 19 tests.

Three packages, ~480 lines of code, 50 unit tests. All public. All on npm.

What I’m learning

1. Match the work to who you are, not who you think you should be.

Indie Hackers and Twitter are full of advice assuming you’re a generalist who loves every part of building a company. Many great engineers aren’t, and that’s fine. Tiny libraries are a perfectly valid path if you optimize for shipping over selling.

2. Zero-deps is a feature, not a constraint.

Every dependency is a future maintenance burden, a potential security issue, and a reason for someone to skip your package. For 100–500 line utilities, you almost never need anything but Node’s standard lib.

3. Series narratives compound.

Each package on its own is a small thing. But three packages with shared types (TokenUsage flows from claude-stream-collector → claude-pricing cleanly) form a tiny ecosystem. Each new package strengthens the others.

4. Build in public ≠ build for engagement.

I posted about each release on X and 即刻. Engagement so far: zero likes, zero replies. Downloads on the first package: 129 in 7 days. People install packages they discover via search and never engage with the announcement. That’s fine — the artifacts persist longer than any tweet.

5. Pivoting hurts less when you preserve learning.

ChatProof isn’t dead. The painpoint research, competitive analysis, and architecture thinking from those 7 days informed every package since. Especially: I knew the streaming pain because I’d done the homework.

What’s next

More small packages: claude-cache-helper, claude-tools-typed, maybe claude-prompt-toolkit. Same shape — small, focused, zero-deps. I’ll keep posting about them but won’t measure success in likes.

If you’re stuck on an indie project trying to be a “real founder,” maybe try shipping a single utility you’d actually use. You might find you ship four more before you’d have written a single landing page.

Three packages:

Building in public: ship-log-2026

One Open Source Project per Day #74: ai-engineering-from-scratch – Build AI Full-stack Skills from Ground Up

Introduction

ai-engineering-from-scratch is a hardcore and comprehensive curriculum for AI engineering. Instead of just teaching you how to call the OpenAI API, it takes you through the underlying mathematical principles, step-by-step, building Tokenizers, Backpropagation, Attention mechanisms, all the way to autonomous agent swarms.

The entire curriculum consists of 20 phases and 435 lessons, requiring approximately 320 hours of study. What makes it unique is that every lesson ships a reusable artifact: a prompt, a skill, an agent, or an MCP server that you can immediately plug into your daily workflow.

Core Features

  • Build from Scratch Philosophy: Follows the principle that the best way to understand something is to build it by hand. You implement algorithms with raw math/Python before moving on to frameworks like PyTorch.
  • Full Spectrum Coverage:

    • Math Foundations: Linear algebra to neural network first principles.
    • ML & DL Core: From classical ML to the evolution of Transformers.
    • Generative AI: Principles of image, video, and audio generation.
    • LLM Engineering: Training, fine-tuning, quantization, and production deployment.
    • Agent Engineering: ReAct loops, memory, multi-agent coordination, and autonomous systems.
  • Output-Oriented: At the end of each lesson, you don’t just “learn” something; you gain a transferable tool (e.g., a .md skill file for Claude/Cursor).

  • Multi-language Support: Primarily Python, but includes TypeScript, Rust, and Julia implementations.

How to Get Started

There are three ways to dive into the curriculum:

  1. Online Reading: Browse all docs at aiengineeringfromscratch.com.
  2. Clone & Run:

    git clone https://github.com/rohitg00/ai-engineering-from-scratch.git
    cd ai-engineering-from-scratch
    python phases/01-math-foundations/01-linear-algebra-intuition/code/vectors.py
    
  3. Agent-Driven (Recommended): If you use Claude, Cursor, or any agent with the curriculum skills installed, run /find-your-level. A 10-question quiz will map your knowledge to a personalized starting phase.

Conclusion

If you’re tired of “one-minute prompt hacks” and want to truly master the underlying logic of AI while building your own arsenal of engineering tools, ai-engineering-from-scratch is the most thorough and high-quality resource available today.

Project URL: rohitg00/ai-engineering-from-scratch

Welcome to my homepage to find more useful knowledge and interesting products.

Designing a Reliable Notification System for 1M+ Users (Push, SMS, Email)

In fintech, notifications are not a “nice-to-have” feature.

They’re part of the product’s trust layer.

If a user transfers money and doesn’t get a confirmation, they panic.
If an OTP arrives 3 minutes late, login fails.
If price alerts come twice, users lose confidence fast.

At small scale, sending notifications feels simple:

Application → Twilio/SendGrid → Done

But once you’re dealing with millions of users, multiple channels, retries, provider outages, and traffic spikes… notification systems become distributed systems problems.

And distributed systems are mostly about handling failure gracefully.

Imagine a fintech platform sending:

  • transaction alerts
  • OTPs
  • portfolio updates
  • price alerts
  • security notifications

…to over 1 million users across:

  • Push notifications
  • SMS
  • Email

The challenge isn’t just “sending messages.”

The real challenge is making sure the system:

  • doesn’t send duplicates
  • doesn’t silently lose messages
  • survives provider outages
  • scales during spikes
  • remains observable when things go wrong

Here’s the architecture I’d use.

High-Level Architecture

[App Service]
      │
      ▼
[Notification Queue (Redis Streams / SQS / Kafka)]
      │
      ▼
[Worker Pool]
      │
      ▼
[Provider Router]
      │
 ┌────┼───────────────────────────────┐
 ▼    ▼                               ▼
SMS  Email                           Push
 │      │                              │
 ▼      ▼                              ▼
Twilio SendGrid                      FCM/APNs
 │
 ▼
Fallback Providers
(Termii / Mailgun / Direct APNs)
      │
      ▼
[Delivery Log + Idempotency Store]
(PostgreSQL + Redis)

1. Queue-Based Architecture

One of the biggest mistakes teams make early on is sending notifications directly from the API request cycle.

That works… until traffic spikes.

Imagine Black Friday, a crypto market crash, or salary payment day.
Suddenly, millions of notifications need to go out almost at once.

If your application waits for Twilio or SendGrid to respond before returning a response to the user, your entire app becomes hostage to external providers.

That’s dangerous.

Instead, the API should do one thing:

Accept the request quickly and push a notification event into a queue.

From there, worker processes handle delivery asynchronously.

This changes the system completely.

Queues give you:

  • horizontal scalability
  • retry capability
  • backpressure handling
  • failure isolation

If providers slow down, the queue absorbs the spike instead of crashing your application.

At this scale, queues stop being optional infrastructure.
They become the safety buffer protecting the rest of your system.

Recommended technologies:

  • Redis Streams
  • AWS SQS
  • Kafka (especially for very high throughput systems)

2. Reliability & Idempotency

The hardest problem in notification systems usually isn’t failed sends.

It’s duplicate sends.

Users are surprisingly tolerant of delayed notifications.
They are not tolerant of receiving the same debit alert three times.

Retries are where duplicates usually happen.

Example:

  1. Worker sends SMS
  2. Provider actually succeeds
  3. Network timeout occurs before acknowledgment returns
  4. Worker assumes failure
  5. Worker retries
  6. User receives duplicate SMS

To prevent this, every notification should carry an idempotency_key.

Before sending, workers check:

“Have we already processed this exact notification?”

Example constraint:

UNIQUE(user_id, notification_type, idempotency_key)

This is one of those small architectural decisions that saves massive operational pain later.

Even if retries happen multiple times, the database becomes the final protection layer against duplicates.

Every delivery attempt should also be logged.

Not just successes — everything.

notification_attempts

Including:

  • provider used
  • response status
  • retry count
  • timestamps
  • provider error payloads

Because when something goes wrong in production, you want evidence, not guesses.

3. Provider Routing & Fallbacks

A reality every senior engineer eventually learns:

Third-party providers fail more often than you expect.

Twilio can degrade.
SendGrid can throttle requests.
FCM can delay pushes.

The mistake is designing systems that assume providers are always available.

Reliable systems assume failure is normal.

So instead of hardcoding a single provider, introduce a provider routing layer.

Example Routing Strategy

Channel Primary Fallback
SMS Twilio / Termii Flutterwave SMS
Email SendGrid Mailgun
Push Firebase FCM Direct APNs

The worker flow becomes:

  1. Attempt primary provider
  2. Detect timeout or failure
  3. Retry intelligently
  4. Automatically fail over if necessary

Users shouldn’t notice your provider had a bad day.

That’s the goal.

4. Retry Strategy

Retries sound simple until they start causing damage.

Bad retry systems can:

  • spam users
  • overload providers
  • amplify outages
  • generate huge costs

A common mistake is retrying too aggressively.

If Twilio is already struggling, hammering it with thousands of immediate retries only makes things worse.

Instead, use exponential backoff.

Example:

Retry #1 → 30 seconds
Retry #2 → 2 minutes
Retry #3 → 10 minutes

This gives providers time to recover while keeping pressure manageable.

And after maximum retries?

Move the message into a Dead Letter Queue (DLQ).

That queue is basically your “something unusual happened here” bucket.

At that point, engineers should be alerted.

5. Reconciliation Jobs

One subtle issue in distributed systems:

Sometimes providers say “accepted” even though delivery eventually fails.

That creates dangerous blind spots.

A notification may look successful internally while the user never actually receives it.

This is why reconciliation jobs matter.

Every few minutes, background jobs should scan for suspicious states:

Notifications stuck in "pending" for too long

Then:

→ Re-query provider APIs
→ Update delivery status
→ Retry if needed

These jobs quietly save systems from edge cases caused by:

  • webhook failures
  • transient outages
  • network interruptions
  • delayed provider processing

A lot of reliability engineering is really just building systems that continuously self-correct.

6. User Preferences & Rate Limiting

Good notification systems are not just reliable.

They’re respectful.

Users should control how they’re contacted.

Examples:

  • disable marketing SMS
  • mute non-critical notifications after 10PM
  • push-only preferences
  • email digests instead of instant alerts

Simple table:

user_notification_settings

…can dramatically improve user experience.

Rate limiting matters too.

Without it, bugs or loops can become expensive very quickly.

Imagine accidentally sending OTPs in a retry loop to thousands of users.

Redis-based limits help protect against this.

Example:

Max 3 SMS/hour/user

That protects:

  • users from spam
  • providers from overload
  • the business from runaway costs

7. Monitoring & Observability

At scale, invisible systems are dangerous systems.

You need to know:

  • Are queues growing?
  • Are workers failing?
  • Which provider is slowing down?
  • Which region is affected?

The most important metrics are usually boring operational ones.

Golden Signals

  • queue latency
  • queue depth
  • worker error rate
  • provider response times

Then business-level metrics:

  • delivery success rate
  • percentage delivered within 30 seconds
  • provider costs
  • user opt-out rates

And finally: alerts.

Example:

Alert if SMS failure rate exceeds 5% for 2 minutes

The earlier you detect degradation, the smaller the incident becomes.

8. Testing Failure Scenarios

The biggest difference between systems that look reliable and systems that are reliable is failure testing.

Because everything works in happy-path demos.

The real question is:

What happens when dependencies misbehave?

One useful strategy is shadow testing.

Route a tiny percentage of production traffic through a new provider and compare results safely.

Example:

  • compare latency
  • compare delivery rates
  • validate formatting consistency

Chaos testing is also incredibly valuable.

Example:

Intentionally fail 10% of Twilio requests in staging

That sounds scary initially.

But it validates whether:

  • retries actually work
  • failovers trigger correctly
  • reconciliation jobs recover messages

Reliable systems are engineered through controlled failure exposure.

Why This Architecture Works

What makes this architecture resilient is that it assumes bad things will happen.

Because eventually:

  • providers fail
  • queues spike
  • retries happen
  • networks become unreliable

The system survives because reliability is built into the architecture itself.

By combining:

  • queues
  • idempotency
  • retries
  • fallback providers
  • reconciliation jobs
  • observability

…the platform continues operating even during partial outages and heavy traffic spikes.

And in fintech, reliability isn’t just infrastructure quality.

It directly affects user trust.

Tech Stack Example

Component Suggested Tech
Queue Redis Streams / Kafka / SQS
Workers Laravel Queues / Go Workers / Node Consumers
Cache Redis
Database PostgreSQL
Monitoring Prometheus + Grafana
Alerts PagerDuty / Slack
SMS Twilio / Termii
Email SendGrid / Mailgun
Push Firebase FCM

Final Thoughts

Most notification systems work during normal traffic.

That’s not the hard part.

The hard part is surviving:

  • provider outages
  • duplicate retry scenarios
  • partial failures
  • sudden spikes in traffic

That’s where architecture starts to matter.

Because users rarely remember the notifications that worked.

They remember the moments when communication failed during something important.

Single-Node Data Engineering: DuckDB, DataFusion, Polars, and LakeSail

Architecture diagram showing the single-node data engineering ecosystem from local laptops to single-node engines querying S3

For the past decade, data engineering was synonymous with distributed clusters. If your dataset exceeded a few gigabytes, standard practice dictated spinning up an Apache Spark cluster on AWS EMR or Databricks. This distributed paradigm introduced massive operational complexity: managing JVM configurations, allocating executors, tuning shuffle partitions, and paying a substantial “serialization tax” to move data across network sockets and language runtimes.

Recently, the data engineering landscape has experienced a single-node renaissance. Rather than scaling out to distributed clusters, teams are scaling up on single machines. Modern laptops ship with 12 or more CPU cores, fast NVMe SSDs capable of multi-gigabyte-per-second read throughput, and up to 128 GB of RAM. Cloud providers offer single virtual machines with hundreds of cores and terabytes of memory for a fraction of the cost of a Kubernetes or Spark cluster.

This physical hardware evolution is only half the story. The true catalyst is a new generation of data technologies built on Apache Arrow, vectorized execution, and out-of-core memory management. Tools like DuckDB, Apache Arrow DataFusion, Polars, and LakeSail enable a single laptop or VM to process hundreds of gigabytes—and even terabytes—of data. You can now execute complex analytical pipelines locally or on a single node without the overhead of a distributed JVM runtime.

Architecture diagram showing the single-node data engineering ecosystem from local laptops to single-node engines querying S3

The Core Foundations: Columnar Memory and Apache Arrow

To understand how single-node data engineering can process datasets that previously required hundreds of cluster nodes, you must look at how data is structured in memory.

Traditional databases and processing runtimes designed for transactional workloads (OLTP) use row-oriented layouts. They store all fields of a single record contiguously in memory: [User_ID, Age, Name], followed by the next record. When executing analytical queries (OLAP) that only target a subset of columns (such as calculating the average age of users), a row-oriented engine must scan the entire record structure from memory. This process loads irrelevant data (like names and IDs) into the CPU’s L1/L2 caches, leading to cache pollution and wasted memory bandwidth.

Columnar query engines solve this inefficiency by storing data contiguously by column: [Age, Age, Age] in one buffer, and [Name, Name, Name] in another. The CPU only reads the specific columns required by the query.

Row-Oriented Layout (OLTP):
┌──────────────────────────────┬──────────────────────────────┐
│ ID 1 │ Age 1 │ Name 1        │ ID 2 │ Age 2 │ Name 2        │
└──────────────────────────────┴──────────────────────────────┘

Columnar Layout (Arrow/OLAP):
┌──────────┬──────────┐ ┌──────────┬──────────┐ ┌──────────┬──────────┐
│ ID 1     │ ID 2     │ │ Age 1    │ Age 2    │ │ Name 1   │ Name 2   │
└──────────┴──────────┘ └──────────┴──────────┘ └──────────┴──────────┘

Apache Arrow standardizes this columnar memory layout. It defines an open-source, language-independent specification for in-memory columnar data. By establishing a shared memory format, Arrow eliminates the serialization tax that historically slowed down data pipelines.

In traditional architectures, passing data between a Python script and a Java or C++ engine required serializing the data into a byte stream (like JSON or Protobuf) and deserializing it on the other side. This serialization tax frequently consumed up to 80% of the total query execution time.

Arrow enables zero-copy Inter-Process Communication (IPC). Because Arrow represents data in memory exactly the same way across Python, Rust, and C++, different processes can memory-map (mmap) the same physical memory buffers. An engine can pass a dataset to Python for machine learning or visualization by exchanging memory pointers. No bytes are copied, and no serialization occurs.

Furthermore, Arrow’s contiguous memory alignment matches the layout of modern CPU cache lines, making it straightforward to utilize Single Instruction, Multiple Data (SIMD) instruction sets (such as AVX-512 on Intel/AMD or Neon on ARM). SIMD allows the CPU to apply a single instruction (such as a filter comparison or an arithmetic addition) to a vector of data points in a single clock cycle. This hardware-level parallelism turns data processing from a memory-bound or CPU-bound bottleneck into an efficient operation running directly on the processor.

Comparison diagram showing the row-based layout versus Apache Arrow's columnar in-memory format and zero-serialization pointer exchange

In-Process SQL Powerhouse: DuckDB Architecture & Features

DuckDB has become the standard database engine for single-node SQL analytics. Designed as an in-process analytical database, DuckDB runs directly inside the host process (such as a Python interpreter or a CLI binary) rather than as a separate server daemon. This eliminates the network socket latency and IPC overhead of client-server databases like PostgreSQL or Snowflake.

DuckDB’s execution engine utilizes a vectorized query execution model. Rather than processing data one row at a time (the Volcano iterator model) or processing entire columns at once (which overflows L1/L2 caches for large tables), DuckDB processes data in small, cache-friendly vectors. These vectors typically contain 2048 elements.

Volcano Model:       [Row 1] ──► [Operator] ──► [Row 2] ──► [Operator]
Column-at-a-time:    [Entire Column (10M rows)] ──► [Operator] (Overflows Cache)
Vectorized Model:    [Vector of 2048 rows] ──► [L1/L2 CPU Cache] ──► [Operator]

By keeping these vectors small enough to fit inside the CPU’s L1/L2 cache, DuckDB minimizes memory bandwidth bottlenecks. The CPU executes operations on the vectors using SIMD instructions, keeping the execution pipelines saturated with data.

To handle datasets that exceed physical RAM, DuckDB implements out-of-core execution. When memory consumption reaches a user-defined limit, DuckDB’s buffer manager automatically spills intermediate query states (such as hash join tables, sorting buffers, or aggregation states) to temporary disk files. This spilling mechanism uses a block-based buffer pool that page-faults data to disk, allowing you to run queries on datasets that are multiple times larger than your system’s RAM.

In the latest v1.5.3 release (May 2026), DuckDB has introduced several updates that expand its single-node utility:

  • Quack Remote Protocol: DuckDB now ships with a core extension implementing the Quack protocol. This protocol allows users to run DuckDB in a client-server configuration when needed, facilitating remote attachments and remote query orchestration without losing the simplicity of the engine.
  • Ecosystem and Format Updates: The Iceberg extension has been upgraded to support MERGE INTO operations, making it possible to execute complex delta updates on Iceberg tables directly from a local DuckDB session.
  • AWS Security and IRSA: Native support for IAM Roles for Service Accounts (IRSA) has been added, simplifying secure S3 access when running DuckDB inside containerized single-node pipelines.
  • Static Linking: The distribution now statically links jemalloc on Linux platforms, improving memory allocation speed and reducing fragmentation during heavy out-of-core spilling.

The following Python script illustrates how to configure DuckDB’s memory limits, register an S3 credential using the new AWS extension features, and run a query that spills to disk:

import duckdb

# Initialize DuckDB connection
con = duckdb.connect(database='local_cache.db')

# Set memory limit to force out-of-core spilling on smaller datasets
con.execute("SET max_memory='8GB';")
con.execute("SET temp_directory='./duckdb_temp';")

# Load S3 and AWS extensions (built-in in v1.5.3)
con.execute("INSTALL aws;")
con.execute("LOAD aws;")

# Autodetect AWS credentials from environment (supports IRSA)
con.execute("CALL load_aws_credentials();")

# Query a large Parquet dataset directly on S3 with predicate pushdown
# DuckDB only downloads the columns and row groups that match the filter
query = """
    SELECT 
        user_id, 
        COUNT(event_id) as event_count,
        AVG(session_duration) as avg_duration
    FROM read_parquet('s3://my-lakehouse/bronze/events/**/*.parquet')
    WHERE event_date >= '2026-01-01'
    GROUP BY user_id
    HAVING event_count > 1000
    ORDER BY avg_duration DESC
"""

# Execute and stream results
result = con.execute(query).fetchdf()
print(result.head())

DuckDB’s combination of SQL support, vectorized performance, and out-of-core stability makes it a core tool for local analytical workloads.

DuckDB vectorized execution architecture showing chunked vector pipelines inside CPU cache and out-of-core spilling to SSD temp files

Extensible Rust Processing: Apache Arrow DataFusion

While DuckDB is packaged as an analytical database, Apache Arrow DataFusion is designed as an extensible query engine framework. Written in Rust and utilizing Apache Arrow as its native memory format, DataFusion is widely used to build other databases, query engines, and custom data platforms (including Bauplan, Spice.ai, and LakeSail).

DataFusion’s design is modular. It decouples the query planning, optimization, and execution stages. If you are building a custom data tool, you can register custom catalogs, write user-defined logical optimization rules (like custom predicate pushdowns), or plug in custom physical execution nodes.

For thread-level parallelism, DataFusion utilizes Rust’s asynchronous Tokio runtime. Rather than pinning execution to a fixed number of threads, DataFusion distributes physical plan fragments (represented as asynchronous streams of Arrow RecordBatch objects) across a Tokio worker thread pool. This allows the engine to adapt to multi-core architectures and avoid thread contention under heavy I/O loads.

In the recent v53.x and v54.x releases (early-to-mid 2026), the DataFusion community has introduced several optimizations:

  • Datetime Predicate Preimages: DataFusion now optimizes queries containing datetime functions (like date_trunc and date_part) by evaluating their mathematical “preimages.” Instead of executing the datetime function on every row, the optimizer rewrites the filter predicate against the raw partition bounds, enabling partition pruning.
  • Sort Pushdown Phase 2: The engine now sorts file groups by physical statistics before executing sort operators. If a set of Parquet files contains non-overlapping sorted ranges, DataFusion skips the global sort merge step, reducing planning and CPU execution times.
  • Null-Aware Anti-Joins: Support has been optimized for null-aware anti-joins, which frequently occur in SQL queries containing NOT IN clauses.
  • Variant Type Integration: The planner has introduced initial support for the binary VARIANT format, laying the groundwork for format-agnostic semi-structured data querying.

The following Rust code snippet demonstrates how to initialize a DataFusion context, register an in-memory Arrow table, and execute a query programmatically:

use datafusion::prelude::*;
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::arrow::schema::{DataType, Field, Schema};
use datafusion::arrow::array::{Int32Array, StringArray};
use std::sync::Arc;

#[tokio::main]
async fn main() -> datafusion::error::Result<()> {
    // Create a local execution context
    let ctx = SessionContext::new();

    // Define a simple schema
    let schema = Arc::new(Schema::new(vec![
        Field::new("id", DataType::Int32, false),
        Field::new("name", DataType::Utf8, false),
    ]));

    // Create Arrow arrays
    let id_array = Int32Array::from(vec![1, 2, 3, 4, 5]);
    let name_array = StringArray::from(vec!["Alice", "Bob", "Charlie", "David", "Eve"]);

    // Build the record batch
    let batch = RecordBatch::try_new(
        schema.clone(),
        vec![Arc::new(id_array), Arc::new(name_array)],
    )?;

    // Register the record batch as an in-memory table
    ctx.register_batch("users", batch)?;

    // Execute SQL query
    let df = ctx.sql("SELECT name FROM users WHERE id > 2").await?;

    // Print the physical execution plan
    df.show().await?;

    Ok(())
}

This library-first model makes DataFusion the preferred choice for teams building specialized, high-performance data systems.

DataFusion extensible Rust architecture showing SQL/DataFrame inputs compiled into physical plans running on Arrow memory, with pluggable catalogs and custom execution nodes

Vectorized DataFrames: Polars Eager & Lazy Pipelines

For developers working in Python, Rust, or JavaScript, DataFrames are the preferred API for data manipulation. While Pandas has been the standard in Python for a decade, it is single-threaded, has a high memory footprint (often requiring 5–10x the dataset size in RAM), and does not support query optimization.

Polars is a Rust-native, Arrow-backed DataFrame library designed to replace Pandas. It is optimized for multi-core execution, utilizing a custom work-stealing CPU scheduler that distributes execution chunks across available cores.

Polars offers two execution modes:

  1. Eager API: Executes operations immediately, step-by-step, mimicking Pandas’ behavior. This mode is useful for interactive debugging in Jupyter Notebooks.
  2. Lazy API: Builds a logical Directed Acyclic Graph (DAG) representing the pipeline. When you call .collect(), Polars passes the DAG through a query optimizer. The optimizer applies several rules:
    • Projection Pushdown: Only reads the columns explicitly referenced in the query.
    • Predicate Pushdown: Moves filter operations as close to the storage layer as possible (pushing them down into the Parquet reader).
    • Common Subexpression Elimination: Identifies duplicate calculations and executes them once.
Eager: Load File (All Columns) ──► Filter Rows ──► Select Columns
Lazy:  Query Planner ──► Push Filter & Select Into File Reader ──► Load File (Filtered & Pruned)

In 2026, the Polars team officially stabilized its streaming execution engine. This engine allows out-of-core DataFrame execution on datasets that exceed physical memory limits. The streaming engine now supports:

  • Streaming Merge and AsOf Joins: Useful for temporal alignments (such as joining financial tick data or IoT sensor metrics).
  • Streaming Aggregations: Complex statistical calculations (including skew, kurtosis, and entropy) can now run in streaming mode.
  • Direct Cloud Sinks: Polars can stream data directly back to storage formats like Delta Lake (sink_delta) and Apache Iceberg (sink_iceberg) without materializing the intermediate tables.

To enable the streaming engine, developers configure Polars to use the streaming execution path:

import polars as pl

# Enable streaming engine affinity globally
pl.Config.set_engine_affinity("streaming")

# Define a Lazy pipeline querying a folder of compressed CSVs
lazy_query = (
    pl.scan_csv("./data/raw_metrics/*.csv")
    .filter(pl.col("metric_type") == "cpu_utilization")
    .with_columns(
        (pl.col("metric_value") * 100).alias("percentage")
    )
    .group_by(["host_id", "timestamp"])
    .agg([
        pl.col("percentage").mean().alias("mean_cpu"),
        pl.col("percentage").skew().alias("skew_cpu") # Uses new streaming aggregations
    ])
    .sort("mean_cpu", descending=True)
)

# Execute the query out-of-core using the streaming engine
# This will process files in batches, avoiding Out-Of-Memory (OOM) crashes
result_df = lazy_query.collect(streaming=True)
print(result_df.head())

Polars’ combination of an expressive DataFrame API, lazy query optimization, and stabilized streaming makes it a powerful engine for Python and Rust developers.

Polars query planning diagram showing Eager sequential execution vs Lazy DAG optimization pathways with projection and predicate pushdowns

Comparative Analysis: Evaluating Single-Node Engines

Choosing the right tool requires evaluating their architectural differences and primary API surfaces:

Feature DuckDB Apache Arrow DataFusion Polars LakeSail (Sail)
Primary Language C++ Rust Rust Rust
API Types SQL, Python, R, Node.js, C++ SQL, DataFrame (Rust/Python) DataFrame (Python/Rust/JS) PySpark, Spark Connect SQL
Native Memory Format Custom Vector / Arrow IPC Apache Arrow Apache Arrow Apache Arrow
Vectorization Pattern Fixed-size Vectors (2048 rows) Arrow RecordBatches Contiguous Arrow arrays Arrow RecordBatches
Out-of-Core Method Buffer Pool Disk Spilling Streaming RecordBatch execution Streaming Engine (Lazy API) DataFusion-backed streaming
Primary Use Case SQL-first analytical queries Query engine library / framework Dataframe transformations JVM-free PySpark execution
Setup Complexity Very Low (single binary/import) Moderate (library setup) Low (pip install polars) Low (pip install pysail)

Key Tradeoffs to Consider

  • API Choice: If your team writes standard SQL, DuckDB is the logical starting point. If you write procedural code, Polars’ expression language is more expressive and easier to parallelize than SQL.
  • Extensibility vs. Out-of-the-Box Utility: DuckDB and Polars are complete user-facing applications. DataFusion is an engine framework. You use DataFusion if you are building a custom database or need to modify how the physical query execution layer functions.
  • Memory Footprint: DataFusion and Polars generally maintain a lower memory footprint than DuckDB for in-memory operations due to Rust’s memory management model and direct mapping to Arrow structures. However, DuckDB’s buffer manager is more mature for highly complex queries that require massive disk spilling.

Zero-JVM Spark: High-Performance Pipelines with LakeSail

For teams with existing data codebases, the primary blocker to adopting single-node tools is the legacy API footprint. Many organizations have thousands of lines of Apache Spark code written in PySpark. Rewriting these pipelines to DuckDB SQL or Polars DataFrames is expensive and introduces validation risks.

Historically, running PySpark locally required spinning up a local Spark cluster. This cluster runs on the Java Virtual Machine (JVM), which introduces significant configuration complexity and memory overhead. A default local Spark session can easily consume 4 GB of RAM just to start, even when processing a 10 MB CSV file.

Furthermore, PySpark operates via a Py4J gateway bridge. When your PySpark code calls a Python User-Defined Function (UDF), the data must be serialized, sent from the JVM to a Python worker process, processed, serialized again, and sent back to the JVM. This cross-process serialization tax makes Python UDF execution in Spark slow.

Traditional PySpark UDF Path:
[JVM Executor] ──(Serialize via Py4J)──► [Python Worker] ──► [Run UDF] ──(Serialize)──► [JVM Executor]

LakeSail (specifically the open-source Sail engine) solves this constraint. Sail is a Rust-native, JVM-free compute engine designed as a drop-in replacement for Apache Spark. It implements the Spark Connect protocol, allowing existing PySpark and Spark SQL applications to run unmodified by connecting to a Sail server over gRPC.

LakeSail PySpark Connect Path:
[PySpark Session] ──(Spark Connect gRPC Logical Plan)──► [LakeSail Rust Server] ──► [DataFusion Physical Execution]

Under the hood, Sail replaces Spark’s JVM-based Catalyst optimizer and Tungsten execution engine with Apache DataFusion and Apache Arrow. This architecture provides several advantages:

  • Zero JVM Overhead: Sail starts in milliseconds and has a negligible idle memory footprint. You can run Spark code on small single-core VMs or local laptops.
  • Zero-Copy Python UDF Execution: Sail embeds a Python interpreter directly into its Rust binary using PyO3. When executing a Python UDF, Sail passes pointers to the Arrow memory buffers directly to the Python interpreter. The UDF executes in-process without serialization, eliminating the cross-process Py4J bottleneck.
  • Native Open Formats: Sail includes native Rust-based support for Delta Lake, Apache Iceberg, and Parquet, integrating directly with AWS Glue, Unity Catalog, and Polaris REST catalogs.

To run your PySpark pipelines against a local Sail session, you install the packages and point the session builder to the local Sail gRPC port:

# Install pysail and PySpark client supporting Spark Connect
pip install pysail pyspark

Start the Sail server from your terminal:

# Start local Sail gRPC server on port 50051
sail spark server --port 50051

In your Python code, connect the SparkSession to the local Sail server using the standard remote connection string:

from pyspark.sql import SparkSession
from pyspark.sql.functions import col, udf
from pyspark.sql.types import IntegerType

# Connect to the local Sail Rust-native server over Spark Connect protocol
spark = SparkSession.builder 
    .remote("sc://localhost:50051") 
    .getOrCreate()

# Load a local Parquet dataset using standard Spark DataFrame API
df = spark.read.parquet("./data/raw_orders")

# Define a standard Python UDF
@udf(returnType=IntegerType())
def calculate_tax(amount):
    # This runs in-process via Sail's PyO3 integration
    # Zero serialization tax is paid between Rust and Python
    return int(amount * 0.08)

# Execute transformations and show results
processed_df = df.filter(col("status") == "COMPLETED") 
                 .withColumn("tax", calculate_tax(col("total_amount")))

processed_df.show()

By keeping the Spark API surface while replacing the execution engine, LakeSail allows teams to modernize their legacy PySpark pipelines and run them on single nodes without the overhead of a JVM.

LakeSail Spark Connect architecture showing PySpark client communicating over gRPC to a Rust-native Spark Connect server with DataFusion and PyO3 embedded UDF zero-copy memory buffers

The Threshold of Scale: When Does Single-Node Break?

While single-node data engineering has expanded the scale of data that can be processed on a single machine, it is not a silver bullet. At a certain point, physical resource constraints make single-node architectures impractical.

The primary bottleneck is I/O. During out-of-core execution, spilling data to disk shifts the bottleneck from memory capacity to disk read/write bandwidth. Even on fast NVMe SSDs, writing and reading hundreds of gigabytes of intermediate join tables or sorting buffers introduces latency. If a query spends more time reading and writing temporary blocks to disk than it does executing CPU cycles, the system is I/O-bound.

The second bottleneck is query planning and CPU execution scaling. If your query must scan multiple terabytes of data, even a vectorized engine running on 64 cores will take minutes to complete the scan. If your business SLAs require sub-second or low-second query latencies, you need to distribute the scanning and processing work across multiple machines in parallel.

The third bottleneck is organizational concurrency. If a single VM hosts your analytical database, and hundreds of analysts or BI dashboards query it simultaneously, the CPU cores will experience thread starvation, and lock contention will slow execution times for all users.

To guide your architectural transitions, use the following operational decision framework:

Metric Single-Node Range MPP Transition Trigger Distributed MPP Target
Compressed Data Volume < 100 GB > 500 GB – 1 TB Multi-TB to Petabytes
Target Query Latency Minutes (OK for batch/ad-hoc) < 3 – 5 Seconds Sub-second interactive BI
Concurrent Users / Queries < 5–10 concurrent sessions > 20+ concurrent queries Hundreds of concurrent dashboards
Data Topology Local files or single S3 bucket Federated across multiple sources Lakehouses, warehouses, transactional DBs

Performance-cost threshold graph showing single-node vs MPP execution efficiency zones based on data scale

The MPP Landscape: Scaling to Spark, Dremio, Bauplan, SpiceAI, and MotherDuck

When your data scale, latency requirements, or concurrency needs exceed single-node limits, you must transition to a distributed MPP (Massively Parallel Processing) architecture. The modern MPP landscape offers several pathways, depending on your workflow patterns.

MotherDuck (Dual Execution)

For teams who want to scale their DuckDB workloads to the cloud without managing infrastructure, MotherDuck provides a serverless platform built on DuckDB.

MotherDuck’s core architectural pattern is Dual Execution (formerly hybrid execution). When you submit a query, MotherDuck’s query planner evaluates the locations of the datasets. It splits the query plan: executing parts of the query locally on your laptop CPU using local cached data, and executing other parts on MotherDuck’s cloud compute nodes (for cloud-hosted Parquet or Iceberg tables). The engine joins these streams dynamically using specialized “bridge” operators.

In early 2026, MotherDuck added a native PostgreSQL wire protocol endpoint. This allows BI tools and legacy applications to connect directly to MotherDuck using standard PostgreSQL drivers, eliminating the need to install the DuckDB runtime on the client machine. Additionally, MotherDuck features Pulse (serverless) billing with one-second increments and DuckLake integration for scaling storage to the petabyte range.

MotherDuck Dual Execution model showing how queries are split by a Hybrid Planner between local laptop CPUs and MotherDuck Cloud Engines

Bauplan (Serverless Python Pipelines)

For data engineers building pipeline workflows on Apache Iceberg, Bauplan provides a serverless, “zero-infrastructure” execution engine.

Instead of managing Spark or Kubernetes clusters to run scheduled data transformations, you define your pipeline steps as standard Python or SQL functions. Bauplan spins up stateless, ephemeral compute on-demand to execute the code and shuts down immediately after, utilizing a pay-per-invocation model.

Bauplan integrates Apache Iceberg with Project Nessie, providing a “Git-for-data” experience. Developers and AI agents can create isolated branches of the lakehouse, run experimental Python pipelines to verify changes, and merge the updates atomically back into production without risking data corruption or paying for idle staging compute.

Spice.ai (Federated Query Acceleration)

Spice.ai (SpiceAI) targets the data access layer for high-performance applications and AI agents. It functions as a federated query runtime that accelerates slow data queries by materializing “hot” data sets locally.

Spice.ai implements a tiered caching model. It caches query results in-memory and caches active working sets of data in high-performance local engines like DuckDB or Cayenne (a native columnar engine).

In its recent v2.0 updates, Spice.ai introduced a prefix-aware list-files cache that speeds up data lake scans, a statistics cache for file metadata, and native Change Data Capture (CDC) syncing that streams updates from databases (like PostgreSQL WAL streams) directly into the local acceleration cache. This keeps the local cached tables updated in real-time without requiring complex Kafka or Debezium setups.

Dremio (Distributed MPP Lakehouse Platform)

For enterprise-scale BI, multi-source data federation, and semantic layer management, Dremio serves as the central engine of the lakehouse.

Dremio is built from the ground up on Apache Arrow, eliminating the serialization tax entirely. When Dremio queries data, the physical execution plan processes memory structures natively in Arrow columnar format and streams results to clients (such as Python scripts or BI tools) using Arrow Flight.

Dremio achieves sub-second performance on massive cloud data lakes through three architectural layers:

  • Columnar Cloud Cache (C3): Automatically caches data blocks from object storage (like AWS S3 or Azure ADLS) onto local NVMe drives at execution nodes, turning remote cloud I/O into local disk read speeds.
  • Reflections: Dremio’s query planner automatically and transparently substitutes physically optimized, pre-computed Iceberg materializations to accelerate user queries. As of Dremio v26, Reflections store data exclusively in Iceberg format, deprecating legacy formats to streamline the storage path. Dremio’s Autonomous Reflections use AI to observe query patterns over a rolling 7-day window, automatically creating, updating, and dropping Reflections to maintain optimal dashboard performance without manual administration.
  • Open Catalog (Powered by Apache Polaris): Dremio’s built-in catalog is built on Apache Polaris, which graduated to a top-level Apache project in 2026. The Open Catalog implements the Apache Iceberg REST specification, allowing other engines (like Spark or Flink) to query the same tables securely. It provides Fine-Grained Access Control (FGAC) including column-masking and row-level filtering.

Dremio’s AI Semantic Layer allows teams to define virtual datasets (views) once and reuse them across all BI and AI applications. This layer embeds descriptions, wikis, and tags directly onto columns and datasets. The semantic layer teaches AI models the business context of your data, allowing AI agents to generate correct, governed SQL queries rather than hallucinating generic code. Dremio also embeds generative AI features to auto-generate wiki descriptions and suggest tags based on schema patterns.

Dremio MPP query engine architecture showing Columnar Cloud Cache on NVMe, Iceberg-based Autonomous Reflections, Open Catalog powered by Polaris, and Arrow Flight client streaming

Architectural Selection Framework and Conclusion

Modern data engineering is no longer about choosing between a local script and a massive cluster. It is about matching your toolchain to your data volume, latency SLAs, and organizational needs.

To guide your selection, follow this decision tree:

  1. Is your workload running locally or on a single node?
    • If you prefer writing SQL for analytical queries: Use DuckDB. It requires zero configuration and handles larger-than-memory data via out-of-core spilling.
    • If you are writing procedural Python or Rust DataFrame pipelines: Use Polars. Its lazy optimizer and stabilized streaming engine provide rapid execution.
    • If you have legacy PySpark or Spark SQL code but want to avoid JVM overhead: Use LakeSail. It executes Spark Connect gRPC logical plans natively in Rust.
    • If you are building a custom query engine or analytical tool: Use Apache Arrow DataFusion as your modular compiler framework.
  2. Does your workload exceed single-node capabilities (multi-TB scale, high concurrency, or cross-source BI)?
    • If you want a serverless, hybrid extension of your DuckDB SQL code: Use MotherDuck.
    • If you need to build serverless Python pipelines directly on Iceberg with Git-like version control: Use Bauplan.
    • If you need to cache and accelerate federated data for local AI/RAG applications: Use Spice.ai.
    • If you need enterprise-scale BI, semantic governance, multi-source federation, and sub-second SQL queries on Iceberg: Use Dremio.

Flowchart decision tree helping engineers select the correct analytical engine based on workload and scale

Single-node data technologies have shifted the boundary of what is possible on a single machine. By utilizing Apache Arrow for zero-copy memory layouts, compilers like DataFusion, and vectorized execution engines, you can process workloads that previously required a complex distributed cluster.

As you design your next data platform, start by evaluating if your workload can run on a single node. Modern columnar engines let you build, test, and run pipelines with minimal infrastructure complexity. When your data scale or organizational concurrency requires a distributed architecture, transition incrementally using open standards like Apache Iceberg and Apache Arrow.

Accelerate Your Lakehouse Skills

To deepen your understanding of modern data architectures, consider the following next steps:

  • Read Lakehouse Reference Materials: Explore “Architecting an Apache Iceberg Lakehouse” and other technical publications that cover partition tuning, catalog design, and query optimization at books.alexmerced.com.
  • Build Your Own Local Pipeline: Start by downloading pysail or polars and testing them against a local Parquet dataset. Compare the query planning time and CPU memory footprint against your existing frameworks.
  • Evaluate Dremio Cloud: If your local query engines are hitting limits or you need to federate data across multiple sources, deploy Dremio directly on your S3 data lake. Try Dremio Cloud free for 30 days at dremio.com/get-started.

Why is VLAN Segmentation Overhyped in Small Networks?

Every new system or network engineer in the industry often starts by segmenting the existing network into slices, all in the name of “securing” it. In my 20 years of field experience, I’ve seen dozens of colleagues define 15 different VLANs even in 50-user offices, only to be crushed under the weight of their own rules. The answer to the question, Why is VLAN Segmentation Overhyped in Small Networks?, lies in that gray area where theoretical security textbooks clash with practical operational realities.

I’ve made this mistake repeatedly and had to correct it, both in the infrastructure of my own side products and in the medium-sized manufacturing facilities I’ve consulted for. In this article, I will explain, with concrete metrics and configuration examples, why VLAN segmentation in small networks is often an operational hindrance rather than a savior.

A Look at VLAN Segmentation in Small Networks: Theory vs. Practice

In theory, everything looks great: the Accounting department (VLAN 10) shouldn’t see Human Resources (VLAN 20), guests (VLAN 30) should only access the internet, and printers (VLAN 40) should be isolated. In classroom training or manufacturer sales brochures, this architecture is presented as “best practice.” However, the real world doesn’t work like that; when Ahmet from Accounting wants to access a shared folder on Elif’s computer in HR, or when he can’t print, your phone is the first to ring.

In a small network (e.g., environments with fewer than 100 hosts), each VLAN means a new IP block, a new gateway, a new DHCP pool, and most importantly, a new set of routing/firewall rules. When you divide a 100-device structure into 5 VLANs, you are essentially creating 5 different small networks that you need to manage.

Traditional Approach (Over-Segmentation):
[Users]        --(VLAN 10)--
[Printers]     --(VLAN 20)---+--> [Firewall / L3 Switch] --> [Internet]
[Servers]      --(VLAN 30)--/

Pragmatic Approach (Flat Network + Host-based Security):
[All Devices (Single Subnet /24)] ------> [Firewall] ------> [Internet]
  |_ (Software rules and endpoint security active)

In a past client project, I took over the network of an advertising agency with only 40 employees. There were exactly 8 VLANs defined in the office, and each department lived on its own island. When designers wanted to access the NAS device, packets would leave the edge switch, go to the core switch, then to the firewall, pass through rules, and return via the same physical path. Due to this unnecessary traffic, the 1 Gbps local network speed dropped to 150 Mbps, bottlenecked by the firewall’s IPS/IDS packet processing capacity (throughput).

Administrative Burden and the “Self-Hacking” Paradox

When you segment your network too much, your security level doesn’t increase; on the contrary, the likelihood of making mistakes multiplies due to management complexity. I call this “self-hacking” or operational blindness. After a while, so many rules accumulate that you start granting temporary Any to Any permissions on the firewall to solve urgent issues, and those temporary rules become permanent.

Take a look at the following Cisco IOS configuration. This ACL (Access Control List) mess, written just to connect two departments and a printer, turns into a nightmare as the network grows:

! Complex ACL example written on a Cisco Switch
ip access-list extended DEPT_ACCOUNTING_ONLY
 permit tcp 192.168.10.0 0.0.0.255 192.168.40.10 0.0.0.0 eq 9100
 permit ip 192.168.10.0 0.0.0.255 192.168.10.0 0.0.0.255
 deny ip 192.168.10.0 0.0.0.255 192.168.20.0 0.0.0.255
 deny ip 192.168.10.0 0.0.0.255 192.168.30.0 0.0.0.255
 permit ip any any
! You have to update these rules every time a new device is added.

Managing such rules becomes so difficult over time that the network administrator gives up when troubleshooting. As I also mentioned in my previous article on [related: firewall and gateway management], every manually managed and non-automated rule set eventually creates a security vulnerability. When you implement VLAN segmentation in small networks, you’re forced to reconfigure switch ports as trunk/access every time a new printer is acquired or a user’s desk changes.

⚠️ Operational Risk

If your network does not have dynamic VLAN assignment mechanisms (like 802.1X, RADIUS, etc.), port-based VLAN management makes you a slave to physical switches. Plugging a cable into the wrong port can halt an entire workflow.

IP Planning and Routing Deadlock: L3 Switch or Firewall?

One of the most critical architectural decisions when implementing VLAN segmentation in small networks is where to perform inter-VLAN routing. You have two options: either terminate and route this traffic on a Layer 3 switch, or push everything to the edge firewall device (Router-on-a-Stick).

If you push traffic to the firewall, the firewall’s port capacity and packet processing limits will create a bottleneck. For example, you might see the firewall CPU hit 100% when copying files to a backup server on the local network. If you perform routing on an L3 switch, you largely lose your ability to filter traffic between VLANs (apply security rules); L3 switches cannot perform stateful packet inspection like firewalls, they only use simple ACLs.

Method Performance (Throughput) Security Depth Ease of Management
L3 Switch Routing Very High (Wire-speed) Low (Stateless ACL only) Medium
Firewall Routing Low / Medium (CPU limited) Very High (Stateful Inspection / IPS) Difficult
Flat Network (Single Subnet) Highest Endpoint-Based Security Very Easy

Let me recount an incident I experienced while working on a production ERP system. Operator screens and barcode printers in the factory were on a separate VLAN, while the ERP server was on another. When operators pressed a button on the screen, the SQL query passed through the firewall to reach the server. During peak production hours (especially during shipment times), the firewall’s session table filled up, and packet loss began. To resolve the issue, we flattened the entire network architecture and moved the server and clients to the same L2 layer. Latency dropped from 12ms to 0.4ms.

DHCP, DNS, and Broadcast Traffic Myths: What Do Real Metrics Say?

One of the most common arguments made by VLAN proponents is to reduce the “broadcast domain” size. They claim, “There will be too much broadcast traffic on the network, switches will lock up, ARP packets will flood the network.” This argument was valid in the late 1990s when 10 Base-T hubs were in use. For today’s modern full-duplex gigabit switches and operating system network stacks, a broadcast domain of 254 devices is trivial.

Let’s analyze this with real data. In a flat network with an average of 150 active devices (/24 or /23 subnet), the metrics I obtained from a 1-hour packet analysis with Wireshark were as follows:

# Measuring broadcast packet ratio with Wireshark / tshark
$ tshark -r capture.pcap -q -z io,phs

===================================================================
Protocol Hierarchy Statistics
Filter: frames

eth                                      frames:1425300 bytes:1240500320
  ip                                     frames:1412000 bytes:1238400210
    udp                                  frames:85000   bytes:12400500
      dns                                frames:12000   bytes:1440000
  arp                                    frames:13300   bytes:851200   <-- Only 0.93%!
===================================================================

As can be seen, the ratio of ARP and other broadcast packets within all traffic is even below 1%. In a 1 Gbps bandwidth network, a few kilobytes per second of broadcast traffic can have no negative impact on performance.

Furthermore, you are forced to configure a separate DHCP helper (ip helper-address) or DHCP server for each VLAN. Defining separate blocks for each subnet in a Linux-based DHCP server (isc-dhcp-server or dnsmasq), managing IP conflicts, and ensuring DNS integration is unnecessary work for small teams.

Alternative and Pragmatic Security Approaches for Small-Scale Networks

So, if we’re not going to use VLAN segmentation, how do we ensure security? The answer is simple: move security from the network layer (L2/L3) to the application and endpoint layers. In the modern world, we call this zero-trust or host-based segmentation.

Having all devices on the same IP block in a company does not mean they can access each other without limits. All modern operating systems have built-in and centrally manageable firewalls (Windows Defender Firewall, Linux iptables/nftables, macOS PF).

# On a Linux server (e.g., running PostgreSQL), allow only specific IPs
# Using host-based firewall instead of traditional VLAN:
sudo iptables -A INPUT -p tcp -s 192.168.1.50 --dport 5432 -j ACCEPT
sudo iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 5432 -j REJECT

With this method, you can provide access control purely at the server level, without touching any switch configuration. Additionally, in Active Directory environments, you can disable file sharing (SMB – port 445) between all Windows clients with a single click using Group Policy (GPO). This way, you prevent a potential ransomware spread not at the network level, but directly at the operating system level.

I previously delved into the architectural details and software isolation techniques of this topic in my article on [related: network segmentation and zero-trust]. Applying those principles to small networks yields far more effective results than struggling with hardware VLANs.

Decision Matrix: When Should We Really Use VLANs?

Of course, we are not completely removing VLANs from our lives. There are some scenarios where using VLANs is truly essential and logical. The important thing is not to make it an obsession, but to implement it based on need.

The following decision matrix will provide you with a practical guide on whether or not you should implement VLAN segmentation in a network:

Situation / Need Is VLAN Necessary? Pragmatic Solution
Guest Wifi Network Yes A separate VLAN is essential to completely isolate guest traffic and prevent it from infiltrating the company network.
IP Phones (VoIP) Yes / Preferably Using a Voice VLAN is logical to prioritize QoS (Quality of Service) for VoIP packets.
Server Room / DMZ Yes Services exposed to the outside (web server, etc.) need to be isolated from the local network.
Department-Based Division No Use Windows GPO, local firewall rules, and Active Directory authorization.
Printers and IoT Devices No (Mostly) Fix printer IPs and allow access only via a print server.

If your network has fewer than 50 users and you don’t have critical servers providing external services, the ideal network design is: A single main subnet (users, printers, local servers) + a completely isolated single Guest VLAN. This simple structure keeps your operational burden close to zero, allowing you to dedicate time to application layer security, which is where you truly need to focus.

My Clear Stance

VLAN segmentation in small networks is often nothing more than a desire for technical personnel to “keep themselves busy” and feel like they are “managing a large enterprise network.” In reality, this over-engineering effort returns to you as outages, slowness, and administrative complexity, rather than security.

My clear stance on this matter is: Every line of configuration you don’t need is a source of a bug you will have to fix in the future. Instead of dividing the network, tighten security measures at the endpoints, establish strong authentication mechanisms, and keep your network infrastructure as simple, observable, and flat as possible. Ultimately, what will allow you to sleep peacefully at night are systems that are simple enough for you to manage, not complex switch rules.

yard-timekeeper: Stop YARD Timestamp Churn in Checked-In Docs

yard-timekeeper logo

yard-timekeeper logo

🕰️ I just released yard-timekeeper v0.1.0, a small RubyGem for Ruby projects that check generated YARD HTML into git.

It solves a very specific kind of documentation noise: timestamp-only churn.

The Problem

If your project publishes generated YARD documentation from a checked-in docs/ directory, you have probably seen this:

  1. You run the docs task.
  2. YARD regenerates HTML.
  3. Git reports changed files.
  4. You inspect the diff.
  5. The only change is the footer timestamp.

That is not a documentation change. It is build noise.

For projects that keep generated docs under version control, this creates unnecessary diffs, noisier pull requests, and extra review work. It also makes it harder to notice when documentation actually changed.

What yard-timekeeper Does

yard-timekeeper runs after YARD generates HTML and checks tracked files under docs/**/*.html.

If a file’s only change is the generated footer timestamp, it restores that file from git.

If the page has real content changes, it leaves the file alone.

The goal is not to hide documentation changes. The goal is to remove timestamp-only churn while preserving the signal that matters.

Install

gem install yard-timekeeper

Or add it with Bundler:

bundle add yard-timekeeper

Configuration

The supported workflow is through rake yard, so the post-process hook can run after YARD finishes.

In your Rakefile or documentation task setup:

require "yard/timekeeper"

Yard::Timekeeper.install_rake_tasks!(:yard)

Then generate docs with:

bin/rake yard

No .yardopts plugin entry is required for this integration. If you were experimenting with --plugin timekeeper, remove it for this workflow and use the rake integration instead.

Behavior

yard-timekeeper is intentionally conservative:

  • It only post-processes docs/**/*.html.
  • It only restores files already tracked by git.
  • It only restores files whose diff is timestamp-only.
  • It preserves real generated documentation changes.
  • It can be disabled with YARD_TIMEKEEPER_DISABLE=true.

That means new documentation pages, deleted pages, and pages with real content edits remain visible to git.

Why This Exists

I maintain a lot of Ruby gems, and many of them publish YARD docs from checked-in HTML. I want documentation generation to be repeatable without filling commits with timestamp changes.

Small tooling like this is not glamorous, but it improves the daily maintenance loop. Cleaner diffs mean easier review. Easier review means fewer mistakes.

Links

yard-timekeeper v0.1.0 is available now.

🕰️ May your docs be fresh, and your diffs quiet.

Mutation Testing for Dummies

Tests are important when it comes to implementing source code. Developers need ways to ensure the security of their source code as it becomes more complex. Increased complexity means that tests may become less capable of catching bugs. Mutation testing allows developers to test the weaknesses in their test suite, which shows what edge cases need to be handled and what constraints must be added to prevent farther bugs.

Mutation testing is a process developers use by creating bugs within their source code and running tests that should fail (failing tests mean a bug was found). When tests run, if the bug has survived, the tests meant to catch it were not thorough enough to handle certain edge cases. Mutation testing is classified as a white-box testing method which is best described as: a system that examines the internal functionality of an application. This is widely used in languages like Java and XML(a tool that stores and transports data). It’s purpose is to find code that may not be nested properly, discover new errors, and to understand how errors spread within the application. The goal is to prevent these errors from happening again in the future.

Mutation testing tools are used mostly for continuous integration and continuous delivery. CI/CD is a modern practice of automated building testing and releasing applications. This operation entails committing code to a shared repository frequently. Typically CI/CD without Mutation Testing requires a waiting period. Usually wait times without Mutation Testing with CI/CD jobs are due to a weak test suite. Mutation Testing can make that process much more efficient due to a decreased wait time for CI/CD jobs to finish.

The first implementation testing (documented) was by Timothy Budd as part of his PHD study titled “Mutation Analysis” in 1980. It was originally proposed by Richard Lipton in 1971. Then in 2004, “Certess inc.” added to much of the practice as part of hardware verification. Today, Mutation Testing is based upon two hypothesis; One being the Competent Programmer Hypothesis: this is where programmers write programs that are close to being correct. The other being The Coupling Effect: This is where simple mistakes can snowball into much more apparent and/or pervasive ones.

Mutation testing types can vary. One type of Mutation testing is called Value Mutation Testing. This is when values that are usually assigned to constants are changed to test whether tests fail or whether the mutations survive. Failing tests is a good thing, as it provides insight that the tests already implemented are thorough at catching bugs. Mutations surviving however, implies the opposite.

const num = 35;

changed:

const num = 3000005

The second type of Mutation Testing is Decision based. Decision based testing is when arithmetic operators are changed. Sometimes the changes can be slight, other times the changes can be apparent.

if(dog = cat)

changed:

if(dog >= cat)

Similar to Decision Based Testing, Statement Mutation testing is when one statement or variable name is replaced with another during reassignment. In this example giraffe is the variable that is being changed.

if(dog < cat)
  let giraffe = 10;
else
  let giraffe = 20;

changed:

if(dog < cat)
  let bunny = 10;
else
  let bunny = 20;

High Order Mutation Testing is when we change more than one single operation or value which can make it more difficult for tests to catch. These changes are usually too subtle to notice at first glance, however when you take a closer look you can see where the bug is.

let function (int a, int b){
 if (dog = 1):
 return cat < dog
 return cat > dog
}

changed:

let function (int a, int b){
 if (dog = 1):
 return cat <= dog
 return cat > dog
}

Mutation Testing can be simply implemented by first creating the source code. Once the code is created, inject mutants. Use one of the types of Mutation Testing to create small bugs that the test suite should hopefully catch. Next, run the tests. If a test fails on the line where the bug was created, then that test is solid. Otherwise, the bug survived and the tests are not thorough enough to prevent that bug from infecting the rest of the source code. Calculate how many tests failed and from there, rebuild the tests in accordance to any edge cases or general constraints that should be added.

The great thing about Mutation Testing is that it allows for more secure test updates. When a bug is not caught, developers can know exactly what to fix and how to make it more secure. Finding bugs that our unit tests can not find provides more efficient code. On the downside, it is very time consuming, and it is not available for Black Box Testing(evaluating functionality of an application without knowing it’s source code). In addition to this, some mutations are too complex to debug, making it difficult to implement corrective code or test against mutations. In all, Mutation Testing is a great method to implement when looking to create more secure code and build an even more secure test suite.

(https://www.computer.org/csdl/journal/ts/2024/05/10472898/1VpY3do6cyA)
https://stryker-mutator.io/docs/
https://www.computer.org/csdl/journal/ts/2024/05/10472898/1VpY3do6cyA(“IEEE Transactions on Software Engineering”; May 2024)
https://www.geeksforgeeks.org/software-testing/software-engineering-white-box-testing/
https://www.redhat.com/en/topics/devops/what-is-ci-cd(“redhat.com“; June 10, 2025)
https://bell-sw.com/blog/a-comprehensive-guide-to-mutation-testing-in-java/
(https://www.geeksforgeeks.org/devops/what-is-ci-cd/)
https://www.diffblue.com/resources/what-is-mutation-testing/
https://en.wikipedia.org/wiki/Mutation_testing
(https://www.geeksforgeeks.org/software-testing/software-engineering-black-box-testing/)
https://www.sciencedirect.com/science/article/abs/pii/S0950584909000688
https://www.cs.cmu.edu/~ckaestne/pdf/fse20hom.pdf

Qwen 3.6 27B and 35B MTP vs Standard on 16GB GPU

I tested Speculative decoding (Multi-Token Prediction, MTP) performance in Qwen 3.6 27B and 35B on an RTX 4080 with 16 GB VRAM.

For a broader view of token speeds and VRAM trade-offs across more models on the same hardware, see 16 GB VRAM LLM benchmarks with llama.cpp.

What MTP (Multi-Token Prediction) Is

Multi-Token Prediction is a form of speculative decoding built directly into certain model checkpoints. Instead of predicting one token per forward pass, the model carries extra “MTP heads” that propose several future tokens in a single step — then verifies them in parallel. If the guesses are accepted, the effective throughput rises without changing the output quality.

The Qwen 3.6 family ships both standard GGUF files and MTP-enabled variants. In llama.cpp, MTP is activated through:

--spec-type draft-mtp --spec-draft-n-max 3

--spec-draft-n-max is the key tuning knob. It sets how many speculative tokens the MTP head proposes at each step. Higher values give a potential speed boost but cost extra VRAM for the draft buffers — a real constraint on 16 GB cards.

What and How I Tested

I tested how the two Qwen 3.6 models behave with MTP enabled versus standard decoding on a GPU with 16 GB VRAM (RTX 4080).

To fit model weights and KV cache into VRAM I used heavily quantised variants:

  • Qwen3.6-27B-UD-IQ3_XXS and Qwen3.6-27B-UD-IQ3_XXS-MTP
  • Qwen3.6-35B-A3B-UD-IQ3_S and Qwen3.6-35B-A3B-UD-IQ3_S-MTP

Two context budgets are tracked per run:

  • Avg Ctx — the context size at which llama.cpp occupies ~14.8 GB VRAM, leaving other apps (Xorg, GNOME Shell, Cursor) a comfortable ~500 MB buffer.
  • Max Ctx — the largest context llama.cpp could allocate given that the same desktop apps already hold ~500 MB VRAM.

A key reason for keeping the average context at a practical target is that Hermes Agent — which I use as the primary AI assistant connecting to llama.cpp on this machine — requires at least 64 K context by default and will reject models with a smaller window at startup. Models below that threshold cannot maintain enough working memory for multi-step tool-calling workflows. For llama.cpp this means passing --ctx-size 65536 or larger. Any MTP configuration that compresses the average usable context significantly below 64 K is therefore unsuitable for daily Hermes workloads, which is why the Avg Ctx numbers in the tables below are the most decision-relevant ones.

Both KV cache quantisation levels were tested: q8 (higher quality, more VRAM) and q5 (lower VRAM, longer context). Be aware that moving from q8 to q5 KV cache can cause a noticeable quality drop — in my testing the degradation was significant enough to make q5 unsuitable for my workloads. The speed and context numbers for q5 are included for completeness, but you should test response quality on your own tasks before committing to it.

Qwen 3.6 27B MTP vs Standard

KV Cache q8

MTP max 1 MTP max 2 MTP max 3 MTP max 4 Standard (IQ3_XXS)
Prompt Speed 148 t/s 151 t/s 148 t/s 147 t/s 200 t/s
Gen Speed 65 t/s 75 t/s 73 t/s 75 t/s 45 t/s
Avg Ctx 40 K 40 K 40 K 30 K 80 K
Max Ctx 60 K 60 K 60 K 50 K 100 K

With q8 KV cache, MTP at --spec-draft-n-max 2 delivers ~67 % faster generation (75 vs 45 t/s) at the cost of halving the average context window from 80 K to 40 K. Prompt ingestion speed drops from 200 to ~150 t/s because MTP requires device-to-host transfers during the prefill phase.

KV Cache q5

MTP max 1 MTP max 2 MTP max 3 MTP max 4 Standard (IQ3_XXS)
Prompt Speed 145 t/s 144 t/s 141 t/s 139 t/s 191 t/s
Gen Speed 57 t/s 62 t/s 67 t/s 66 t/s 41 t/s
Avg Ctx 70 K 60 K 60 K 50 K 130 K
Max Ctx 100 K 100 K 90 K 80 K 160 K

Switching to q5 KV cache recovers meaningful context: --spec-draft-n-max 1 gives 70 K average context at 57 t/s — a 39 % generation speedup over standard decoding while still keeping the context window at a useful size. At --spec-draft-n-max 3 the context drops to 60 K but generation reaches 67 t/s (+63 %).

Qwen 3.6 27B Takeaway

MTP is genuinely useful for the 27B dense model. The sweet spot on 16 GB VRAM is:

  • q8 KV + --spec-draft-n-max 2 — best raw speed (75 t/s), context down to 40–60 K
  • q5 KV + --spec-draft-n-max 1 — best speed-vs-context balance (57 t/s, 70 K avg context)

Qwen 3.6 35B MTP vs Standard

The 35B model is a Mixture-of-Experts (MoE) architecture (35B-A3B means 35B total parameters, ~3B active per token). MoE models usually benefit more from MTP because the sparse routing keeps the MTP head computationally cheap relative to a full forward pass.

KV Cache q8

MTP max 1 MTP max 2 MTP max 3 MTP max 4 Standard (IQ3_S)
Prompt Speed 277 t/s 277 t/s 265 t/s 275 t/s 368 t/s
Gen Speed 186 t/s 189 t/s 180 t/s 171 t/s 146 t/s
Avg Ctx 15 K 10 K — — 80 K
Max Ctx 80 K 70 K 60 K 50 K 150 K

The MoE architecture delivers impressive raw generation speed with MTP (+27 % at max 1, +29 % at max 2 vs standard 146 t/s). But the practical problem is the average context. With q8 KV cache, even --spec-draft-n-max 1 only gives 15 K average context — barely enough for modest tasks. Higher draft depths have no viable average context at all on a 16 GB card.

This is the central VRAM cost question for MTP on consumer hardware: the extra draft buffers eat into the remaining VRAM budget directly, and the 35B-A3B model with q8 KV cache leaves very little headroom.

KV Cache q5

MTP max 1 MTP max 2 MTP max 3 MTP max 4 Standard (IQ3_S)
Prompt Speed 264 t/s 266 t/s 270 t/s 264 t/s 343 t/s
Gen Speed 151 t/s 147 t/s 137 t/s 131 t/s 122 t/s
Avg Ctx 10 K — — — 120 K
Max Ctx 120 K 110 K 110 K 80 K 200 K

q5 KV cache only marginally improves the average context story. --spec-draft-n-max 1 gives 10 K average context at 151 t/s. Standard decoding at q5 gives 122 t/s with 120 K average context.

Qwen 3.6 35B Takeaway

On a 16 GB GPU the 35B MoE model with MTP faces a hard wall: the average usable context collapses to 10–15 K tokens, making it impractical for real workloads. Standard decoding at 122–146 t/s with 80–120 K context is significantly more useful.

If you have 24 GB+ VRAM, the 35B + MTP combination becomes much more attractive — the context window issue disappears and you keep the speed benefit.

Choosing the Right --spec-draft-n-max Value

The question of how many speculative tokens to propose per step (--spec-draft-n-max) does not have a single right answer — it depends on both model architecture and available VRAM:

  • For 27B dense on 16 GB: --spec-draft-n-max 2 with q8 KV is the fastest, --spec-draft-n-max 1 with q5 KV is the most context-friendly.
  • For 35B MoE on 16 GB: --spec-draft-n-max 1 is the only option that keeps any usable context, and even then only marginally.
  • Higher values (3, 4) increase VRAM pressure without proportional speed gains — at max 4 you’re spending roughly the same extra VRAM as max 2 but gen speed doesn’t keep pace.

How to Enable MTP in llama.cpp

Make sure you use an MTP-enabled GGUF (the filename contains MTP). If you are new to llama.cpp flags, the llama.cpp Quickstart with CLI and Server covers all the fundamentals. Then launch llama-server or llama-cli with:

llama-server 
  --model Qwen3.6-27B-UD-IQ3_XXS-MTP.gguf 
  --ctx-size 40000 
  -ngl 99 --flash-attn on 
  --cache-type-k q8_0 --cache-type-v q8_0 
  --spec-type draft-mtp 
  --spec-draft-n-max 2

For q5 KV cache, replace q8_0 with q5_1 or q5_0 and adjust --ctx-size upward:

llama-server 
  --model Qwen3.6-27B-UD-IQ3_XXS-MTP.gguf 
  --ctx-size 80000 
  -ngl 99 --flash-attn on 
  --cache-type-k q5_1 --cache-type-v q5_1 
  --spec-type draft-mtp 
  --spec-draft-n-max 1

MTP is activated automatically once llama.cpp sees the MTP heads in the GGUF file and --spec-type draft-mtp is set.
So standard Qwen3.6-27B-UD-IQ3_XXS.gguf will not work in MTP mode, you will need Qwen3.6-27B-UD-IQ3_XXS-MTP.gguf.
But the Qwen3.6-27B-UD-IQ3_XXS-MTP.gguf can work in both Speculative decoding mode and autoregressive one.

Conclusion

On a 16 GB GPU (RTX 4080), with these quants, llama.cpp’s MTP is a clear win for Qwen 3.6 27B and a net negative for Qwen 3.6 35B in practical use:

Qwen 3.6 27B (IQ3_XXS) — MTP is worthwhile:

  • q8 KV + MTP max 2 → ~67 % faster generation, context 40–60 K (vs 80–100 K without MTP)
  • q5 KV + MTP max 1 → ~39 % faster generation, context 70–100 K (vs 130–160 K without MTP)
  • Good balance of speed and VRAM efficiency at --spec-draft-n-max 2

Qwen 3.6 35B (IQ3_S) — MTP is not practical at 16 GB:

  • Generation speed is 27–29 % higher but average context collapses to 10–15 K at q8, 10 K at q5
  • Standard decoding at 122–146 t/s with 80–120 K context is more useful for real tasks
  • The situation improves substantially on 24 GB+ VRAM

On paper, q5 KV cache is the obvious answer for maximising context window while keeping MTP speed gains — but in practice the quality drop moving from q8 to q5 can be significant. Test q5 on your own tasks before adopting it; for my workloads the degradation was unacceptable, and q8 with a tighter context budget remains the better trade-off.

For the wider picture of LLM serving options and infrastructure trade-offs, see the LLM Hosting in 2026 pillar and LLM Performance in 2026. If you are tuning Qwen 3.6 sampler settings alongside MTP, the Agentic LLM Inference Parameters Reference for Qwen 3.6 and Gemma 4 is a useful companion.

PID Tuning That Actually Converges: Ziegler-Nichols and the Root Locus View

The PID controller is the workhorse of industrial control. It runs temperature loops, motor drives, flow valves, and flight surfaces. The algorithm itself is three terms and a line of arithmetic. The hard part has never been the controller — it is choosing the three gains. Set them too low and the system crawls toward its target. Set them too high and it oscillates, or worse, runs away. “Tuning” is the craft of finding the gains that land in between.

This article covers two complementary ways to tune a PID loop: the empirical Ziegler-Nichols method and the graphical root locus view — and shows how they describe the same boundary from different sides.

Why this calculation matters

An untuned loop is not a minor inconvenience. A sluggish temperature controller wastes energy and product. An oscillating position loop hammers the mechanics and shortens equipment life. An unstable loop is a safety incident. Between those failure modes sits a band of gains that gives a fast, well-damped response — and tuning is how you find that band on purpose rather than by trial and error at the plant.

The two methods matter because they suit different situations. Ziegler-Nichols needs only a simple experiment on the real system and no model. Root locus needs a model but, in return, shows you the whole stability picture at a glance. A capable engineer reaches for whichever the situation allows.

The two methods

Ziegler-Nichols, ultimate-sensitivity form. Disable the integral and derivative terms, leaving proportional control only. Raise the proportional gain until the loop sits in a steady, sustained oscillation — neither growing nor decaying. Record two numbers: the gain that produced it, the ultimate gain Ku, and the period of the oscillation, the ultimate period Pu. The classic PID settings then follow from a small table:

Kp = 0.6 * Ku
Ti = Pu / 2          (integral time)
Td = Pu / 8          (derivative time)

In the parallel form the integral and derivative gains are Ki = Kp/Ti and Kd = Kp·Td.

Root locus. This is the model-based companion. The root locus is a plot of where the closed-loop poles travel in the complex plane as the proportional gain increases from zero. Poles in the left half-plane mean a stable, decaying response; poles crossing into the right half-plane mean instability. The exact gain at which the locus crosses the imaginary axis is the ultimate gain Ku, and the frequency at that crossing gives the ultimate period through Pu = 2 pi / omega. The two methods are looking at the same stability boundary — Ziegler-Nichols finds it by experiment, root locus finds it by geometry.

A worked example

Suppose the ultimate-sensitivity experiment on a process gives an ultimate gain Ku = 8 and an ultimate period Pu = 2 seconds. Apply the Ziegler-Nichols PID table:

Kp = 0.6 x 8   = 4.8
Ti = 2 / 2     = 1.0 s   ->  Ki = Kp / Ti = 4.8
Td = 2 / 8     = 0.25 s  ->  Kd = Kp x Td = 1.2

So a starting controller is Kp = 4.8, Ki = 4.8, Kd = 1.2. Note the word starting. Classic Ziegler-Nichols deliberately targets a fairly aggressive response — roughly quarter-amplitude decay, meaning each overshoot is about a quarter of the one before. That is lively. For a loop that must not overshoot, back the proportional gain down by 20 to 40 % from the table value and re-check. Ziegler-Nichols gets you into the right neighbourhood fast; the final trim is yours.

The root locus view of the same system would show the closed-loop poles starting on the open-loop poles, sweeping outward as gain rises, and crossing the imaginary axis exactly at gain 8 — confirming Ku — at a frequency of pi rad/s, which gives Pu = 2 pi / pi = 2 s. Same two numbers, reached without ever oscillating the real plant.

Common mistakes

Running the Ku experiment on a fragile plant. Driving a real system into sustained oscillation is not always safe — think of a large thermal mass or a machine with travel limits. When oscillating the plant is risky, use a model and root locus, or a relay-feedback test that bounds the swing.

Leaving the gains at the textbook values. The Ziegler-Nichols table is a first guess tuned for disturbance rejection, not for a smooth setpoint response. Treat its output as iteration zero.

Over-using the derivative term. Derivative action amplifies measurement noise. On a noisy sensor a large Kd makes the actuator chatter. Filter the derivative, or reduce it, before blaming the loop.

Forgetting the sample rate on a digital controller. A discrete PID is not the continuous one. If the loop runs too slowly relative to the process dynamics, the phase lag from sampling erodes the stability margin the tuning assumed.

Try the interactive NovaSolver calculator

Tuning is much easier to learn when you can see the step response change as you move the gains. The PID tuning tool on NovaSolver lets you apply Ziegler-Nichols settings and then drag Kp, Ki, and Kd to watch overshoot, settling time, and steady-state error respond in real time.

Related calculators

  • PID controller response — to see how each of the three terms shapes the output on its own.
  • Root locus — the graphical stability picture, including the imaginary-axis crossing that defines Ku.
  • Digital PID discretization — how sample rate and discretization affect a controller running in software.

The full set is in the PID and controls tools hub.

Closing note

PID tuning has a reputation as a black art, but the two methods here remove most of the mystery. Ziegler-Nichols gives you a fast, model-free starting point from one experiment. Root locus gives you the whole stability map if you have a model, and it pinpoints the exact boundary the experiment is feeling for. Use them together: locate the stability edge, back off to a sensible margin, and trim against the response you actually want. That is tuning done deliberately rather than by luck.

Cx Dev Log — 2026-04-27

Nothing landed today in the Cx language project, making it a perfect opportunity to delve into something that’s been lurking in the background: the merge gap between submain and main. This isn’t just a gap; it’s the biggest structural challenge we’re facing right now.

The Merge Gap

Submain sits 21 commits ahead of main. And these aren’t trivial updates. These commits incorporate crucial updates: Phases 10 and 11 of the IR backend covering while/for/loop/break/continue lowering, unary expression transitions, a reworked error model with Result, integer overflow enforcement, optional semicolons throughout the language, comprehensive audits on parser/semantic/interpreter agreement as well as memory boundary soundness, a diagnostics readability pass, and the version bump for the roadmap to v5.0—with all 9 hard blockers resolved.

Main is still at version 4.8 with 8 hard blockers hanging overhead. Test data paints the same picture: main holds at 78/78 tests, whereas submain runs ahead with 117/117. Merging these would address a significant 39-test disparity in one swift action.

Every daily log over the past week has identified “merge submain into main” as a critical next step. Yet, the merge isn’t happening. It’s not a crisis at this moment, but the longer submain stays diverged, the more complex merging will become if new work gets added to either branch independently.

Branch Housekeeping

Alongside the merge gap, there’s another mess that needs addressing: the proliferation of daily-log branches. 26 branches from March 29, 2026, to April 26, 2026, sit unmerged in the remote repository. Only two—March 28 and March 30—made it via PRs. The remaining 24 persist as operational noise. They’re not blocking anything critically, but tidying up would certainly clean the remote branch list.

Yesterday’s Expectations vs. Reality

The last log suggested four next actions: the submain to main merge, progressing through Phase 11 expression lowering, addressing the IR backend test failure, and clearing out the daily-log branch backlog. None of this happened. Development was idle, with zero new contributions made—in stark contrast to the day’s predictions.

What’s Next

What’s striking is how the priority list has remained consistent, which says something about our current state. Based on leverage:

  1. Merge submain to main. This is the highest priority action. It upgrades main from v4.8 to v5.0, addresses all remaining hard blockers, and closes the 39-test gap.
  2. Continue Phase 11 expression lowering. Remaining unsupported expressions like ArrayLit, Index, MethodCall, StructLit, HandleVal, HandleDrop, and Range in lower.rs need handling.
  3. Fix the IR backend test failure. Submain has 123 tests passing against one failing. Fixing this would render the suite fully green.
  4. Clean up stale daily-log branches. While they don’t block progression, tidying these 24 branches would help reduce clutter.

Quiet days will inevitably dot any development journey. The project’s not in crisis mode by any means, but the delay in merging submain is flagging as an overdue task on the agenda. Whenever development picks up, this merge should be top of the to-do list.

Follow the Cx language project:

Originally published at https://cx-lang.com/blog/2026-04-27