DEV Community

Kyrylo Malovychko
Kyrylo Malovychko

Posted on

The Algorithm Was Correct. The Graph Contract Was Not.

I tried to build an algorithm-heavy service with AI before I understood the algorithms well enough to specify them. A few days later I had a lot of code, no coherent graph, and one decision that changed the next two years of my work.

I began with a shortcut.

The service I wanted to build depended on a long list of algorithms. Some were familiar. Some I remembered from university only as names, formulas, and half-erased diagrams. A few I did not understand well enough to control at all.

AI looked like the obvious accelerator. I could describe the pieces, ask for implementations, join them later, and learn the difficult parts while the product was already taking shape.

Several days of hard work produced files at an impressive rate.

There were interfaces. Options. Callbacks. Result types. Comments that sounded confident.

Almost none of it belonged to the same system.

I could not even make BFS and DFS work against one graph model that I understood. One implementation expected adjacency in one shape, the other quietly assumed another. Options changed behaviour I had never defined. Results returned values, but not guarantees. I could not explain what had happened, why the answer was correct, or which part of it would remain stable after the next refactor.

I was not mildly dissatisfied. I was completely disappointed.

There was exactly one useful result: I stopped trusting AI with the parts I had not learned. If these algorithms were going to sit under a real service, I had to understand them and build them myself.

That was the point of no return.

Code was not the missing part

It would be easy to turn this into another story about AI producing bad code. That is not the lesson.

The deeper mistake was mine. I had asked for implementations before I could state the contract around them.

What graph shapes were legal? Was neighbour order observable? Who owned tie-breaking? Could edges be directed, undirected, or mixed? What did 0 mean inside a matrix? How was an unreachable target represented? Did a result carry a path, a predecessor map, a cut, a tree, or only a number? Could a caller classify failures without parsing an error string?

I had no precise answers. AI filled the gaps with plausible assumptions.

That is what makes this class of failure dangerous. Broken syntax is cheap to reject. Plausible code is not. It compiles, returns something, and invites the rest of the system to depend on decisions nobody made consciously.

A function can be mathematically correct and still publish an unusable software contract.

The first example was neighbour order.

Code insert 1: the graph that looks harmless

This is a reduced reconstruction of the pattern, not the original generated code.

package naive

type Graph map[string]map[string]struct{}

func (g Graph) AddUndirected(a, b string) {
    if g[a] == nil {
        g[a] = make(map[string]struct{})
    }
    if g[b] == nil {
        g[b] = make(map[string]struct{})
    }

    g[a][b] = struct{}{}
    g[b][a] = struct{}{}
}

func (g Graph) Neighbors(id string) []string {
    out := make([]string, 0, len(g[id]))
    for neighbor := range g[id] {
        out = append(out, neighbor)
    }
    return out
}
Enter fullscreen mode Exit fullscreen mode

This graph is small, normal Go. It stores undirected connections and exposes neighbours.

It also returns a slice without defining its order.

Go map iteration order is not a public ordering policy. The same topology can reach a traversal through a different neighbour sequence. The graph has already influenced the result before BFS or DFS runs, but no package owns the decision and no caller knows whether the order can be relied on.

Maybe order does not matter to the mathematics. It still matters to software.

The slice may be logged, serialised, compared in a golden test, used for equal-cost tie-breaking, or shown to a user. Once callers can observe it, “we never intended the order to matter” is not much of a defence.

Code insert 2: a correct BFS inherits the uncertainty

package naive

func BFS(g Graph, start string) []string {
    seen := map[string]bool{start: true}
    queue := []string{start}
    order := make([]string, 0, len(g))

    for len(queue) > 0 {
        current := queue[0]
        queue = queue[1:]
        order = append(order, current)

        for _, next := range g.Neighbors(current) {
            if seen[next] {
                continue
            }
            seen[next] = true
            queue = append(queue, next)
        }
    }

    return order
}
Enter fullscreen mode Exit fullscreen mode

Suppose api connects to both auth and cache, and both are one hop away.

These outputs can both satisfy the BFS distance property:

[api auth cache db worker]
[api cache auth db worker]
Enter fullscreen mode Exit fullscreen mode

The algorithm is not necessarily wrong.

The product can still be wrong.

If the sequence drives a deployment plan, chooses the first equal candidate, appears in a reproducible report, or becomes part of a test fixture, two mathematically legal answers are not an adequate API contract.

That was the first thing I had failed to specify. I had named the algorithm. I had not defined the observable law around it.

Back to the mathematics, one stubborn step at a time

I stopped generating more code and went back to theory.

That phase was slower than I expected. Article after article, I found dense formulas with almost no reasoning, implementations without a mathematical explanation, questionable translations, and examples that demonstrated syntax but taught very little about behaviour.

I had been naïve enough to think I would quickly recover the university mathematics and continue with the product.

Instead, I moved one stubborn step at a time.

Graphs and matrices came back first. BFS and DFS followed. Dijkstra and minimum spanning trees were where irritation turned into a decision.

The foundations were not new. Much of the mathematics predates the modern software industry. Yet the useful knowledge was split across papers, tutorials, source repositories, paid material, shallow snippets, and documentation that stopped exactly where the uncomfortable production questions began.

I kept asking the same thing:

Why is there no free, coherent source where the mathematics is explained, the implementation is open, the contracts are explicit, and the examples are worth running?

Eventually the answer became obvious.

Build it.

Not another catalogue of textbook functions. The source I had been looking for: something a developer could study, use, challenge, and improve without surrendering the difficult parts to a black box.

A graph is not “vertices plus edges”

My early model of a graph was too weak. I treated it as a container.

A graph is also a domain of legal states.

Are edges directed? Are non-zero weights allowed? Can directed and undirected edges coexist? Are loops valid? Are parallel edges distinct facts or duplicate input? Does adding an edge create missing vertices? Can an option rewrite endpoints after validation? What survives a clone? Which values alias live storage?

If these answers live only in comments, invalid topology travels until a later algorithm discovers it in the worst possible place.

Dijkstra should not be the first layer to reveal that the graph never validated weights. An MST implementation should not guess whether parallel edges are legal. DFS should not discover halfway through cycle analysis that “directed” meant “usually directed.”

The capabilities belong at construction and mutation boundaries.

Code insert 3: make the graph policy executable

package main

import (
    "fmt"
    "log"

    "github.com/lvlath/go/core"
)

func main() {
    g, err := core.NewGraph(core.WithDirected(false), core.WithWeighted())
    if err != nil {
        log.Fatal(err)
    }

    if _, err = g.AddEdge("api", "db", 3, core.WithID("edge-api-db")); err != nil {
        log.Fatal(err)
    }

    if _, err = g.AddEdge("api", "cache", 1, core.WithID("edge-api-cache")); err != nil {
        log.Fatal(err)
    }

    if _, err = g.AddEdge("api", "auth", 2, core.WithID("edge-api-auth")); err != nil {
        log.Fatal(err)
    }

    ids, err := g.NeighborIDs("api")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println(ids) // [auth cache db]
}
Enter fullscreen mode Exit fullscreen mode

The policy is visible.

The graph is undirected by default. Non-zero weights are legal. Edge identities are explicit. NeighborIDs publishes unique adjacent vertex IDs in lexicographic order.

The insertion order is not the contract.

A caller can dislike the policy, measure the cost, or propose another one. Fine. At least the disagreement is now concrete and testable.

That is the point of a contract. It does not eliminate trade-offs. It stops them hiding inside incidental implementation behaviour.

The number is not the meaning

Ordering was only the first hidden contract.

Numeric meaning was worse because the wrong answer could still look perfectly reasonable.

A 0 can be a real zero-cost edge. In a classic adjacency matrix, the same 0 can mean no edge at all. +Inf can mean no direct edge, no known path, or an uninitialised cell. A negative weight is legal for one algorithm and forbidden for another. NaN can poison a matrix while every surrounding type remains valid.

No number carries its domain semantics with it.

The representation has to declare the policy.

This became a hard boundary in lvlath/matrix. Zero-preserving weighted adjacency uses finite 0 for a real edge and +Inf for absence. Metric closure uses 0 on the diagonal and +Inf for unreachable pairs. Then it refuses to export those derived distances as if they were original graph edges.

That refusal is not inconvenience. It prevents a false topology.

The matrix problem deserves its own article, but the lesson belongs here: a perfect algorithm cannot recover information that the representation erased before the algorithm started.

Errors should survive better wording

The prototypes returned error values. That did not make the failures useful.

If a caller has to compare text, improved diagnostics become breaking changes. More importantly, different operational states collapse into one vague failure path.

Unknown is not unreachable. Unreachable is not “path tracking was disabled.” Cancellation is not resource exhaustion. Invalid topology is not an empty result.

Named error identity is not exciting. It is dependable.

Code insert 4: classify the failure, do not parse the sentence

package main

import (
    "errors"
    "fmt"
    "log"

    "github.com/lvlath/go/core"
)

func main() {
    g, err := core.NewGraph() // unweighted by default
    if err != nil {
        log.Fatal(err)
    }

    _, err = g.AddEdge("A", "B", 3)
    fmt.Println(errors.Is(err, core.ErrBadWeight)) // true
}
Enter fullscreen mode Exit fullscreen mode

ErrBadWeight is useful because code can react to its identity while the surrounding diagnostics improve.

The same discipline continues above the graph layer. A shortest-path package should distinguish an unknown target from a known unreachable one. A cycle detector should return a witness. A max-flow result should expose the residual network when the caller needs to inspect the bottleneck. A DTW result should distinguish alignment cost from the recovered path.

A number without evidence is often just a polite version of “trust me.”

I had already tried that approach.

v0.0.1: publish the weakness, not the mythology

The first architecture looked sensible when it lived only in my head:

graph/
├── algorithms/
├── core/
└── matrix/
Enter fullscreen mode Exit fullscreen mode

BFS, DFS, Dijkstra, Prim, and Kruskal shared one algorithm directory. core held the graph. matrix handled adjacency and incidence representations.

Simple.

Too simple.

Figure 1. The v0.0.1 structure. Useful as a public baseline; not a structure worth preserving.

Each algorithm wanted its own options, result type, errors, examples, ownership rules, complexity statement, and failure surface. Keeping them together encouraged convenient shared assumptions and made every explanation blur into the next one.

My second mistake was more expensive because I made it deliberately: implement first, document later.

I told myself that serious documentation should wait until the code worked. In practice, the delay allowed unclear behaviour to spread. When I finally tried to describe every public option, error, result, complexity claim, ownership rule, and edge case in one consistent format, the contradictions became impossible to ignore.

Another restructuring followed.

Documentation written after an API hardens is often archaeology. I had created my own dig site.

I shelved a chess engine. I abandoned the deeper-analysis service that had started the whole journey. The list of planned algorithms changed too. Early ambitions included SAX, HMM, ARIMA, GBM, and GJR-GARCH. Shipping that breadth on a weak foundation would have repeated the original mistake at a larger scale.

So I narrowed the path.

Graph and matrix first. Then BFS, DFS, Dijkstra, MST, flow, TSP, and DTW in an order a reader could actually follow.

I released v0.0.1 anyway.

It was primitive. Some parts were awkward. That was the point. I needed a real tag, a version I could install outside the IDE, and a public baseline that made weak seams visible.

Then the first email arrived.

The first user changed the quality bar

Another Go developer had found lvlath while looking for a graph library.

He was building a package for scheduling binary execution. Users could define arguments, conflicts, and dependencies between those arguments. He wanted to know whether graph theory, and lvlath specifically, could help him check those relationships efficiently even at a small scale.

Figure 2. The first external question was not about an academic demo. It was about conflicts, dependencies, and a real package design.

That message did not prove the library was good.

It proved the problem was not mine alone.

Until then, I could still treat the quality bar as personal. After that email, every ambiguous contract became someone else's risk. Another developer was ready to build on assumptions I had made.

“Works” stopped being enough.

So did 80 percent. Then 90. Then 95.

Not because a percentage can certify a library. It cannot. The numbers describe the movement of the bar: from a personal prototype toward public responsibility. Tests had to cover validation, medium cases, adversarial boundaries, deterministic repetition, race safety, and result witnesses. Documentation had to match the implementation. Examples had to compile against the public API and teach a problem worth recognising.

Praise would have felt good.

A concrete question was better. It forced the project to answer somebody else's reality.

v0.1.0: separate the contracts

The rebuilt structure is wider and much less accidental.

Figure 3. By v0.1.0, each algorithm owns a package boundary, while docs/ and examples/ are visible parts of the system.

One algorithm, one package.

Not because directories are architecture by themselves, but because boundaries force questions into the open. BFS and DFS no longer need to pretend they share one result contract. Dijkstra can own target semantics. MST can separate strict-tree and forest behaviour. Flow can expose residual-network evidence. TSP can state solver method, assumptions, and stopping policy. DTW can distinguish cost from path recovery and memory mode.

The documentation layer changed too.

example_test.go scenarios are not decorative snippets. They are executable teaching material. The long-form docs/*.md files explain the mathematics, the public contract, the failure modes, the complexity, and the operational traps. GoDoc states the API. Tests defend it. Source should not invent a fourth interpretation.

This combination is the part I am most proud of.

Not one clever function.

The agreement between code, explanation, example, and test.

Tests should defend promises, not accidents

A happy-path test can preserve the wrong behaviour forever.

If a test expects one traversal order but the API never promises an order, the test has converted an accident into a hidden requirement. If it reaches into private maps, it protects storage rather than caller behaviour. If it checks only a scalar cost, it can miss a broken witness.

For neighbour ordering, the useful test is not “this insertion sequence produced this slice once.”

It is “all supported construction histories publish the same documented surface.”

Code insert 5: build the same graph three ways

package core_test

import (
    "slices"
    "testing"

    "github.com/lvlath/go/core"
)

func TestNeighborIDsIgnoreInsertionOrder(t *testing.T) {
    orders := [][]string{
        {"db", "cache", "auth"},
        {"auth", "db", "cache"},
        {"cache", "auth", "db"},
    }
    want := []string{"auth", "cache", "db"}

    for _, order := range orders {
        g, err := core.NewGraph(core.WithDirected(false))
        if err != nil {
            t.Fatal(err)
        }

        for _, target := range order {
            if _, err = g.AddEdge("api", target, 0); err != nil {
                t.Fatal(err)
            }
        }

        got, err := g.NeighborIDs("api")
        if err != nil {
            t.Fatal(err)
        }

        if !slices.Equal(got, want) {
            t.Fatalf("insertion %v: got %v, want %v", order, got, want)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The test does not care whether adjacency is stored in maps, sorted slices, trees, or a compact representation not yet implemented. It defends only the behaviour the caller is entitled to observe.

That kind of test survives a real refactor.

The same idea scales. Validate a path independently from the solver. Verify that an MST is acyclic and spans the required component. Check flow conservation and residual capacity instead of trusting one maximum. Permute insertion order. Fuzz invalid option combinations. Separate race safety from snapshot guarantees.

A green suite is not proof by itself.

But every public guarantee needs evidence strong enough to fail when the guarantee is broken.

The current public promise

The present module is not just a repository tree. It publishes a contract where people already look for Go packages.

Figure 4. The current public promise: the same input and policy should produce the same observable result surface, witness semantics, and failure class.

The sentence at the top of the documentation is deliberately strict:

Same graph, same options, same algorithm - same result surface, same witness semantics, same failure class.

That promise is harder to maintain than implementing BFS.

It has to survive new packages, performance work, refactors, better diagnostics, more examples, and eventually new major versions. It requires boring details: stable identities, explicit capabilities, sentinel errors, documented ownership, numerical semantics, cancellation behaviour, partial results, and tests that do not depend on private storage.

Those details are the library.

The algorithm is only the centre.

What remains unfinished

There is plenty left to argue about.

Matching implementations such as blossom and greedy matching currently sit inside tsp; they may deserve clearer package boundaries. The planned catalogue is much larger than the current release. Future major versions will have to decide how far the ecosystem should expand without repeating the old mistake of breadth before a stable foundation.

The project uses one Go module with multiple packages. That is deliberate today. It gives users one versioned dependency surface while each algorithm owns its public API. Splitting packages into separate modules would add release and compatibility cost; it should happen only when user evidence justifies it, not because more module files look more modular.

v0.1.0 is not a monument.

Good.

There is still time to challenge the contracts before they harden.

The rule I use now

Before I blame an algorithm, I inspect the layer beneath it.

Does the graph publish a stable vertex, edge, and neighbour order?

Are directedness, weights, loops, parallel edges, and mixed edges validated as capabilities rather than suggested by comments?

Do 0, +Inf, NaN, negative values, and absence have one documented meaning in the current representation?

Are identity, cloning, views, aliases, metadata, and ownership clear?

Can software classify failures without parsing prose?

Does the result carry enough evidence to inspect the answer: path, predecessor map, cycle witness, tree, cut, residual graph, tour, or alignment?

Do the examples, tests, GoDoc, and long-form documentation describe the same behaviour?

If the answer is “it depends,” that is not always a bug.

It is a policy waiting for an owner.

The point of no return, revisited

The original service never became the product I had imagined.

The chess engine was shelved. The deeper-analysis project was abandoned. The first architecture was replaced. Documentation forced another round of redesign. The list of future algorithms kept growing while the release scope had to become narrower.

That cost was real.

It also produced something I had not planned: a public source for the next engineer who reaches the same wall.

AI still has a place in my workflow. It can accelerate scaffolding, propose adversarial cases, compare designs, and reduce repetitive work. It cannot take responsibility for a mathematical contract I have not written.

That responsibility remains mine.

The first external user changed lvlath from a private attempt into a public obligation. The current structure, examples, documentation, and tests are how I am trying to honour it.

If a guarantee is weak, open an issue. If an example is confusing, say exactly where. If an algorithm is missing, describe the real problem before naming the package. If the mathematics is wrong, bring a counterexample.

I paid for the confusion once.

The next engineer should not have to.

Before debugging the algorithm, ask whether the graph underneath it ever made a promise.

Which hidden contract has cost you the most: ordering, topology, numerical meaning, identity, mutation, or missing evidence?


Repository and references

Top comments (0)