I Forced an AI to Program Like It Was 1995. After 20 Changes, It Started Reinventing Modern Software. ai coding.. ai coding. ANSI C.. ai coding. ANSI C. atomic writes.. ai coding. ANSI C. atomic writes. function pointers.. ai coding. ANSI C. atomic writes. function pointers. Legacy code.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring. schema migration.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring. schema migration. software architecture.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring. schema migration. software architecture. software maintenance... ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring. schema migration. software architecture. software maintenance.. искусственный интеллект.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring. schema migration. software architecture. software maintenance.. искусственный интеллект. Программирование.. ai coding. ANSI C. atomic writes. function pointers. Legacy code. llm. Refactoring. schema migration. software architecture. software maintenance.. искусственный интеллект. Программирование. Проектирование и рефакторинг.

Most experiments with AI coding assistants start from a modern stack. The model gets Python, JavaScript, Go, Rust or a recent version of C++, plus libraries, package managers and years of conventions already embedded in the ecosystem. That is useful if the goal is productivity, but I wanted to test something slightly stranger. What happens if the language gives the model almost none of the tools it is used to seeing in modern code? Would it actually think like a programmer from the mid-1990s, or would it simply reconstruct modern architecture using primitive language features?

The starting application was intentionally boring. It was a command-line contact database written in ANSI C. One executable, one local data file, fixed-size fields and a few commands. There was no database server, no network, no dynamic plugin system, no package manager and no third-party code. The environment itself was modern Linux because I was interested in programming constraints rather than hardware emulation. Everything was compiled using GCC with strict C89 settings.

gcc -std=c89 -pedantic -Wall -Wextra -O2 contacts.c -o contacts

The first version was only 97 lines long. It could add contacts, print all contacts and search for a substring in a name. Records were written almost directly as C structures. There was barely any architecture because there was barely anything that needed architecture. That simplicity was useful because every abstraction introduced later had an obvious reason for appearing.

The starting point was almost painfully simple

The first implementation treated a contact as little more than a structure copied to disk. Search meant reading records one after another. There was no header, no file version, no migration logic and no stable identifier independent of file position. The core persistence logic looked roughly like this.

#include <stdio.h>
#include <string.h>

#define DB_FILE "people.dat"
#define NAME_LEN 32
#define PHONE_LEN 24

typedef struct {
    int active;
    char name[NAME_LEN];
    char phone[PHONE_LEN];
} Person;

static int save_person(Person *p)
{
    FILE *f;

    f = fopen(DB_FILE, "ab");

    if (f == NULL)
        return 0;

    if (fwrite(p, sizeof(Person), 1, f) != 1) {
        fclose(f);
        return 0;
    }

    fclose(f);
    return 1;
}

static void list_people(void)
{
    FILE *f;
    Person p;

    f = fopen(DB_FILE, "rb");

    if (f == NULL)
        return;

    while (fread(&p, sizeof(Person), 1, f) == 1) {
        if (p.active)
            printf("%s  %sn", p.name, p.phone);
    }

    fclose(f);
}

There is nothing clever here, and that was exactly what I wanted. A record is a struct. The database is a file. Persistence is fwrite. The mental model fits in your head almost immediately. Add a contact and one structure is appended. List contacts and the program reads structures until the file ends. It is the kind of code that feels wonderfully simple on day one and starts becoming uncomfortable as soon as data has to survive several generations of the program.

The model was not allowed to escape into modern infrastructure

The most important rule was that every new requirement had to be solved inside the existing world. ANSI C89 only, standard library only, one local executable, local files for persistence and no switching to another technology when the problem became uncomfortable. SQLite would have solved a large part of the experiment immediately, which was precisely why it was forbidden. The same applied to C++, Python, external serialization libraries, generated code and any kind of service architecture.

There was another rule that changed the experiment quite a lot: the model never received the future requirements in advance. It was not designing a system from a complete specification. Each request arrived as maintenance work on the code that already existed. First deletion was needed. Then editing a phone number. Then notes. Then stable identifiers because file positions were no longer useful as identity. After that came creation timestamps, case-insensitive search, prefix search, exact phone lookup, duplicate detection and phone validation. None of those requests looked dramatic by itself.

The later tasks were less friendly. The application had to detect incompatible data files instead of interpreting arbitrary bytes as current records. Data created by an older version needed to remain recoverable. A failed write should not destroy the previous database. Changes should leave enough history to understand what happened. Filtering logic should not be copied between several commands. Finally, persistence should no longer be hard-wired into every operation because another storage implementation might appear later. At no point did the requests mention clean architecture, dependency injection, repositories, migration frameworks or event logs. Still, pieces resembling all of them began appearing because the requirements kept pushing the program in that direction.

The first big change came from something as boring as adding one field

The original binary record contained an active flag, a name and a phone number. Then a note field was added. That sounds like a five-minute change, but direct struct serialization makes it a compatibility problem immediately. Once the size and layout of Person changed, an old file and a new file were no longer safely interchangeable. The program needed some way to know what it was reading before interpreting the bytes.

The model introduced a small file header containing a magic marker and a version number. It was not presented as an architectural breakthrough. It was simply the easiest way to stop old data from silently turning into garbage. The next requirement was backward compatibility, which forced the program to recognize the previous layout and convert it into the current one. At that point the application effectively had schema versions and migrations, even though there was no database and no migration tool anywhere in the project. That was the first point where the experiment became more interesting than expected. Versioning is often discussed as part of databases, APIs and serialization frameworks, but the actual need is much older and simpler. As soon as persistent data survives longer than the code that originally created it, history enters the system. You can avoid using the word schema, but you cannot avoid the underlying problem.

Crash safety quietly created something that looked like a transaction

The next architectural jump came from a very ordinary requirement: a failed save should not destroy the previous database. Earlier versions could mostly get away with appending records, but editing and deletion made full rewrites increasingly convenient. The naive implementation was easy: open the database for writing, truncate it and serialize the current state. That also meant that a crash halfway through the operation could leave half a database behind.

The model changed the procedure. The complete new state was written to a temporary file first. Only after that file had been successfully finished would it replace the previous database. This was obviously not a full ACID transaction. Filesystem behavior varies and true durability involves more than a successful fclose. Still, conceptually the structure had become familiar: prepare a new state, complete it, then make it current. Nothing in the request mentioned transactions. The primitive transaction-like boundary appeared because partial writes are dangerous regardless of whether the program was designed in 1995 or 2026. The environment had removed modern frameworks, but it had not removed the reasons those frameworks exist.

After twenty changes, the syntax was still old but the architecture was not

By the final checkpoint, the program still compiled as C89 and still depended only on the standard library. There were no classes, generics, traits, package managers or external components. Yet the structure of the application was completely different from the first version. Persistence had metadata, records had stable IDs, filtering had its own representation and storage was no longer directly embedded into every command.

The most revealing part looked something like this.

typedef struct {
    unsigned long id;
    int active;
    char name[32];
    char phone[24];
    char note[96];
    unsigned long created_at;
} Person;

typedef struct {
    const char *name_prefix;
    const char *phone;
    int only_active;
} Query;

typedef struct Storage Storage;

struct Storage {
    int (*load)(Storage *self, Database *db);
    int (*save)(Storage *self, const Database *db);
    const char *path;
};

static int file_save(
    Storage *self,
    const Database *db
)
{
    FILE *f;

    (void)self;

    f = fopen("people.tmp", "wb");

    if (f == NULL)
        return 0;

    if (!write_database(f, db)) {
        fclose(f);
        remove("people.tmp");
        return 0;
    }

    if (fclose(f) != 0)
        return 0;

    remove("people.db");

    if (rename("people.tmp", "people.db") != 0)
        return 0;

    return 1;
}

static int matches(
    const Person *p,
    const Query *q
)
{
    if (q->only_active && !p->active)
        return 0;

    if (
        q->name_prefix != NULL &&
        !prefix_ci(p->name, q->name_prefix)
    )
        return 0;

    if (
        q->phone != NULL &&
        !text_equal_ci(p->phone, q->phone)
    )
        return 0;

    return 1;
}

The part that caught my attention most was Storage. C89 has no interface keyword, but a structure containing function pointers can produce a very similar effect. The rest of the application can load and save data without knowing the exact implementation behind those operations. In C++ this might become an abstract base class. In Go it would probably become an interface. In Rust it could become a trait. Here the same idea was assembled manually from a struct and two function pointers.

That was the point where the experiment stopped feeling like old programming with AI assistance. The syntax was still old, but the design had clearly moved forward by decades. The compiler thought it was dealing with C89. The architecture did not seem to care what year it was.

The AI did not really violate the rules, it kept rebuilding what was missing

Before starting, I expected one of two boring outcomes. Either the model would remain procedural because the restrictions forced it to, or at some point it would give up and recommend SQLite, C++, Python or another modern tool. Instead, it repeatedly recreated missing abstractions using whatever C89 could provide.

When multiple persistence implementations became imaginable, function pointers appeared. When stored data began changing, version fields appeared. When the program needed to understand old records, conversion logic appeared. When failed writes became dangerous, temporary files created a commit boundary. When several operations needed the same validation, shared validation functions appeared. When filtering rules multiplied, they became a Query structure instead of a pile of repeated conditions. When file positions stopped being reliable identifiers, explicit IDs appeared. When it became important to understand previous changes, an append-only audit file appeared.

None of those ideas actually require a modern language. That seems obvious once everything is listed together, but watching them emerge one requirement at a time felt different. We often talk about modern architecture as if it belongs to modern ecosystems, while much of it is simply a response to recurring engineering pressure. Persistent state changes, so versioning appears. Writes fail, so some kind of commit boundary appears. Implementations vary, so an interface appears. Debugging historical changes becomes difficult, so history appears.

The names change. The problem does not.

Some modern instincts made the tiny program worse

The model was not automatically right just because the architecture looked familiar. One of the most useful parts of the experiment was watching it overreact to small problems. Once search gained case-insensitive matching, prefix search and exact phone lookup, the model started moving toward a persistent index. Technically that was a reasonable idea. Practically, the database was capped at 10,000 contacts. A linear scan over ten thousand small records is not exactly a distributed systems emergency.

Adding an index would have created another persistent structure that needed to stay synchronized with the primary data file. Updating a record would then mean updating two representations. A crash between those writes could leave them inconsistent. Startup might require verification or rebuilding. Deletion would need more logic. A tiny contacts utility was suddenly heading toward recovery procedures for an optimization it barely needed. That was a useful reminder that architectural instincts can be correct in shape and wrong in scale. The model recognized a familiar problem and moved toward a familiar solution, but the actual size of the application did not justify the extra machinery. Have you ever opened a tiny internal tool and discovered three layers around an operation that could have been one loop? This is probably one way that happens. Nobody wakes up and decides to overengineer the entire project. Twenty individually sensible decisions can achieve the same result.

A similar thing happened with the storage abstraction. Once Storage existed, it became tempting to add callbacks for every operation and make the entire persistence layer pluggable. On a diagram it would have looked cleaner. In a few hundred lines of C it would mostly have made the code harder to follow. I stopped at the point where the abstraction explained something useful rather than becoming the experiment itself.

The source code grew much faster than the binary

One result surprised me because it was almost the opposite of what the code looked like visually. The original checkpoint contained 97 lines of C. Compiled on the same x86-64 Linux machine with the same options, the dynamically linked executable was 16,576 bytes. After the sequence of changes, the working checkpoint contained 329 lines, while the executable reached 17,088 bytes.

The source had grown by more than three times, while the executable increased by only 512 bytes. Those numbers obviously depend on compiler version, target platform, linking strategy and the exact source, so they are not intended as a universal C benchmark. What interested me was the contrast between machine complexity and human complexity. From the CPU’s point of view, the application was still tiny. From a developer’s point of view, it now contained versioned persistence, stable identifiers, deleted state, validation, an audit trail, temporary writes, storage callbacks, query matching and compatibility rules. The expensive part was no longer the number of machine instructions. It was the amount of context required to change the application safely. The original program could almost be understood in a few minutes. The final version required knowing which file format was current, what happened after an interrupted save, how identifiers behaved after deletion, where validation occurred, when logging happened and which parts of the application were allowed to touch persistence.

This may be one reason old utilities can be surprisingly difficult to maintain even when their binaries are tiny and their repositories look harmless. A 20 KB executable can still contain years of accumulated decisions. Machine size and mental size are two very different measurements.

The cleaner architecture still sat on top of a bad old primitive

There was also an intentionally ugly problem that remained unresolved. The program still serialized native C structures using fwrite. That means the persistent representation can depend on padding, integer width, alignment and endianness. A version header can tell the application which format it expects, but it does not magically make that format portable across arbitrary platforms and compilers.

I deliberately left this weakness visible because it exposed something useful about refactoring. Putting a cleaner architecture around a primitive does not improve the primitive itself. The Storage layer is more organized than scattering fopen calls throughout the entire application, but underneath it the program is still dumping native memory layouts into files. A serious implementation should define a stable byte representation or use a known serialization format.

Fixing that would have moved the project farther away from the primitive old-style program I wanted to keep alive, though. The goal was not to build the best possible address book. It was to see what happened when modern AI had to work under old constraints. Leaving one historically believable weakness in place made the result more interesting than pretending every layer had suddenly become production-grade.

At some point the AI stopped thinking like it was 1995

The final program still looked old at the language level. There were no templates, garbage collector, async runtime, reflection, framework or package manager. Every abstraction had to be built from structs, functions, files and pointers. Yet the organization of the program had moved far away from the original procedural utility. That changed the original question for me. Can a modern coding AI program like it is 1995? Syntactically, yes. Give it C89 restrictions and it can stay inside them. Architecturally, the answer is much less clear because the model has been exposed to decades of software created after 1995. When persistent data evolves, it reaches for versioning. When updates become risky, it introduces transaction-like behavior. When several implementations become possible, it builds an interface even if the language has no interface keyword.

Removing modern tools did not remove modern thinking. It forced the model to reconstruct those ideas from smaller pieces. The more interesting question is whether this behavior is specific to AI at all. Give an experienced human developer C89, a persistent file format and years of changing requirements, and many of the same structures would probably appear eventually. They might have different names and might never be presented as formal patterns, but the engineering pressure would remain.

That is probably the most useful result I got from the experiment. Old software is not necessarily primitive software. Quite often it is software solving the same problems we solve today, except its abstractions had to be built manually and the fashionable terminology arrived much later.

Автор: Sisoev_Oleg66

Источник