Sr. Content Developer at Microsoft, working remotely in PA, TechBash conference organizer, former Microsoft MVP, Husband, Dad and Geek.
152072 stories
·
33 followers

Why Many Beginner Self-Taught Developers Struggle (And What to Do About It)

1 Share

Self‑taught developers often begin with the same “starter pack”: a laptop, internet access, and sheer determination. What they lack, however, is structured guidance, a defined curriculum, or any form of pedagogical support.

This absence of direction makes the journey significantly harder. Faced with an overwhelming abundance of online resources, many beginners become confused about where to start and often attempt to learn everything at once.

This is where the struggle with knowledge retention begins.

Not because they lack intelligence or effort, but because they're learning in a way that contradicts how the human brain actually works.

They dive into tutorials and courses without understanding the mechanism of the brain – that is, how the brain processes, stores, and retrieves information. As a result, much of what they learn simply doesn’t stick.

How the Brain Processes Information

So what's the connection between the brain and learning how to code, you might ponder?

The connection is direct and unavoidable.

Coding isn't learned through willpower or motivation — though both matter — or by spending countless hours watching tutorials.

It's learned through the brain’s ability to process, store, and retrieve information.

Every variable, function, data structure, or debugging pattern must pass through the brain’s cognitive systems before it becomes usable knowledge.

If your learning process doesn't align with how the brain naturally acquires and organises information, your retention will collapse, no matter how determined you are.

Now imagine you’re trying to fill a bucket with water. You keep pouring and pouring, but the bucket has tiny holes at the bottom. No matter how much effort you put in, the water keeps leaking out. You might blame yourself for not pouring fast enough, or you might try switching to a bigger jug, but the real problem isn’t your effort — it’s the bucket.

The water is the information you’re trying to learn.

The bucket is your brain’s memory system.

The holes in the bucket are the natural forgetting mechanisms of the brain: cognitive overload, limited working memory, and other constraints that make retention difficult.

If you don’t understand these mechanisms, you can pour in as much information as you want, but most of it will leak out.

Not because you’re incapable, but because you’re learning in a way that contradicts how the brain actually retains knowledge.

The Role of Academic Learning Theories

Since learning ultimately takes place in the brain, an important question is: How does the human brain acquire, organize, and apply knowledge and why does the typical self‑taught learning process clash with these principles?

This is where academic learning theories become indispensable. These frameworks explain how the brain actually acquires, retains, and applies complex information and they offer a scientific roadmap for learning more effectively. Without understanding these principles, self‑taught developers unintentionally work against the brain’s natural architecture.

The purpose of this article is to unpack these essential learning theories and apply them directly to the beginner self‑taught developer’s journey.

By understanding how the brain processes information, beginners can structure their learning more intentionally, retain knowledge more reliably, and move toward becoming competent developers with far greater confidence and clarity.

Table of Contents

Cognitive Load Theory (CLT)

Learning a new concept often requires mental effort by the brain to process newly acquired information. This effort exerted by the brain is known as cognitive load, a term coined by Australian educational psychologist John Sweller in 1988 during his study on how the brain acquires and retains information (Sweller, 1988).

Since then, his work has been expanded upon by other researchers. Notably, Dylan Wiliam famously tweeted in 2017 that Cognitive Load Theory (CLT) is "the single most important thing for teachers to know" (Dylan William, 2017).

You might wonder again: What does this have to do with me? As a beginner self‑taught developer, the answer is simple: you're both the teacher and the student.

So this is the most important theory you should know. In this self-tutoring journey, you're tasked with designing your own curriculum, choosing your own resources, pace your own learning, and evaluating your own progress.

Without understanding how cognitive load affects your ability to absorb and retain information, you may unintentionally overload your brain and sabotage your own learning.

Before we get into the nitty-gritty of CLT, there are important concepts masterminded by David Geary that you'll need to grasp to sufficiently understand this concept : “that which can be learnt” (biologically primary knowledge), “that which can be taught”(biologically secondary knowledge) (Geary, 2007, 2008).

According Geary (2007, 2008), "biologically primary knowledge" consists of "instinctual" skills that the brain is evolved to pick up naturally without formal schooling.

Examples include learning a first language, recognizing faces, or basic social navigation.

"Biologically secondary knowledge", on the other hand, consists of cultural and technical skills, like reading and writing, that are necessary for society but don't come naturally to the brain.

This is because we aren't "wired" to pick these up automatically. Instead, they require formal instruction and schools to pass them down.

Therefore, coding is a prime example of biologically secondary knowledge. The human brain is remarkably plastic, but it didn't evolve to interpret syntax, manage memory allocation, or debug logical loops.

These are cultural inventions, not natural instincts. Unlike learning to walk or speak your native language (which are biologically primary skills) you can't learn to code simply by “being around” computers.

Recognising that the human brain is not instinctively prepared for coding allows you to change your strategy. Once you accept that coding concepts are not “natural,” you can finally approach them with the structured, deliberate effort they require.

The second set of concepts beginner self-taught developers should know and understand are working memory, Miller's Laws, chunking, long-term memory, and schemas.

Working Memory

Working memory is where thinking happens. It's the active mental workspace where you hold information while you process it. When you encounter concepts like syntax, loops, functions, or an if/elseif statement for the first time, all of that information sits inside your working memory. The problem is that working memory is extremely limited and fragile.

When you first learn to code, your working memory functions like a small mental desk where only a few items can be placed at once.

Imagine trying to assemble a piece of IKEA furniture on a tiny coffee table. If you spread out the instruction manual, the screws, the wooden panels, and the tools all at the same time, the table becomes cluttered instantly. You start losing track of which part goes where, not because you’re incapable, but because the surface you’re working on is too small to hold everything at once.

Working memory behaves the same way. When you’re learning new concepts – like arrays, loops, functions, or error handling – each idea takes up space on that mental desk. If you then overload it, the desk becomes overcrowded.

Once it exceeds its capacity, things begin to fall off, and your ability to retain collapses.

It’s not a lack of intelligence. It’s simply the natural limit of working memory.

Now this collapse happens because you went against the threshold your working memory can hold. This is backed up by research that shows that working memory can typically process only 5–9 pieces of information at any given time (Miller, 1956). This is known as Miller’s Law.

Miller's Law

In 1956, George Miller found that the average human can hold about seven items (plus or minus two) in working memory at once, even some recent research has stated the number is even lower about four item (Nelson Cowan, 2001).

So imagine you encounter a tutorial that introduces the following concepts all at the same time: a Route, a Controller, a Model, a Migration, a View, a Request, Helper files, Jobs and Queues, Middleware, Roles and Permissions, and a Service Provider. If you attempt to hold all of these in your mind simultaneously, you'll inevitably hit Miller’s Wall, as your working memory becomes overloaded, and you'll likely forget the first concept long before you reach the last.

So how do you handle complex tasks if the brain can only juggle 4–9 items at once?

You use chunking — the process of grouping small pieces of information into a single, meaningful unit.

Chunking

Chunking is the brain’s strategy for compressing complexity. Instead of forcing working memory to hold a dozen unrelated items, you reorganise them into a few coherent structures. This reduces cognitive load, prevents overload, and allows you to work with far more information than your raw working‑memory limits would normally allow.

Let's consider an example:

A beginner learning Laravel might see Route, Controller, Model, Migration, and View as five separate, overwhelming items. To a beginner, each one feels like a distinct cognitive burden. But an experienced developer doesn't treat them as isolated concepts. Instead, they're understood as a single meaningful unit: the MVC pattern. Instead of holding five items in working memory, the expert holds one.

This raises an important question: how does a beginner know that these five elements belong together when they have only just encountered them?

It's crucial to emphasise that chunking isn't automatic. It depends on recognising meaningful relationships between concepts, and beginners typically lack the prior knowledge needed to perceive those relationships early on.

But as learners repeatedly encounter the same sequence during the learning process, they begin to notice consistent patterns. Over time, the brain’s natural tendency to seek structure enables them to identify which components reliably operate together, allowing these elements to gradually fuse into a single, meaningful chunk.

For example, when I first followed a Laravel e-commerce tutorial, I noticed that for every new resource the tutor created – Payment, Cart, KYC, and Contact – the same pattern was repeated: a Controller, a Model, and a View were always created together.

After encountering this sequence several times, it became clear that these components consistently belonged together as a set. Over time, I began to perceive the Controller, Model, and View not as separate elements, but as a single, integrated unit.

So beginners may not be able to chunk effectively on day one because they lack the prior knowledge needed to recognise what belongs together. But with time, and repeated encounters across different contexts, these individual pieces fuse into stable mental units stored in long‑term memory.

What feels overwhelming at first eventually becomes effortless, not because the task became simpler, but because your internal representation became more organised.

This is the power of chunking: it transforms scattered pieces of information into organised units that fit comfortably within the limits of working memory.

Without chunking, beginners drown in details. With chunking, they gain the cognitive space needed to understand, retain, and apply what they learn.

Long-term memory

Unlike working memory, long‑term memory has virtually infinite capacity. The goal of all study is to move information from the cramped working memory into the vast long‑term memory.

Here is the real secret: you don’t learn in working memory – you only process there.

True learning is the permanent change that happens in long‑term memory.

Schema

Once stored in long-term memory, information becomes part of a schema — a mental map or filing system that organizes related ideas.

For example, when you finally learn that Laravel is an MVC framework, you aren’t just memorizing three letters. You're building a schema that tells your brain: Models handle data, Views handle presentation, and Controllers handle logic.

Once a schema is built, it can be pulled into working memory as a single chunk, effectively bypassing Miller’s Law.

This is how experts think effortlessly while beginners feel overwhelmed.

And this is why Garnett (2020) argues that "being competent or lacking competence in something depends entirely on how secure the retrieval of knowledge held in the schema is".

Now that the foundations of working memory, long‑term memory, schemas, and chunking are clear, we can turn to another set of concepts every self‑taught developer must understand: intrinsic load, extraneous load, and germane load. These three components make up the full structure of Cognitive Load Theory, and they determine whether learning feels manageable or overwhelming.

Intrinsic Load: The Natural Difficulty of the Task

Intrinsic load refers to the inherent complexity of the material itself. Some concepts are simply harder than others because they contain more interacting elements that must be processed at the same time.

In Laravel, understanding a simple Route has low intrinsic load.

But concepts like Dependency Injection or Polymorphic Relationships have high intrinsic load because they involve multiple layers of abstraction and interdependent ideas.

You can't change the intrinsic load of a concept, but you can manage it by breaking the idea into smaller, more digestible sub‑tasks. This is why good teaching — and good self‑teaching — always begins with simplification and sequencing.

Simplification means stripping a concept down to its essential parts so the learner isn't overwhelmed by unnecessary detail.

Sequencing means introducing parts in a logical order, where each step builds on the previous one. This helps reduce unnecessary cognitive load and allows learners to devote more mental effort (germane load) to building schemas.

It’s like meeting someone new, and they tell you their name and it happens to be your mother’s name. Instantly, your brain forms a connection. You associate this new person’s name with the strong, deeply stored memory of your mother.

Because that schema already exists in your long-term memory, the new information “attaches” to it. Later, when you try to recall the name, you don’t struggle, you simply think of your mother, and the name comes back easily.

While many believe self‑taught developers struggle because they lack immediate, reliable, personal guidance, there's actually a hidden advantage in this predicament. When a teacher explains a concept, even if they try their best to “chunk” the information, they can't truly know the student’s internal limits — how much intrinsic load the learner can handle, how quickly they can process new ideas, or how much prior knowledge they can activate.

This is where self‑taught developer quietly shines. Because you are both the teacher and the student, you know your own cognitive limits better than anyone else. You can slow down when something feels heavy, pause when working memory is overloaded, and chunk information in a way that perfectly matches your personal capacity.

You can simplify a concept to its bare essentials and sequence it at a pace that aligns with your own understanding.

Extraneous Load: The Mental Noise

Extraneous load is the enemy of the self‑taught developer. It's the mental effort wasted on tasks that don't contribute to actual learning. This is where a self-taught developer's strength must truly shine.

A teacher in a classroom is responsible for removing any distractions that might derail a child or slow down their assimilation of knowledge. As a self-taught developer, that responsibility falls entirely on you. You must identify these distractions and eliminate them.

As a self-taught developer myself, I use specific strategies to ensure I stay focused. Before starting any course, I spend time reading the comment section to see what others have experienced. If I see complaints about low audio quality, unclear explanations, or tutorials that move too fast, I immediately abandon that course and look for one with better reviews.

Anything that might derail my progress must be removed. If you spend half of your mental energy trying to figure out all of these, you only have the remaining half available for understanding the logic of the code. And remember: when learning new concepts, we use working memory, which is fragile.

As your own “inner teacher,” you must eliminate this noise so your limited working memory can focus entirely on the material that matters.

Germane Load: The Construction Work

Germane load is the productive mental effort used to build and refine schemas — the mental structures that make future learning easier.

This is the “Aha!” moment when new information connects meaningfully to what you already know.

For example, germane load appears when you realise that a Database Migration is essentially a version‑control system for your table structure.

That insight is schema construction in action.

Teachers are often advised to help manage a child's germane load. One way they do this is by connecting the new idea being taught to an existing concept.

By doing this, they help the student build schemas: mental frameworks that organise and interpret information.

For a self-taught developer, this means instead of memorizing a new syntax in isolation, you look for a 'hook' in something you already understand.

For example, if you already know how a physical filing cabinet works, understanding Arrays or Objects in code becomes much easier.

You aren't learning from scratch – you're simply "plugging" new data into an old socket. This reduces the mental strain and makes the new knowledge stick permanently.

But this can only happen when intrinsic load is properly managed and extraneous load is removed.

It's important to note that, unlike intrinsic and extraneous load, germane load isn't an independent type of cognitive load.

Instead, it represents the portion of your working memory that remains available to handle the element interactivity associated with intrinsic load.

In other words, germane load is the mental energy you have left for learning once the unnecessary noise is stripped away.

Understanding cognitive load explains why learning can feel overwhelming in the moment, but it doesn't explain why knowledge fades after the moment has passed. For that, we turn to another foundational principle in learning science: the Ebbinghaus Forgetting Curve.

Ebbinghaus Forgetting Curve

If you remember the bucket analogy, this curve represents one of the holes at the bottom — the brain’s natural tendency to let information leak away unless it's reinforced.

In the late 19th century, Hermann Ebbinghaus discovered that human memory follows a predictable pattern of decline. After learning something new, we forget most of it astonishingly quickly — often within hours — unless the information is revisited. The forgetting curve shows that memory retention drops sharply at first and then continues to decline more slowly over time.

Studies based on Ebbinghaus’ forgetting curve found that without a conscious effort to retain newly acquired information, we lose approximately 50% of new information within 24 hours, and up to 90% within a week (Clearwater, 2024).

In other words, the brain is designed to discard information that isn't reinforced.

For self‑taught developers, this has profound implications.

You may understand a Laravel controller today, spatial roles and permission concepts, and so on – but if you don't revisit it, practice it, or apply it within the next few hours, your brain will naturally let it fade.

This is not a sign of weakness or lack of talent. It's simply how human brain works.

The forgetting curve also explains why tutorials feel deceptively easy the moment you're going through them.

While watching, everything seems clear — but a week later, the same concepts feel unfamiliar.

The knowledge never made it into long‑term memory because you didn't revisit, practice, or connect it to existing schemas.

Since the human brain is designed to forget anything that isn't repeated, repetition becomes the signal that tells the brain, “This matters — keep it.”

This is why, when you meet someone for the first time and they tell you their name, you'll almost certainly forget it unless you consciously repeat it to yourself several times. If you don’t reinforce it, you end up asking — often with embarrassment — “Sorry, what was your name again?”

The same principle applies to learning code: without deliberate repetition, the brain simply lets the information fade. However, with a technique called spaced repetition, retention is significantly improved .

How the Theory of Spaced Repetition Works

Spaced repetition is a learning technique grounded in cognitive psychology that involves reviewing information at increasingly spaced intervals to strengthen long‑term memory retention.

It's based on the principle that memory decays predictably over time — as demonstrated by the Ebbinghaus Forgetting Curve — and that strategically timed reviews or repetition interrupt this decay, making the memory more durable with each repetition.

This idea is what gave birth to Anki-Flash cards.

Imagine you're trying to memorise the time complexity of different algorithms.

This is a classic "dry" academic topic that's easy to forget.

To understand why spaced repetition is so powerful, consider a familiar scenario. You spend Sunday night staring at a chart of Big‑O complexities for four hours. By Monday’s review, you can recall most of them. By Friday, only a few remain. Two weeks later, the entire chart has vanished from memory.

Spaced repetition reverses this process by reviewing information at the precise moment it's about to be forgotten. Instead of cramming Big‑O notation in a single session, you revisit it across expanding intervals:

  1. Day 1 (Initial Learning): You study the Big‑O chart and understand each complexity class.

  2. Day 2 (First Review): You test yourself. If you recall an item correctly, you schedule the next review three days later. If you miss it, you review it again the following day.

  3. Day 5 (Second Review): You encounter the material again. Because you still remember it, the interval expands to ten days.

  4. Day 15 (Third Review): Your memory has begun to fade, but the moment you see the prompt, the concept resurfaces. This slight struggle to retrieve the information is precisely what strengthens long‑term retention.

  5. Day 45 (Fourth Review): By now, the memory is deeply consolidated. Concepts like O(log⁡n) feel as natural and accessible as your own phone number.

Through this process, spaced repetition transforms fragile, short‑term awareness into durable, long‑term knowledge. Each review interrupts the forgetting curve, reinforces the schema, and reduces the cognitive load required to recall the concept in the future.

For self-taught developers, spaced repetition can take many forms. You might rewrite code from memory, re‑implement a feature days later, build small variations of the same concept, or return to the concept after working on different tasks.

Every review strengthens the schema and reduces the cognitive load required to recall it. Over time, what once felt complex becomes automatic — not because the concept changed, but because your brain reorganised it into a stable, efficient structure.

As you can see, learning isn't a single event but a cycle of exposure, forgetting, and reinforcement.

Mastery comes not from seeing something once, but from returning to it until it becomes part of your cognitive architecture.

But we must be careful with repetition. Doing the same thing over and over again doesn't guarantee improvement. In fact, mindless repetition can trap you at the same level indefinitely.

This is where the theory of deliberate practice becomes essential, as it emphasises increasing the level of challenge, focusing on specific weaknesses, and actively seeking feedback so that each repetition leads to measurable improvement rather than just familiarity.

Theory of Deliberate Practice

Developed by psychologist K. Anders Ericsson, it argues that expertise is not the result of talent but of high‑quality, intentional practice (Ericsson, 1993). This type of practice is fundamentally different from simply doing something repeatedly.

He coined the term "deliberate practice" while researching how people become experts. Studying experts from several different fields, he dismantled the myth that expert performers have unusual innate talents.

Instead, he discovered that experts attain their high performance through how they practice: it's a deliberate effort to become an expert. This effort is characterized by breaking down required skills into smaller parts and practicing these parts repeatedly.

According to Anders Ericsson, Deliberate Practice requires:

  1. Clear goals

  2. Immediate feedback

  3. Tasks that stretch your ability just beyond your comfort zone

  4. Full concentration and effort

The main tenet of Deliberate Practice is that tasks must stretch your ability just beyond your comfort zone. This is paramount to the advancement of learning.

Imagine a child being taught 1+1 every day. That child will never grow beyond basic arithmetic. Anders Ericsson calls this "Arrested Development" (Ericsson, Nandagopal and Roring, 2005). For that child to grow to become a mathematician, their knowledge must be stretched.

The takeaway for developers is a play on the DRY principle (Don’t Repeat Yourself): If you are only repeating what you already know without stretching yourself, you aren't growing. This "stretch" is the extra edge that Deliberate Practice adds to Spaced Repetition.

Building a simple to-do list, a calculator, or a weather app over and over again won't take you anywhere. You already know how to do those.

To truly grow, you must stretch yourself. Instead, try a project that integrates new ideas, like building a mini-app where the weather data affects your to-do list. For example, if the API shows it's raining, the app automatically hides outdoor tasks and calculates the time you'll save or the indoor tasks you should prioritize instead.

This forces you to handle complex logic and state management, moving you beyond simple repetition into true mastery.

This ability to create brings me to the last theory: Bloom's Taxonomy.

What is Bloom's Taxonomy?

Bloom’s Taxonomy provides a hierarchy of cognitive skills that learners move through as they develop mastery. It begins with the simplest tasks and progresses toward the most complex:

  1. Remember – recalling facts or syntax

  2. Understand – explaining concepts in your own words

  3. Apply – using knowledge in real situations

  4. Analyze – breaking problems into parts

  5. Evaluate – judging solutions or comparing approaches

  6. Create – building original systems or applications

Most self‑taught developers get stuck in the first two levels. They memorize syntax and understand examples, but they struggle to apply, analyze, or create.

This isn't because they lack ability. Rather, it's because they haven't been taught that learning must progress through these stages.

Bloom’s Taxonomy gives structure to the learning journey.

It reminds self-taught developers that mastery isn't achieved by watching tutorials but by climbing the ladder from remembering → understanding → applying → analyzing → evaluating → creating (with an emphasis on Creation).

Creation is one of the most difficult yet most transformative experiences in your journey as a developer. It forces you to think abstractly, confront ambiguity, and notice dimensions of a problem that tutorials rarely reveal.

When you build something real, the neatness of the theory in your head collapses, and you begin to see its true complexity. You must then devise strategies to navigate these challenges, and through this process, you learn.

And as with anything worthwhile, the process isn't smooth. You'll encounter bugs — not just one or two, but hundreds. Yet this is precisely how real knowledge is built. Every bug you solve becomes a permanent entry in your long‑term memory.

The next time you see that error, you won’t panic. Instead, you’ll recognise it instantly and know exactly where it’s coming from and how to fix it.

Some self‑taught developers encounter a few bugs and never return to their projects again, concluding that “coding isn’t for me.”

After trying several fixes and seeing no progress, they abandon the work and look for something else. But this is the wrong conclusion. The problem is rarely a lack of talent — it's a misunderstanding of how the brain behaves under cognitive strain.

Focused Mode vs Diffuse Mode

When you spend a long time wrestling with a bug, you may be experiencing mental fixation or functional fixedness.

This is when your brain becomes locked into a single line of reasoning, repeating the same logic path over and over because it feels like the right direction. The longer you stare at the problem, the deeper the cognitive rut becomes. You develop tunnel vision, making it almost impossible to see alternative solutions.

This is where understanding how the brain operates becomes essential.

According to Oakley (2014), the brain works in two primary modes:

  1. Focused Mode: Ideal for executing a known formula or following a clear procedure, terrible for discovering a new approach or breaking out of a mental rut.

  2. Diffuse Mode: This is activated when you step away — walking, showering, relaxing, or sleeping.

In this second mode, the brain enters a “big‑picture” state where neural connections stretch across different regions.

The background processes continue working on the problem without the restrictive tunnel vision of conscious focus.

This phenomenon is known as incubation.

This is why solutions often appear when you’re not actively thinking about the problem. You step away, and suddenly the answer emerges, not because you stopped working, but because a different part of your brain started working for you.

The reality is that many developers never allow for incubation. While you step away from the problem, your brain performs subconscious synthesis: it clears out the noise (Extraneous Load) and lets the core logic (Germane Load) settle. When you return, the “wrong” paths you were obsessing over have faded, and the correct path which was there all along often finally becomes visible.

This is why developers must deliberately allow for incubation. We can take some lessons from great minds of the past:

Henri Poincaré famously struggled with Fuchsian functions for weeks. It was only during a geological excursion when he had completely forgotten about the mathematics that the solution appeared with “perfect certainty” the moment he stepped onto an omnibus. His breakthrough did not come from more effort, but from stepping away long enough for diffuse mode to take over.

Friedrich August Kekulé experienced something similar after years of wondering why benzene’s carbon atoms didn't fit a linear structure.

If some of the greatest minds in history stepped away from their problems and found solutions in diffuse mode, why should developers treat themselves any differently?

Now that you're familiar with some key learning strategies – Cognitive Load Theory, Spaced Repetition, and Bloom's Taxonomy – creating or building a project from the ground up should be your next task. It will help you curate, retrieve, organise, and seal in all that diverse knowledge you've gathered as a self-taught developer.

Conclusion

In this article, we explored why the human brain isn't instinctively wired to understand programming. Coding is a biologically secondary skill, which means it doesn't develop naturally through immersion but requires explicit instruction, structure, and patience.

We also talked about the limits of working memory, the importance of chunking, and the need to manage cognitive load so that learning remains possible rather than overwhelming.

We then analyzed the three components of Cognitive Load Theory – intrinsic, extraneous, and germane load – and discussed how each influences the learning process. Reducing extraneous load is especially crucial for self‑taught developers, as it frees up mental resources for meaningful understanding.

From there, we turned to the Ebbinghaus Forgetting Curve, which demonstrates how quickly newly learned information fades without reinforcement.

To counter this natural forgetting, we introduced Spaced Repetition, a method that strengthens memory by reviewing material at expanding intervals. We also examined Deliberate Practice, which pushes learners just beyond their comfort zone to promote genuine skill development, and Bloom’s Taxonomy, which outlines the stages of cognitive growth from remembering to creating.

Finally, we emphasized the importance of knowing when to step back. The brain operates in both focused and diffuse modes, and effective learning requires movement between the two. Breaks are not signs of weakness but essential components of consolidation and insight.

Together, these theories form a comprehensive framework for learning to code with scientific precision. When self‑taught developers understand how their brain learns, forgets, and grows, they can design a learning process that isn't only more efficient but far more sustainable.

With all this new knowledge, one truth is certain: focus, determination, and consistency are the forces that transform theory into mastery.

Learning science can guide the process, but only sustained effort turns knowledge into skill.

References

  1. Clearwater, L. (2024). Understanding the Science Behind Learning Retention | Reports | What We Think | Indegene. [online] www.indegene.com. Available at: https://www.indegene.com/what-we-think/reports/understanding-science-behind-learning-retention.

  2. Dylan Wiliam [@dylanwiliam]. (2017, January 25). I’ve come to the conclusion Sweller’s Cognitive Load Theory is the single most important thing for teachers to know [Tweet]. X. https://x.com/dylanwiliam/status/824682504602943489

  3. Ericsson, K. A., Krampe, R. T., & Tesch-Römer, C. (1993).
    The role of deliberate practice in the acquisition of expert performance.
    Psychological Review, 100(3), 363–406.

  4. Garnett, S. (2020.). Cognitive Load Theory A handbook for teachers. [online] Available at: https://www.crownhouse.co.uk/assets/look-inside/9781785835018.pdf.

  5. Geary, D. C. (2007). An evolutionary perspective on learning disability in mathematics. Developmental Neuropsychology, 32(1), 471–519. https://doi.org/10.1080/87565640701360924

  6. Geary, D. C. (2008). An evolutionarily informed education science. Educational Psychologist, 43(4), 179–195. https://doi.org/10.1080/00461520802392133

  7. George A. Miller (1956). The magical number seven, plus or minus two: Some limits on our capacity for processing information. Psychological Review, 63(2), 81–97. https://doi.org/10.1037/h0043158

  8. Nelson Cowan (2001). The magical number 4 in short-term memory: A reconsideration of mental storage capacity. Behavioral and Brain Sciences, 24(1), 87–114. https://doi.org/10.1017/S0140525X01003922

  9. Oakley, B. (2014). A Mind for Numbers: How to Excel at Math and Science (Even If You Flunked Algebra). New York: TarcherPerigee.

  10. Sweller, J. (1988). Cognitive Load during Problem Solving: Effects on Learning. Cognitive Science, [online] 12(2), pp.257–285. doi:https://doi.org/10.1207/s15516709cog1202_4.



Read the whole story
alvinashcraft
8 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

🗺️ ️ AOT-Friendlyst Dropped — And .NE Meet t2i — The ElBruno.Text2Image CLI🖼️ MAI-Image-2 Just Dropped — And .NET Support Is Already Here

1 Share

⚠ This blog post was created with the help of AI tools. Yes, I used a bit of magic from language models to organize my thoughts and automate the boring parts, but the geeky fun and the 🤖 in C# are 100% mine.

Hi!

I just shipped t2i, a terminal-first CLI tool for ElBruno.Text2Image. Generate images from your shell in two commands — no UI, no browser, just a simple cli interface to image generation from the cloud.

This is the Lite edition (cloud-only, ~2.4 MB on NuGet) — perfect for CI/CD pipelines, deployment scripts, batch jobs, and developers who live in the terminal.


🛠 Install

Option 1: .NET Tool (recommended)

If you have .NET 8+ installed:

dotnet tool install --global ElBruno.Text2Image.Cli

Then use t2i from anywhere on your machine.

Option 2: Self-Contained Binaries

No .NET? Download pre-built binaries from the release page. Currently available for:

  • Windows x64
  • Linux x64
  • macOS arm64 (Intel coming back soon)

Just extract and run. One file, no dependencies.


🚀 First Image in 30 Seconds

Ready to generate? Two commands:

t2i config

This launches an interactive setup wizard (powered by Spectre.Console, so it’s pretty). Pick your provider, enter your API key, and you’re done. The CLI stores everything securely.

Then generate:

t2i "a robot painting a landscape"

That’s it. The image appears in your current directory.


🔐 Where Do My Secrets Live?

Short version: t2i config stores credentials securely on your machine — DPAPI-encrypted on Windows, 0600-permissioned file on macOS/Linux. Env vars are for CI only. CLI --api-key is for one-off tests.

For the full resolution chain, CI/CD guidance, and the do/don’t table, see docs/cli-secrets.md.


☁ Providers in Lite

This release ships with two cloud providers on Azure AI Foundry:

ProviderModelBest For
foundry-flux2FLUX.2 ProHigh-quality, fine control, batch jobs
foundry-mai2MAI-Image-2Synchronous API, rich prompts, fast iteration

More providers (Anthropic, OpenAI) coming in future releases.


📋 Useful Commands

Here’s the CLI cheat sheet:

# List available providers
t2i providers
# Run health checks (config + API connectivity)
t2i doctor
# Show stored secrets (redacted)
t2i secrets list
# Display version + commit SHA
t2i version
# Interactive config setup
t2i config
# Generate with default provider
t2i "a cyberpunk cityscape at night"
# Generate with specific provider, dimensions, and output file
t2i "my prompt" \
--provider foundry-mai2 \
--width 1024 \
--height 1024 \
--output ./my-image.png
# Show help
t2i --help
t2i generate --help
# Teach your AI agent
t2i init

🔄 Switching Models (v0.10.0+)

Both providers support multiple model variants. By default, foundry-mai2 uses MAI-Image-2 and foundry-flux2 uses FLUX.2-pro. To switch models:

# Use MAI-Image-2e
t2i config set foundry-mai2.model MAI-Image-2e
# Use FLUX.2 Flex for text-heavy design and logos
t2i config set foundry-flux2.model FLUX.2-flex
# View your configuration
t2i config show

The config show command displays your endpoint and model in plain text, with only the API key masked. This makes it easy to verify your setup without revealing sensitive credentials.

🤖 Teach Your AI Agent — t2i init

Inspired by Aspire’s agent init pattern, t2i now ships with a skill file your AI coding agent can read. Run this in any repo:

t2i init

That writes a SKILL.md to both .github/skills/t2i/ and .claude/skills/t2i/. From that point on, GitHub Copilot, Claude Code, and any MCP-aware agent know:

  • Which t2i commands exist and when to use each one
  • How to set up secrets safely (env vars first, never commit keys)
  • The full provider list and which one to default to
  • Common workflows: first-time setup, single image, batch loops

Want only one target?

t2i init --target github # only .github/skills/t2i/
t2i init --target claude # only .claude/skills/t2i/
t2i init --force # overwrite existing skill files

The canonical version of this skill also lives in this repo at .github/skills/t2i/SKILL.md — that means if you open the ElBruno.Text2Image source itself in Copilot or Claude Code, your agent already knows how to drive the CLI.


📦 Coming Soon — Other Platforms

winget

The manifest stub is already in the repo (winget/manifests/E/ElBruno/Text2Image/0.10.0/). First submission to microsoft/winget-pkgs is queued. Automation will come in v0.2.0.

Homebrew

Tap elbruno/elbruno is planned for v0.2.0.

macOS Intel (osx-x64)

GitHub’s macOS-13 runner queue has been a bit slow lately. Intel Mac users should use dotnet tool install for now. The self-contained binary will return once the runners stabilize.

Full Edition (Local GPU)

The Cli.Full edition — supporting local inference with ONNX Runtime (CPU, CUDA, DirectML, NPU) — is coming in a future release. ~200 MB, separate package.


🎨 Sample Usages

Here are some fun one-liners to try:

t2i "a cyberpunk taco truck at sunset, cinematic lighting, volumetric fog"
t2i "a low-poly 3D render of a friendly robot waving" --provider foundry-mai2
t2i "minimalist line art of a cat reading a book" --width 1024 --height 1024 --output cat.png
t2i "watercolor painting of the Madrid skyline at dusk, golden hour" --output madrid.png

Tip: The better your prompt, the better the image. Be specific about style (cinematic, watercolor, line art), mood (peaceful, energetic), and composition (wide-angle, close-up). Models reward detail.


🤝 Let’s Go

Download it now, run t2i config, and start generating. Found a bug? File an issue.

Links:

Happy coding!

Greetings

El Bruno

More posts in my blog ElBruno.com.

More info in https://beacons.ai/elbruno




Read the whole story
alvinashcraft
8 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

256 Lines or Less: Test Case Minimization

1 Share

256 Lines or Less: Test Case Minimization

Property Based Testing and fuzzing are a deep and science-intensive topic. There are enough advanced techniques there for a couple of PhDs, a PBT daemon, and a client-server architecture. But I have this weird parlor-trick PBT library, implementable in a couple of hundred lines of code in one sitting.

This week I’ve been thinking about a cool variation of a consensus algorithm. I implemented it on the weekend. And it took just a couple of hours to write a PBT library itself first, and then a test, that showed a deep algorithmic flaw in my thinking (after a dozen trivial flaws in my coding). So, I don’t get to write more about consensus yet, but I at least can write about the library. It is very simple, simplistic even. To use an old Soviet joke about Babel and Bebel, it’s Gogol rather than Hegel. But for just 256 lines, it’s one of the highest power-to-weight ratio tools in my toolbox.

Read this post if:

  • You want to stretch your generative testing muscles.
  • You are a do-it-yourself type, and wouldn’t want to pull a ginormous PBT library off the shelf.
  • You would pull a library, but want to have a more informed opinion about available options, about essential and accidental complexity.
  • You want some self-contained real-world Zig examples :P

Zig works well here because it, too, is exceptional in its power-to-weight.

FRNG

The implementation is a single file, FRNG.zig, because the core abstraction here is a Finite Random Number Generator — a PRNG where all numbers are pre-generated, and can run out. We start with standard boilerplate:

const std = @import("std");
const assert = std.debug.assert;

entropy: []const u8,

pub const Error = error{OutOfEntropy};

const FRNG = @This();

pub fn init(entropy: []const u8) FRNG {
    return .{ .entropy = entropy };
}

In Zig, files are structs: you obviously need structs, and the language becomes simpler if structs are re-used for what files are. In the above const FRNG = @This() assigns a conventional name to the file struct, and entropy: []const u8 declares instance fields (only one here). const Error and fn init are “static” (container level) declarations.

The only field we have is just a slice of raw bytes, our pre-generated random numbers. And the only error condition we can raise is OutOfEntropy.

The simplest thing we can generate is a slice of bytes. Typically, API for this takes a mutable slice as an out parameter:

pub fn fill(prng: *PRNG, bytes: []u8) void { ... }

But, due to pre-generated nature of FRNG, we can return the slice directly, provided that we have enough entropy. This is going to be our (sole) basis function, everything else is going to be a convenience helper on top:

pub fn bytes(frng: *FRNG, size: usize) Error![]const u8 {
    if (frng.entropy.len < size) return error.OutOfEntropy;
    const result = frng.entropy[0..size];
    frng.entropy = frng.entropy[size..];
    return result;
}

The next simplest thing is an array (a slice with a fixed size):

pub fn array(frng: *FRNG, comptime size: usize) Error![size]u8 {
    return (try frng.bytes(size))[0..size].*;
}

Notice how Zig goes from runtime-known slice length, to comptime known array type. Because size is a comptime constant, slicing []const u8 with [0..size] returns a pointer to array, *const [size]u8.

We can re-interpret a 4-byte array into u32. But, because this is Zig, we can trivially generalize the function to work for any integer type, by passing in Int comptime parameter of type type:

const builtin = @import("builtin");

pub fn int(frng: *FRNG, Int: type) Error!Int {
    comptime {
        assert(@typeInfo(Int).int.signedness == .unsigned);
        assert(builtin.cpu.arch.endian() == .little);
    }
    return @bitCast(try frng.array(@sizeOf(Int)));
}

This function is monomorphised for every Int type, so @sizeOf(Int) becomes a compile-time constant we can pass to fn array.

Production code would be endian-clean here, but, for simplicity, we encode our endianness assumption as a compile-time assertion. Note how Zig communicates information about endianness to the program. There isn’t any kind of side-channel or extra input to compilation, like --cfg flags. Instead, the compiler materializes all information about target CPU as Zig code. There’s a builtin.zig file somewhere in the compiler caches directory that contains

pub const cpu: std.Target.Cpu = .{
    .arch = .aarch64,
    .model = &std.Target.aarch64.cpu.apple_m3,
    // ...
}

This file can be accessed via @import("builtin") and all the constants inspected at compile time.

We can make an integer, and a boolean is even easier:

pub fn boolean(frng: *FRNG) Error!bool {
    return (try frng.int(u8)) & 1 == 1;
}

Strictly speaking, we only need one bit, not one byte, but tracking individual bits is too much of a hassle.

From an arbitrary int, we can generate an int in range. As per Random Numbers Included, we use a closed range, which makes the API infailable and is usually more convenient at the call-site:

pub fn int_inclusive(frng: *FRNG, Int: type, max: Int) Error!Int

As a bit of PRNG trivia, while this could be implemented as frng.int(Int) % (max + 1), the result will be biased (not uniform). Consider the case where Int = u8, and a call like frng.int_inclusive(u8, 64 * 3).

The numbers in 0..64 are going to be twice as likely as the numbers in 64..(64*3), because the last quarter of 256 range will be aliased with the first one.

Generating an unbiased number is tricky and might require drawing arbitrary number of bytes from entropy. Refer to https://www.pcg-random.org/posts/bounded-rands.html for details. I didn’t, and copy-pasted code from the Zig standard library. Use at your own risk!

pub fn int_inclusive(frng: *FRNG, Int: type, max: Int) Error!Int {
    comptime assert(@typeInfo(Int).int.signedness == .unsigned);
    if (max == std.math.maxInt(Int)) return try frng.int(Int);

    const bits = @typeInfo(Int).int.bits;
    const less_than = max + 1;

    var x = try frng.int(Int);
    var m = std.math.mulWide(Int, x, less_than);
    var l: Int = @truncate(m);
    if (l < less_than) {
        var t = -%less_than;

        if (t >= less_than) {
            t -= less_than;
            if (t >= less_than) t %= less_than;
        }
        while (l < t) {
            x = try frng.int(Int);
            m = std.math.mulWide(Int, x, less_than);
            l = @truncate(m);
        }
    }
    return @intCast(m >> bits);
}

Now we can generate an int bounded from above and below:

pub fn range_inclusive(
    frng: *FRNG, Int: type,
    min: Int, max: Int,
) Error!Int {
    comptime assert(@typeInfo(Int).int.signedness == .unsigned);
    assert(min <= max);
    return min + try frng.int_inclusive(Int, max - min);
}

Another common operation is picking a random element from a slice. If you want to return a pointer to a element, you’ll need a const and mut versions of the function. A simpler and more general solution is to return an index:

pub fn index(frng: *FRNG, slice: anytype) Error!usize {
    assert(slice.len > 0);
    return try frng.range_inclusive(usize, 0, slice.len - 1);
}

At the call site, xs[try frng.index(xs)] doesn’t look too bad, is appropriately const-polymorphic, and is also usable for multiple parallel arrays.

Simulation

So far, we’ve spent about 40% of our line budget implementing a worse random number generator that can fail with OutOfEntropy at any point in time. What is it good for?

We use it to feed our system under test with random inputs, see how it reacts, and check that it does not crash. If we code our system to crash if anything unexpected happens and our random inputs cover the space of all possible inputs, we get a measure of confidence that bugs will be detected in testing.

For my consensus simulation, I have a World struct that holds a FRNG and a set of replicas:

const World = struct {
    frng: *FRNG,
    replicas: []Replica,
    // ...
};

World has methods like:

fn simulate_request(world: *World) !void {
    const replica = try world.frng.index(world.replicas);
    const payload = try world.frng.int(u64);

    world.send_payload(replica, payload);
}

I then select which method to call at random:

fn step(world: *World) !void {
    const action = try world.frng.weighted(.{
        .request = 10,
        .message = 20,
        .crash = 1,
    });
    switch (action) {
        .request => try world.simulate_request(),
        .message => { ... },
        .crash => { ... },
    }
}

Here, fn weighted is another FRNG helper that selects an action at random, proportional to its weight. This helper needs quite a bit more reflection machinery than we’ve seen so far:

pub fn weighted(
    frng: *FRNG,
    weights: anytype,
) Error!std.meta.FieldEnum(@TypeOf(weights)) {
    const fields =
        comptime std.meta.fieldNames(@TypeOf(weights));

    var total: u32 = 0;
    inline for (fields) |field|
        total += @field(weights, field);
    assert(total > 0);

    var pick = try frng.int_inclusive(u64, total - 1);
    inline for (fields) |field| {
        const weight = @field(weights, field);
        if (pick < weight) {
            return @field(
                std.meta.FieldEnum(@TypeOf(weights)),
                field,
            );
        }
        pick -= weight;
    }
    unreachable;
}

weights: anytype is compile-time duck-typing. It means that our weighted function is callable with any type, and each specific type creates a new monomorphised instance of a function. While we don’t explicitly name the type of weights, we can get it as @TypeOf(weights).

FieldEnum is a type-level function that takes a struct type:

const S = struct {
    foo: bool,
    bar: u32,
    baz: []const u8
};

and turns it into an enum type, with a variant per-field, exactly what we want for the return type:

const E = enum { foo, bar, baz };

Tip: if you want to quickly learn Zig’s reflection capabilities, study the implementation of std.meta and std.enums in Zig’s standard library.

The @field built-in function accesses a field given comptime field name. It’s exactly like Python’s getattr / setattr with an extra restriction that it must be evaluated at compile time.

To add one more twist here, I always find it hard to figure out which weights are reasonable, and like to generate the weights themselves at random at the start of the test:

pub fn swarm_weights(frng: *FRNG, Weights: type) Error!Weights {
    var result: Weights = undefined;
    inline for (comptime std.meta.fieldNames(Weights)) |field| {
        @field(result, field) = try frng.range_inclusive(u32, 1, 100);
    }
    return result;
}

(If you feel confused here, check out Swarm Testing Data Structures)

Stepping And Runnig

Now we have enough machinery to describe the shape of test overall:

fn run_test(gpa: Allocator, frng: *FRNG) !void {
    var world = World.init(gpa, &frng) catch |err|
        switch (err) {
            error.OutOfEntropy => return,
            else => return err,
        };
    defer world.deinit(gpa);

    while (true) {
        world.step() catch |err| switch (err) {
            error.OutOfEntropy => break,
        };
    }
}

const World = struct {
    frng: *FRNG,
    weights: ActionWeights,

    // ...

    const ActionWeights = struct {
        request: u32,
        message: u32,
        crash: u32,
        // ...
    };

    pub fn init(gpa: Allocator, frng: *FRNG) !void {
        const weights = try frng.swarm_weights(ActionWeights);
        // ...
    }

    fn step(world: *World) error{OutOfEntropy}!void {
        const action = try world.frng.weighted(world.weights);
        switch (action) {
            .request => { ... },
            // ...
        }
    }
};

A test needs an FRNG (which ultimately determines the outcome) and an General Purpose Allocator for the World. We start by creating a simulated World with random action weights. If FRNG entropy is very low, we can run out of entropy even at this stage. We assume that the code is innocent until proven guilty — if we don’t have enough entropy to find a bug, this particular test returns success. Don’t worry, we’ll make sure that we have enough entropy elsewhere.

We use catch |err| switch(err) to peel off OutOfEntropy error. I find that, whenever I handle errors in Zig, very often I want to discharge just a single error from the error set. I wish I could use parenthesis with a catch:

// NOT ACTUALY ZIG :(

var world = try World.init(gpa, &frng)
    catch (error.OutOfEntropy) return;

Anyway, having created the World, we step through it while we still have entropy left. If any step detects an internal inconsistency, the entire World crashes with an assertion failure. If we got to the end of while(true) loop, we know that at least that particular slice of entropy didn’t uncover anything suspicious.

Notice what isn’t there. We aren’t generating a complete list of actions up-front. Rather, we make random decisions as we go, and can freely use the current state of the World to construct a menu of possible choices (e.g., when sending a message, we can consider only not currently crashed replicas).

Binary Search the Answer

And here we can finally see the reason why we bothered writing a custom Finite PRNG, rather than using an off-the-shelf one. The amount of entropy in FRNG defines the complexity of the test. The fewer random bytes we start with, the faster we exit the step loop. And this gives us an ability to minimize test cases essentially for free.

Suppose you know that a particular entropy slice makes the test fail (cluster enters split brain at the millionth step). Let’s say that the slice was 16KiB. The obvious next step is to see if just 8KiB would be enough to crash it. And, if 8KiB isn’t, than perhaps 12KiB?

You can binary search the minimal amount of entropy that’s enough for the test to fail. And this works for any test, it doesn’t have to be a distributed system. If you can write the code to generate your inputs randomly, you can measure complexity of each particular input by measuring how many random bytes were drawn in its construction.

And now the hilarious part — of course it seems that the way to minimize entropy is to start with a particular failing slice and apply genetic-algorithm mutations to it. But a much simpler approach seems to work in practice — just generated a fresh, shorter entropy slice. If you found some failure at random, then you should be able to randomly stumble into a smaller failing example, if one exists — there are much fewer small examples, so finding a failing one becomes easier when the size goes down!

The Searcher

The problem with binary searching for failing entropy is that a tripped assertion crashes the program. There’s no unwinding in Zig. For this reason, we’ll move the search code to a different process. So a single test will be a binary with a main function, that takes entropy on stdin.

Zig’s new juicy main makes writing this easier than in any previous versions of Zig :D

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const io = init.io;

    var stdin_reader = std.Io.File.stdin().reader(io, &.{});
    const entropy = try stdin_reader.interface
        .allocRemaining(gpa, .unlimited);
    defer gpa.free(entropy);

    var frng = FRNG.init(entropy);

    var world = World.init(gpa, &frng, .{}) catch |err|
        switch (err) {
            error.OutOfEntropy => return,
            else => return err,
        };
    defer world.deinit(gpa);

    world.run();
}

Main gets Init as an argument, which provides access to things like command line arguments, default allocator and a default Io implementation. These days, Zig eschews global ambient IO capabilities, and requires threading an Io instance whenever we need to make a syscall. Here, we need Io to read stdin.

Now we will implement a harness to call this main. This will be FRNG.Driver:

pub const Driver = struct {
    io: std.Io,
    sut: []const u8,
    buffer: []u8,

    const log = std.log;
};

It will be spawning external processes, so it’ll need an Io. We also need a path to an executable with a test main function, a System Under Test. And we’ll need a buffer to hold the entropy. This driver will be communicating successes and failures to the users, so we also prepare a log for textual output.

How we get entropy to feed into sut? Because we are only interested in entropy size, we won’t be storing the actual entropy bytes, and instead will generate it from a u64 seed. In other words, just two numbres, entropy size and seed, are needed to reproduce a single run of the test:

fn run_once(driver: Driver, options: struct {
    size: u32,
    seed: u64,
    quiet: bool,
}) !enum { pass, fail } {
    assert(options.size <= driver.buffer.len);
    const entropy = driver.buffer[0..options.size];

    var rng = std.Random.DefaultPrng.init(options.seed);
    rng.random().bytes(entropy);

    var child = try std.process.spawn(driver.io, .{
        .argv = &.{driver.sut},
        .stdin = .pipe,
        .stderr = if (options.quiet) .ignore else .inherit,
    });

    try child.stdin.?.writeStreamingAll(driver.io, entropy);
    child.stdin.?.close(driver.io);
    child.stdin = null;

    const term = try child.wait(driver.io);
    return if (success(term)) .pass else .fail;
}

fn success(term: std.process.Child.Term) bool {
    return term == .exited and term.exited == 0;
}

We use default deterministic PRNG to expand our short seed into entropy slice of the required size. Then we spawn sut proces, feeding the resulting entropy via stdin. Closing child’s stdin signals the end of entropy. We then return either .pass or .fail depending on child’s exit code. So, both explicit errors and crashes will be recognized as failures.

Next, we implement the logic for checking if a particular seed size is sufficient to find a failure. Of course, we won’t be able to say that for sure in a finite amount of time, so we’ll settle for some user-specified amount of retries:

fn run_multiple(driver: Driver, options: struct {
    size: u32,
    attempts: u32,
}) !union(enum) { pass, fail: u64 } {
    // ...
}

The user passes us the number of attempts to make, and we return .pass if they all were successfull, or a specific failing seed if we found one:

assert(options.size <= driver.buffer.len);

for (0..options.attempts) |_| {
    var seed: u64 = undefined;
    driver.io.random(@ptrCast(&seed));

    const outcome = try driver.run_once(.{
        .seed = seed,
        .size = options.size,
        .quiet = true,
    });
    switch (outcome) {
        .fail => return .{ .fail = seed },
        .pass => {},
    }
}
return .pass;

To generate a real seed we need “true” cryptographic non-deterministic randomness, which is provided by io.random.

Finally, the search for the size:

fn search(driver: Driver, options: struct {
    attempts: u32 = 100,
}) !union(enum) {
    pass,
    fail: struct { size: u32, seed: u64 },
} {
    // ...
}

Here, we are going to find a smallest entropy size that crashes sut. If we succeed, we return the seed and the size. The upper bound for the size is the space available in the pre-allocated entropy buffer.

The search loop is essentially a binary search, with a twist — rather than using dichotomy on the size directly, we will be doubling a step we use to change the size between iterations.

That is, we start with a small size and step, and, on every iteration, double the step and add it to the size, until we hit a failure (or run out of buffer for the entropy).

Once we found a failure, we continue the serach in the other direction — halving the step and subtracting it from the size, keeping the smaller size if it still fails.

On each step, we log the current size and outcome, and report the smallest failing size at the end.

var found_size: ?u32 = null;
var found_seed: ?u64 = null;

var pass: bool = true;
var size: u32 = 16;
var step: u32 = 16;
for (0..1024) |_| {
    if (step == 0) break;
    const size_next = if (pass) size + step else size -| step;
    if (size > driver.buffer.len) break;

    const outcome = try driver.run_multiple(.{
        .size = size_next,
        .attempts = options.attempts,
    });
    switch (outcome) {
        .pass => log.info("pass: size={}", .{size_next}),
        .fail => |seed| {
            found_size = size_next;
            found_seed = seed;
            log.err("fail: size={} seed={}", .{ size_next, seed });
        },
    }
    const pass_next = (outcome == .pass);

    if (pass and pass_next) {
        step *= 2;
    } else if (!pass and !pass_next) {
        // Keep the step.
    } else {
        step /= 2;
    }

    if (pass or !pass_next) {
        size = size_next;
        pass = pass_next;
    }
} else @panic("safety counter");

if (found_size == null) return .pass;
return .{ .fail = .{
    .size = found_size.?,
    .seed = found_seed.?,
} };

Finally, we wrap Driver’s functionality into main that works in two modes — either reproduces a given failure from seed and size, or searches for a minimal failure:

pub fn main(
    gpa: std.mem.Allocator,
    io: std.Io,
    sut: []const u8,
    operation: union(enum) {
        replay: struct { size: u32, seed: u64 },
        search: struct {
            attempts: u32 = 100,
            size_max: u32 = 4 * 1024 * 1024,
        },
    },
) !void {
    const size_max = switch (operation) {
        .replay => |options| options.size,
        .search => |options| options.size_max,
    };

    const buffer = try gpa.alloc(u8, size_max);
    defer gpa.free(buffer);

    var driver: Driver = .{
        .io = io,
        .buffer = buffer,
        .sut = sut,
    };

    switch (operation) {
        .replay => |options| {
            const outcome = try driver.run_once(.{
                .size = options.size,
                .seed = options.seed,
                .quiet = false,
            });
            log.info("{t}", .{outcome});
        },
        .search => |options| {
            const outcome = try driver.search(.{
                .attempts = options.attempts,
             });
            switch (outcome) {
                .pass => log.info("ok", .{}),
                .fail => |fail| {
                    log.err("minimized size={} seed={}", .{
                        fail.size, fail.seed,
                     });
                },
            }
        },
    }
}

Running the search routine looks like this in a terminal:

Those final seed&size can then be used for .replay, giving you a minimal reproducible failure for debugging!

This … of course doesn’t look too exciting without visualizing a specific bug we can find this way, but the problem there is that interesting examples of systems to test in this way usually take more than 256 lines to implement. So I’ll leave it to your imagination, but you get the idea: if you can make a system fail under a “random” input, you can also systematically search the space of all inputs for the smallest counter-example, without adding knowledge about the system to the searcher. This article also provides a concrete (but somewhat verbose) example.

Here’s the full code:

https://gist.github.com/matklad/343d13547c8bfe9af310e2ca2fbfe109

Read the whole story
alvinashcraft
9 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Writing Node.js addons with .NET Native AOT

1 Share

C# Dev Kit is a VS Code extension. Like all VS Code extensions, its front end is TypeScript running in Node.js. For certain platform-specific tasks, such as reading the Windows Registry, we’ve historically used native Node.js addons written in C++, which are compiled via node-gyp during installation to the developer’s workspace.

This works, but it comes with overhead. Using node-gyp to build these particular packages requires an old version of Python to be installed on every developer’s machine. For a team that works on .NET tooling, this requirement added complexity and friction. New contributors had to set up tools they’d never touch directly, and CI pipelines needed to provision and maintain them, which slowed down builds and added yet another set of dependencies to keep up to date over time.

The C# Dev Kit team already has the .NET SDK installed, so why not use C# and Native AOT to streamline our engineering systems?

How Node.js addons work

A Node.js native addon is a shared library (.dll on Windows, .so on Linux, .dylib on macOS) that exports a specific entry point. When Node.js loads such a library, it calls the function napi_register_module_v1. The addon registers any functions it provides, and from that point on, JavaScript treats it like any other module.

The interface that makes this possible is N-API (also called Node-API) – a stable, ABI-compatible C API for building addons. N-API doesn’t care what language produced the shared library, only that it exports the right symbols and calls the right functions. This makes Native AOT a viable option because it can produce shared libraries with arbitrary native entry points, which is all N-API needs.

Throughout the rest of this post, let’s look at the key parts of a small Native AOT Node.js addon that can read a string value from the registry. To keep things simple, we’ll put all the code in one class, though you could easily factor things out to be reusable.

The project file

The project file is minimal:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

PublishAot tells the SDK to produce a shared library when the project is published. AllowUnsafeBlocks is needed because the N-API interop involves function pointers and fixed buffers.

The module entry point

Node.js expects the shared library to export napi_register_module_v1. In C#, we can do this with [UnmanagedCallersOnly]:

public static unsafe partial class RegistryAddon
{
    [UnmanagedCallersOnly(
        EntryPoint = "napi_register_module_v1",
        CallConvs = [typeof(CallConvCdecl)])]
    public static nint Init(nint env, nint exports)
    {
        Initialize();

        RegisterFunction(
            env,
            exports,
            "readStringValue"u8,
            &ReadStringValue);

        // Register additional functions...

        return exports;
    }
}

A few C# features are doing work here. nint is a native-sized integer — the managed equivalent of intptr_t – used to pass around N-API handles. The u8 suffix produces a ReadOnlySpan<byte> containing a UTF-8 string literal, which we pass directly to N-API without any encoding or allocation. And [UnmanagedCallersOnly] tells the AOT compiler to export the method with the specified entry point name and calling convention, making it callable from native code.

Each call to RegisterFunction attaches a C# function pointer to a named property on the JavaScript exports object, so that calling addon.readStringValue(...) in JavaScript invokes the corresponding C# method directly, in-process.

Calling N-API from .NET

N-API functions are exported by node.exe itself, so rather than linking against a separate library, we need to resolve them against the host process. We declare our P/Invoke methods using [LibraryImport] with "node" as the library name, and then register a custom resolver via NativeLibrary.SetDllImportResolver that redirects to the host process at runtime:

private static void Initialize()
{
    NativeLibrary.SetDllImportResolver(
        System.Reflection.Assembly.GetExecutingAssembly(),
        ResolveDllImport);

    static nint ResolveDllImport(
        string libraryName,
        Assembly assembly,
        DllImportSearchPath? searchPath)
    {
        if (libraryName is not "node")
            return 0;

        return NativeLibrary.GetMainProgramHandle();
    }
}

With this resolver in place, the runtime knows to look up all "node" imports from the host process, and the N-API P/Invoke declarations work without any additional configuration:

private static partial class NativeMethods
{
    [LibraryImport("node", EntryPoint = "napi_create_string_utf8")]
    internal static partial Status CreateStringUtf8(
        nint env, ReadOnlySpan<byte> str, nuint length, out nint result);

    [LibraryImport("node", EntryPoint = "napi_create_function")]
    internal static unsafe partial Status CreateFunction(
        nint env, ReadOnlySpan<byte> utf8name, nuint length,
        delegate* unmanaged[Cdecl]<nint, nint, nint> cb,
        nint data, out nint result);

    [LibraryImport("node", EntryPoint = "napi_get_cb_info")]
    internal static unsafe partial Status GetCallbackInfo(
        nint env, nint cbinfo, ref nuint argc,
        Span<nint> argv, nint* thisArg, nint* data);

    // ... other N-API functions as needed
}

For each registered function we must register a native function as a named property on the exports object:

private static unsafe void RegisterFunction(
    nint env, nint exports, ReadOnlySpan<byte> name,
    delegate* unmanaged[Cdecl]<nint, nint, nint> callback)
{
    NativeMethods.CreateFunction(env, name, (nuint)name.Length, callback, 0, out nint fn);
    NativeMethods.SetNamedProperty(env, exports, name, fn);
}

The source-generated [LibraryImport] handles the marshalling. ReadOnlySpan<byte> maps cleanly to const char*, function pointers are passed through directly, and the generated code is trimming-compatible out of the box.

Marshalling strings

Most of the interop work comes down to moving strings between JavaScript and .NET. N-API uses UTF-8, so the conversion is straightforward, though it does require a buffer. Here’s a helper that reads a string argument passed from JavaScript:

private static unsafe string? GetStringArg(nint env, nint cbinfo, int index)
{
    nuint argc = (nuint)(index + 1);
    Span<nint> argv = stackalloc nint[index + 1];
    NativeMethods.GetCallbackInfo(env, cbinfo, ref argc, argv, null, null);

    if ((int)argc <= index)
        return null;

    // Ask N-API for the UTF-8 byte length
    NativeMethods.GetValueStringUtf8(env, argv[index], null, 0, out nuint len);

    // Allocate a buffer
    int bufLen = (int)len + 1;
    byte[]? rented = null;
    Span<byte> buf = bufLen <= 512
        ? stackalloc byte[bufLen]
        : (rented = ArrayPool<byte>.Shared.Rent(bufLen));

    try
    {
        fixed (byte* pBuf = buf)
            NativeMethods.GetValueStringUtf8(env, argv[index], pBuf, len + 1, out _);

        return Encoding.UTF8.GetString(buf[..(int)len]);
    }
    finally
    {
        if (rented is not null)
            ArrayPool<byte>.Shared.Return(rented);
    }
}

This code asks N-API for the byte length, allocates a buffer (on the stack for small strings, from the pool for larger ones), reads the bytes, then decodes to a .NET string.

Returning a string to JavaScript is the same process in reverse. We encode a .NET string into a UTF-8 buffer and pass it to napi_create_string_utf8:

private static nint CreateString(nint env, string value)
{
    int byteCount = Encoding.UTF8.GetByteCount(value);

    byte[]? rented = null;
    Span<byte> buf = byteCount <= 512
        ? stackalloc byte[byteCount]
        : (rented = ArrayPool<byte>.Shared.Rent(byteCount));

    try
    {
        Encoding.UTF8.GetBytes(value, buf);
        NativeMethods.CreateStringUtf8(
            env, buf[..byteCount], (nuint)byteCount, out nint result);
        return result;
    }
    finally
    {
        if (rented is not null)
            ArrayPool<byte>.Shared.Return(rented);
    }
}

Both directions use Span<T>, stackalloc, and ArrayPool to avoid heap allocations for typical string sizes. Once you have these helpers in place, you can write exported functions without thinking much about marshalling values.

Implementing an exported function

With the N-API plumbing in place, implementing an actual exported function is straightforward. Here’s one that reads a value from the Windows Registry and returns it to JavaScript as a string:

[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static nint ReadStringValue(nint env, nint info)
{
    try
    {
        var keyPath = GetStringArg(env, info, 0);
        var valueName = GetStringArg(env, info, 1);

        if (keyPath is null || valueName is null)
        {
            ThrowError(env, "Expected two string arguments: keyPath, valueName");
            return 0;
        }

        using var key = Registry.CurrentUser.OpenSubKey(
            keyPath,
            writable: false);

        return key?.GetValue(valueName) is string value
            ? CreateString(env, value)
            : GetUndefined(env);
    }
    catch (Exception ex)
    {
        ThrowError(env, $"Registry read failed: {ex.Message}");
        return 0;
    }
}

The structure is the same for every exported function. Read any arguments to the function first. Here we read string arguments with GetStringArg. Then, do the work using normal .NET APIs, and finally return a result via CreateString or similar. One thing to be careful about is exception handling – an unhandled exception in an [UnmanagedCallersOnly] method will crash the host process. We catch exceptions and forward them to JavaScript via ThrowError, which causes a standard JavaScript Error to be thrown on the calling side.

This example also shows why native addons are useful in the first place. Node.js doesn’t have built-in access to the Windows Registry, so a native addon lets us use Microsoft.Win32.Registry from .NET and expose the result to JavaScript with minimal ceremony.

Calling our function from TypeScript

First, we must produce a platform-specific shared library. Running dotnet publish produces a native library appropriate for your operating system (for example, RegistryAddon.dll on Windows, libRegistryAddon.so on Linux, or libRegistryAddon.dylib on macOS). By convention, Node.js treats paths ending with .node as native addons, so we rename this output file to MyNativeAddon.node.

We declare a TypeScript interface for our module, through which we expose type-safe access to our module’s functions:

interface RegistryAddon {
    readStringValue(keyPath: string, valueName: string): string | undefined;

    // Declare additional functions...
}

From there, loading it in TypeScript is a standard require() call:

// Load our native module
const registry = require('./native/win32-x64/RegistryAddon.node') as RegistryAddon

// Call our native function
const sdkPath = registry.readStringValue(
    'SOFTWARE\\dotnet\\Setup\\InstalledVersions\\x64\\sdk', 'InstallLocation')

And with that, we’re done! We can call from TypeScript into native code that was written in C#. While this particular registry addon is Windows-only, the same Native AOT and N-API approach works equally well on Windows, Linux, and macOS.

What about existing libraries?

There is an existing project, node-api-dotnet, that provides a higher-level framework for .NET/JavaScript interop. It handles a lot of the boilerplate and supports richer scenarios. For our use case, we only needed a handful of functions, and the thin N-API wrapper gave us full control over the interop layer without bringing in additional dependencies. If you need to expose entire .NET classes or handle callbacks from JavaScript into .NET, a library like that is worth considering.

What we gained

The immediate, practical benefit was simplifying our contributor experience. Anyone who wants to develop in our repo no longer needs a specific Python version. yarn install works with just Node.js, C++ tooling and the .NET SDK, which are tools we already require for development. Our CI pipelines are simpler as well.

Performance has been comparable to the C++ implementation. Native AOT produces optimized native code, and for the kind of work these functions do – string marshalling, registry access – there’s no meaningful difference in practice. The .NET runtime does bring a garbage collector and a slightly larger memory footprint, but in a long-running VS Code extension process this is negligible.

Looking ahead, this opens up some interesting possibilities. We currently run substantial .NET workloads in a separate process, communicating over a pipe. With Native AOT producing shared libraries that load directly into the Node.js process, we could potentially host some of that logic in-process, avoiding the serialization and process-management overhead. That’s a longer-term exploration, but the foundation is now in place.

A footnote

When the idea of using Native AOT first arose, no one on the team had direct experience of integrating native code with Node.js. Even though we have experience with Native AOT, the prospect of learning N-API’s C calling conventions and wiring up the interop might have seemed daunting enough to put the whole idea on the back burner. GitHub Copilot allowed us to get a working proof-of-concept running very quickly, at which point the idea seemed promising enough to pursue. It’s been a fantastic tool for exploring ideas that we wouldn’t previously have had the time for. It’s improving our products, and the team’s quality of life.

Summary

Native AOT increases the number of places you can run your .NET code. In this case, it allowed us to consolidate our tooling around fewer technologies and streamline our developer experience, particularly for onboarding new developers to the codebase.

If you’re running in Node.js, or in any other environment that can load native code, consider using Native AOT to produce that code. It allows you to write your native code in a language with memory safety, a rich standard library, and modern tooling. And if you’re not very familiar with native coding, you might be surprised to learn just how simple it can be to wire this all up (especially if you have a Copilot to help).

The post Writing Node.js addons with .NET Native AOT appeared first on .NET Blog.

Read the whole story
alvinashcraft
9 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Trump Administration Begins Refunding $166 Billion In Tariffs

1 Share
"After a Supreme Court of the United States ruling in Feb. 2026, many tariffs imposed by the Trump administration were declared illegal because the president overstepped his authority," writes Slashdot reader hcs_$reboot. "As a result, the U.S. government now has to refund a massive amount of money, around $160-170+ billion, paid mainly by importers." According to the New York Times, the administration has now begun accepting refund requests, "surrendering its prized source of revenue -- plus interest." From the report: For some U.S. businesses, the highly anticipated refunds could be substantial, offering critical if belated financial relief. Tariffs are taxes on imports, so the president's trade policies have served as a great burden for companies that rely on foreign goods. Many have had to choose whether to absorb the duties, cut other costs or pass on the expenses to consumers. By Monday morning, those companies can begin to submit documentation to the government to recover what they paid in illegal tariffs. In a sign of the demand, more than 3,000 businesses, including FedEx and Costco, have already sued the Trump administration in a bid to secure their refunds, with some cases filed even before the Supreme Court's ruling. But only the entities that officially paid the tariffs are eligible to recover that money. That means that the fuller universe of people affected by Mr. Trump's policies -- including millions of Americans who paid higher prices for the products they bought -- are not able to apply for direct relief. The extent to which consumers realize any gain hinges on whether businesses share the proceeds, something that few have publicly committed to do. Some have started to band together in class-action lawsuits in the hopes of receiving a payout. Many business owners said they weren't sure how easy the tariff refund process would be, particularly given Mr. Trump's stated opposition to returning the money. The administration has suggested that it may be months before companies see any money. Adding to the uncertainty, the White House has declined to say if it might still try to return to court in a bid to halt some or all of the refunds. The money will mostly go to importers and companies, since they were the ones that directly paid the tariffs. While individual refunds with interest could take around 60 to 90 days to process, the overall effort will probably move much more slowly because of how large and complicated it will be. There are also legal questions around whether companies would have to pass any of that money on to consumers. Slashdot reader AmiMoJo commented: "This is perhaps the biggest transfer of wealth in American history. Most of those companies will just pocket the refund and not pass any of it on to the consumer. If prices go down at all, they won't be back to pre-tariff levels. You paid the tariffs, but you ain't getting the refund."

Read more of this story at Slashdot.

Read the whole story
alvinashcraft
50 minutes ago
reply
Pennsylvania, USA
Share this story
Delete

Changes to GitHub Copilot Individual plans

1 Share

Today we’re making the following changes to GitHub Copilot’s Individual plans to protect the experience for existing customers: pausing new sign-ups, tightening usage limits, and adjusting model availability. We know these changes are disruptive, and we want to be clear about why we’re making them and how they will affect you.

Agentic workflows have fundamentally changed Copilot’s compute demands. Long-running, parallelized sessions now regularly consume far more resources than the original plan structure was built to support. As Copilot’s agentic capabilities have expanded rapidly, agents are doing more work, and more customers are hitting usage limits designed to maintain service reliability. Without further action, service quality degrades for everyone.

We’ve heard your frustrations about usage limits and model availability, and we need to do a better job communicating the guardrails we are adding—here’s what’s changing and why.

  1. New sign-ups for GitHub Copilot Pro, Pro+, and Student plans are paused. Pausing sign-ups allows us to serve existing customers more effectively.
  2. We are tightening usage limits for individual plans. Pro+ plans offer more than 5X the limits of Pro. Users on the Pro plan who need higher limits can upgrade to Pro+. Usage limits are now displayed in VS Code and Copilot CLI to make it easier for you to avoid hitting these limits.
  3. Opus models are no longer available in Pro plans. Opus 4.7 remains available in Pro+ plans. As we announced in our changelog, Opus 4.5 and Opus 4.6 will be removed from Pro+.

These changes are necessary to ensure we can serve existing customers with a predictable experience. If you hit unexpected limits or these changes just don’t work for you, you can cancel your Pro or Pro+ subscription and you will not be charged for April usage. Please reach out to GitHub support between April 20 and May 20 for a refund.

How usage limits work in GitHub Copilot

GitHub Copilot has two usage limits today: session and weekly (7 day) limits. Both limits depend on two distinct factors—token consumption and the model’s multiplier.

The session limits exist primarily to ensure that the service is not overloaded during periods of peak usage. They’re set so most users shouldn’t be impacted. Over time, these limits will be adjusted to balance reliability and demand. If you do encounter a session limit, you must wait until the usage window resets to resume using Copilot.

Weekly limits represent a cap on the total number of tokens a user can consume during the week. We introduced weekly limits recently to control for parallelized, long-trajectory requests that often run for extended periods of time and result in prohibitively high costs.

The weekly limits for each plan are also set so that most users will not be impacted. If you hit a weekly limit and have premium requests remaining, you can continue to use Copilot with Auto model selection. Model choice will be reenabled when the weekly period resets. If you are a Pro user, you can upgrade to Pro+ to increase your weekly limits. Pro+ includes over 5X the limits of Pro.

Usage limits are separate from your premium request entitlements. Premium requests determine which models you can access and how many requests you can make. Usage limits, by contrast, are token-based guardrails that cap how many tokens you can consume within a given time window. You can have premium requests remaining and still hit a usage limit.

Avoiding surprise limits and improving our transparency

Starting today, VS Code and Copilot CLI both display your available usage when you’re approaching a limit. These changes are meant to help you avoid a surprise limit.

Screenshot of a usage limit being hit in VS Code. A message appears that says 'You've used over 75% of your weekly usage limit. Your limit resets on Apr 27 at 8:00 PM.'
Usage limits in VS Code
A screenshot of a usage limit being hit in GitHub Copilot CLI. A message appears that says '! You've used over 75% of your weekly usage limit. Your limit resets on Apr 24 at 3 PM.'
Usage limits in Copilot CLI

If you are approaching a limit, there are a few things you can do to help reduce the chances of hitting it:

  • Use a model with a smaller multiplier for simpler tasks. The larger the multiplier, the faster you will hit the limit.
  • Consider upgrading to Pro+ if you are on a Pro plan to raise your limit by over 5X.
  • Use plan mode (VS Code, Copilot CLI) to improve task efficiency. Plan mode also improves task success.
  • Reduce parallel workflows. Tools such as /fleet will result in higher token consumption and should be used sparingly if you are nearing your limits.

Why we’re doing this

We’ve seen usage intensify for all users as they realize the value of agents and subagents in tackling complex coding problems. These long-running, parallelized workflows can yield great value, but they have also challenged our infrastructure and pricing structure: it’s now common for a handful of requests to incur costs that exceed the plan price! These are our problems to solve. The actions we are taking today enable us to provide the best possible experience for existing users while we develop a more sustainable solution.

The post Changes to GitHub Copilot Individual plans appeared first on The GitHub Blog.

Read the whole story
alvinashcraft
51 minutes ago
reply
Pennsylvania, USA
Share this story
Delete
Next Page of Stories