# Goran Miletic

> The complete text of https://mileticgoran.com, generated from the same Markdown sources as the website.

**Email:** goran.miletic122@gmail.com  
**GitHub:** https://github.com/miletic22  
**LinkedIn:** https://www.linkedin.com/in/gmiletic2/  
**X:** https://x.com/GoranMi3

## English

### Home

My name is [Goran Miletic](/about/). I'm an AI engineer at [Mason Labs](https://usemason.ai/) and a Data Science and Statistics major at [Minerva University](https://www.minerva.edu/).

My work focuses on AI systems that turn scattered organizational knowledge into shared, usable context. I'm especially interested in knowledge graphs and company-brain architectures, along with the evaluation harnesses, hill-climbing methods, and verification techniques needed to make agents measurable, improvable, and trustworthy.

### About

I'm an AI engineer at [Mason Labs](https://usemason.ai/) and a Computer Science student at [Minerva University](https://www.minerva.edu/), concentrating in Data Science and Statistics. At Mason, I build AI agents and the systems that make their work reliable, measurable, and traceable.

Before Mason, I spent three years on Minerva's data team building analytics tools for students and faculty. I launched a scorecard system backed by 720,000 academic metrics, turned internal Python tools into browser extensions, and used data to improve the introductory computer science curriculum. At Spark Amplify, I built and evaluated an NLP service that simplified books across six reading levels for students with limited English proficiency. I also built [Fuego.Earth](/projects/fuego-earth/), a wildfire-intelligence platform whose caching system cut repeat simulations from about 30 seconds to 200 milliseconds.

My current work sits at the intersection of knowledge graphs, shared organizational memory, evaluation harnesses, hill-climbing, agent behavior, and verifiability. In my senior thesis, I am building and evaluating ways for agents to use a company's accumulated knowledge without becoming opaque: keeping their claims attributable, their behavior measurable, and their performance open to systematic improvement.

You can read my [notes](/notes/), browse my [projects](/projects/), or find me on [GitHub](https://github.com/miletic22), [LinkedIn](https://www.linkedin.com/in/gmiletic2/), and [X](https://x.com/GoranMi3).

## English projects

### [The Pillar Paradox](https://mileticgoran.com/projects/pillar-paradox/)

**Date:** 2026

**Summary:** A social-force simulation and evolutionary search testing when pillars help—or harm—crowds moving through a door.

**A pillar in front of an exit can make a panicking crowd leave faster. It can also turn an ordinary evacuation into a permanent jam.** The interesting problem is not whether the paradox exists in one simulation. It is why it appears, where it stops, and whether a search can find a layout that is useful without being dangerous.

I built a social-force crowd simulator, used evolutionary search to place circular pillars, and then extended the project by treating the simulated crowd as both a fluid and a granular material. The final study contains thousands of matched simulations across door widths, crowd sizes, desired speeds, pillar gaps, and multi-pillar patterns. Every reported winner was evaluated on crowds the search had never seen.

The result is less like a design recommendation than a map of a trade-off. A tuned pillar can raise a one-metre door's capacity by about 20%, lower the pressure concentrated around the exit, and cut long stalls in half. But its useful position sits beside a sharp failure region, and the larger search did not discover a layout with a large gain for both calm and pushing crowds.

_Interactive figure: headline._

## The model

The room is 12 by 12 metres with a one-metre door in its right wall. Each person is a disc with radius 0.25–0.35 m and mass 80 kg. The simulator follows the social-force model introduced by Helbing, Farkas and Vicsek: each person is driven toward the exit, repelled before contact, and compressed and dragged by friction after contact.

$$
m\dot{\mathbf v}_i = m\frac{v_0\mathbf e_i-\mathbf v_i}{\tau} + \sum_{j\ne i}\mathbf f_{ij} + \sum_W\mathbf f_{iW}
$$

The desired speed $v_0$ is the control for how hard the crowd pushes. At low values people approach the door calmly. At 5 m/s they continue trying to accelerate even when the crowd in front has nowhere to go. Contact and tangential friction then stabilize temporary arches across the opening. This creates the classic *faster-is-slower* effect: after a point, increasing the desired speed makes the room empty more slowly.

The simulator is intentionally small enough to inspect. A scenario fixes the room, door, desired speed, people, pillars, and random seed. Paired comparisons use the same seed with and without a pillar, so the initial crowd is identical and only the geometry changes.

```python
from pillar import Scenario, run_many, summarize

base = Scenario(desired_speed=5.0, seed=101)
pillar = base.with_(pillars=[(10.5, 6.0, 0.53)])

empty = run_many([base.with_(seed=s) for s in range(101, 181)])
treated = run_many([pillar.with_(seed=s) for s in range(101, 181)])

print(summarize(empty, base.max_time))
print(summarize(treated, pillar.max_time))
```

I measured the time at which the last person left, the flow through the middle 80% of departures, pressure from body compression, time spent above a crush threshold, and the gaps between successive exits. A run that still had people inside after 300 seconds counted as jammed rather than silently disappearing from the mean.

## The first result was both promising and unsafe

The original search optimized a pillar for a pushing crowd. It found a diameter of 1.06 m, almost centred on the door, with 0.735 m between its surface and the exit wall.

On 80 unseen pushing crowds, that pillar reduced average evacuation time from 128 s to 106 s. Flow rose from 1.22 to 1.47 people per second. Peak density hardly changed, but the smoothed pressure peak fell from 3.7 to 2.6 times the model's crush threshold. The share of exit gaps longer than three seconds fell from 3.2% to 1.5%.

_Interactive figure: stampede-gif._

On calm crowds, the same pillar was disastrous: 74 of 80 runs failed to finish within 300 seconds. An average score would hide that failure, so jam counts remain separate everywhere in the analysis.

_Interactive figure: calm-gif._

## Looking at the crowd as a fluid

Individual trajectories make the simulation vivid, but they do not explain the mechanism. I therefore coarse-grained the particles into density, velocity, pressure, and granular-temperature fields. A small kernel $\varphi$ spreads each person over nearby space:

$$
\rho(\mathbf x,t)=\sum_i \varphi(\mathbf x-\mathbf x_i), \qquad
\rho\mathbf u=\sum_i \mathbf v_i\varphi(\mathbf x-\mathbf x_i)
$$

The resulting fields obey conservation of people, $\partial_t\rho+\nabla\!\cdot(\rho\mathbf u)=0$. I did not solve a new continuum model; I used these definitions to measure the existing particle simulations.

The maps show a clear geometric change. Without a pillar, streamlines converge on one point and pressure concentrates on the wall beside the door. With the pillar, the approach splits, moves around both sides, and recombines at the opening. The peak density stays near 3.2 people/m², but peak contact pressure falls by 28%. The mean bulk speed through the opening rises from 0.19 to 0.34 m/s.

_Interactive figure: fluid-gif._

The pillar does not make people intrinsically better at moving through dense crowds. At the same density, the speed curves with and without it nearly overlap. It changes the force and approach geometry. Near 3.1 people/m², contact pressure is about 29% lower with the pillar.

Granular temperature—the motion of people relative to the local average—falls by roughly a factor of 51 between 0.9 and 3.6 people/m². In the densest regions, the crowd loses the small rearrangements that let an arch break. It effectively freezes.

## The door behaves like an orifice

If door capacity were proportional to width, doubling the opening would double the flow. The simulator does something very different. Across door widths from 0.8 to 2.4 m, the empty-room flow is fitted extremely well by a Beverloo-type law used for grains leaving a hopper:

$$
Q = C(D-D_0)^n, \qquad C=2.9,\quad D_0=0.42\text{ m},\quad n=1.6
$$

The fitted exponent's bootstrap interval is 1.5–1.7, close to the two-dimensional granular value of $3/2$. The dead width $D_0$ is about 0.7 body diameters. A plain power law fits about eight times worse; a line with an offset fits about 34 times worse.

_Interactive figure: doors._

The pillar's contribution depends on that opening. It increases flow by 32% at a 0.9 m door and 26% at 1.0 m, about 10% at 1.4–1.6 m, and only 5–6% at 2.0–2.4 m. A pillar tuned for the one-metre door does not transfer: unchanged, it cuts flow by 16% at 1.4 m and 39% at 2.4 m.

Once capacity is known, the evacuation time is almost determined:

$$
T = 1.01\frac{N}{Q}+1.9\text{ s}, \qquad R^2=0.9998
$$

That turns the paradox into a narrower question. The pillar helps only when it raises the effective door capacity without creating a permanent clog.

## What the pauses reveal

Mean flow hides the fact that people leave in bursts. I measured every gap between successive exits—about 12,000 gaps per condition. In a pushing crowd, the tuned pillar reduced the mean gap from 0.86 to 0.71 s, lowered its coefficient of variation from 2.0 to 1.3, and halved the share longer than three seconds.

This supports a modest claim: the pillar makes ordinary stalls shorter and the flow more regular. It does not justify a claim about the extreme tail. Events longer than ten seconds were too rare to estimate a power-law exponent, and the single longest pause is an anecdote, not a distribution.

## The useful gap sits beside a cliff

I swept pillar-to-wall gaps from 0.4 to 2.4 m and desired speeds from 1 to 6 m/s. Any gap of 0.5 m or less jammed every run at every speed. The mean body diameter is 0.6 m, so one plausible explanation is that a single person can plug a passage of roughly one body width. I did not vary body size, so that remains a hypothesis.

At 5 m/s, the tuned 0.735 m gap gives about a 24% improvement in evacuation time. At gaps of 0.9 m and above, the measured gains are smaller and mostly within sampling noise. At 2.5 m/s and below, no tested gap between 0.4 and 1.1 m is better than an empty room.

This is the most important engineering result. The optimum is not in the middle of a forgiving plateau. It is beside a failure region. A small error in placement—or a mismatch between simulated and real body sizes—can move the design from helpful to obstructive.

## A crowd is not a silo

In a grain silo, wall friction carries part of the load, pressure eventually saturates, and discharge depends little on how much grain remains above the opening. That does not happen here.

_Interactive figure: sizes._

Without a pillar, flow falls from 1.74 people/s with 50 people to 1.13 with 250. Peak pressure rises from 6 to 27 crush units. Every person supplies their own drive, so adding people adds force rather than simply adding depth. The pillar's benefit persists above 50 people, but it does not make the system independent of crowd size.

## Searching from one to five pillars

The single-pillar result optimized for stampedes, so I ran a second search with a stricter objective: calm, hurrying, and pushing crowds counted equally. A layout with $n$ pillars has $3n$ free variables—the wall gap, sideways offset, and radius of each pillar. Overlapping pillars are repaired by pushing them apart.

For each size from one to five pillars, the evolutionary search tried 36 layouts per generation for 18 generations. Each candidate saw six search crowds. The best 30 were re-ranked on 16 different crowds, and the winner was finally tested on 40 unseen crowds. The objective was the mean ratio of evacuation time with pillars to the empty-room time across desired speeds of 1.5, 3, and 5 m/s.

_Interactive figure: search._

The search did avoid the calm-crowd catastrophe. Its winners were roughly neutral for calm and hurrying crowds and 5–10% faster for pushing crowds. But the uncertainty for the pushing condition was ±7–9%, and the gain did not grow with the number of pillars. Only the two-pillar layout was clearly better when the three speeds were averaged: 4% ± 3%.

The layouts also became harder to interpret. One and two pillars stayed near the door. Additional pillars drifted backward and sideways and looked more like clutter than a reusable pattern. From three pillars onward, one or two of the 40 calm or hurrying crowds jammed.

## Named patterns and a random baseline

To make the search legible, I tested seven named families: a row parallel to the wall, a wedge pointing toward the crowd, guide rails forming a funnel, an arc around the door, staggered rows, posts beside the door, and a spine extending outward from the wall. Across one to five pillars, this produced 488 candidate layouts. Each candidate was selected and evaluated with the same separation between search and unseen crowds.

_Interactive figure: patterns._

Patterns that leave the main approach lane open are neutral or mildly useful. Funnels, arcs, and rows are generally between 5% faster and unchanged. Patterns that occupy the lane are worse. Wedges and staggered rows degrade as pillars are added. The spine jams every calm crowd at every tested size and is roughly 66–68% slower on average.

Few pillars beat many for every family that helps. Two or three are enough to express a useful arc or funnel. Beyond that, an extra obstacle is more likely to add a new failure mode than a new benefit.

I also drew 648 layouts at random for one and five pillars and passed them through the same selection stages. Random search matched the evolutionary search within noise: for one pillar, −4% ± 3% on unseen crowds versus −2% ± 3%; for five, 0% ± 3% versus −1% ± 4%.

That does not prove evolutionary search cannot work. It says this run did not extract more signal than random drawing. Six crowds per fitness evaluation are noisy, especially for stampedes. The landscape may also be broad and flat, with many nearly equivalent arrangements and a few catastrophic cliffs.

## The trade-off the optimizer cannot remove

_Interactive figure: tradeoff._

Of the 35 selected layouts in the final comparison, six improved a pushing crowd by more than 10%. Every one made a calm crowd at least 11% slower. The original tuned pillar remains the extreme case: about 18% faster for a stampede and 60% slower for a calm crowd.

So the best design depends on the objective. In this model, one or two large pillars at least 0.8 m from the wall are the cautious choice when a crowd could be calm or panicked. The expected average gain is only about 4%, with roughly 5% for a stampede. If only stampedes matter, a single tuned pillar or a row of four or five around 0.9 m from the wall can buy 15–20%, but the calm-crowd cost is severe.

## What I would trust—and what I would not

I trust the internal comparisons: the seeds are paired, search and evaluation crowds are disjoint, jammed runs are counted, and the reported uncertainty describes variation across crowds. I also trust the measured mechanism inside this model: the pillar changes the approach geometry, lowers pressure near the opening, shortens ordinary clogs, and changes the effective capacity.

I would not turn those results into a recommendation for a real building. The simulator was not calibrated against evacuation experiments. Its width scaling disagrees with common pedestrian-engineering rules of thumb. It uses one room, one door, round pillars, and one social-force model. Most importantly, each simulated person actively routes around only the first pillar blocking the direct path to the door; subsequent obstacles are handled by repulsion. That rule may unfairly penalize multi-pillar layouts.

The next decisive tests are therefore not a larger search. I would first vary body size to see whether the jamming cliff moves with it, detect contact arches directly, repeat the layout study with a second crowd model and better path planning, and calibrate flow against published bottleneck experiments. Only then would it make sense to ask whether a pillar pattern transfers beyond this simulator.

The paradox survives the study, but in a narrower and more useful form: an obstacle can improve a congested exit by breaking the force geometry that sustains clogs. Whether it helps depends on the door, the gap, the crowd's drive, and the routing model. The count of pillars is almost the least important part.

### [Fuego.Earth](https://mileticgoran.com/projects/fuego-earth/)

**Date:** 2026

**Summary:** A browser-based wildfire platform that turns fuels, terrain, and weather into fire-spread simulations and risk maps.

A fire-spread model is not useful just because it runs. Someone has to choose a place, supply the right terrain, fuels, and weather, wait for the computation, and understand what the result does and does not say.

[Fuego.Earth](https://www.fuego.earth) is an attempt to make that whole chain usable from a browser. I built it with My Tran, Álvaro Galván, and Sofiia Bilyk at Minerva University. A user can place an ignition point on a map, choose a date and duration, run Cell2Fire or FARSITE, and replay the estimated spread hour by hour. The platform also produces ensemble and relative-risk maps for questions that a single perimeter cannot answer.

I worked mostly where the scientific code became a dependable product: simulator inputs, spatial and temporal correctness, probability runs, API behavior, caching, deployment, and the contracts that connect those pieces.

_Interactive figure: spread._

## From one click to a perimeter

The visible interaction is simple. Pick an ignition point, choose the simulation window, and press run. Behind that click, Fuego.Earth has to collect and reconcile several kinds of evidence.

LANDFIRE provides fuel models and terrain for the selected area. Weather comes from sources such as HRRR and Open-Meteo. Other products add satellite observations, land cover, land-surface temperature, and roads. These sources were not designed to fit together. They arrive in different coordinate systems, resolutions, time zones, file formats, and definitions of missing data.

The first version let too much of that complexity leak into the fire engines. A fix for one data source could quietly change another path. A layer could look plausible on the map while being shifted by a few cells. Weather labels could be one interval away from the values actually used.

We eventually gave the system one internal contract. Extraction modules download raw data. Processing aligns every layer to a common grid with the same extent, resolution, coordinate system, and missing-data rules. The result is a `Landscape`. Products consume that object instead of knowing where each file came from.

That boundary mattered more than any one model. It made spatial alignment testable, let multiple products reuse the same prepared landscape, and kept the API thin. It also made provenance possible: an output can say which source, time window, and transformation produced it.

## Two engines, one interface

Cell2Fire and FARSITE do not accept the same inputs or return the same outputs. One expects a particular set of ASCII grids, fuel tables, ignition cells, and weather rows. The other has its own landscape and weather formats. Their time steps and geometries differ too.

Fuego.Earth treats each engine as an adapter behind one fire-spread interface. The pipeline prepares model-specific files, runs the selected engine, and converts the result into a common GeoJSON time series. The web application only has to know that each step has a timestamp, a burned perimeter, area, weather, and a few summary statistics.

This sounds like ordinary software plumbing, but it is where many scientific applications become fragile. Early bugs included fuel classes missing from a lookup table, a drifting ignition marker, weather rows that overflowed long simulations, and timestamps that described the wrong interval. None of them made the interface crash in an obvious way. They produced believable, wrong results.

The lesson was that correctness has to include the glue around the model. A physically sophisticated engine cannot repair a raster shifted east or a weather series shifted in time.

## One run is not a probability

A single simulation answers a conditional question: given this ignition, this weather, this landscape, and this model, where might the fire travel? It does not tell you how likely each place is to burn.

For the ensemble view, Fuego.Earth varies the uncertain conditions across many Cell2Fire runs. Each grid cell records the share of scenarios in which it burns. The interface calls that quantity **chance of burning**, which keeps it distinct from the separate **Burn Probability Map**. The latter is a relative-risk product that combines ranked drivers such as vegetation, slope, aspect, temperature, wind, humidity, and proximity to roads or settlements.

The ensemble feature forced a change in the product architecture. A request that starts one hundred fire simulations cannot safely sit inside one browser connection. It became an asynchronous job with progress, durable state, limits, and cached landscape data. The cache was not just a speed optimization. It had to be crash-safe, reject truncated terrain files, and keep results from one run from appearing in another.

## Comparing the models with a real fire

We used historical fire perimeters as a check against the simulators. One stored benchmark follows Fire 5501 for 36 hours and compares each model with VEDA observations at 12, 24, and 36 hours.

_Interactive figure: benchmark._

At 36 hours, the observed perimeter covered 16.17 km². FARSITE produced 17.92 km², with an F1 score of 0.656 and an intersection-over-union score of 0.488. Cell2Fire produced 23.86 km², with an F1 score of 0.610 and an intersection-over-union score of 0.439.

That does not mean FARSITE is simply the better model. Cell2Fire had the stronger F1 score at 24 hours, and this is one fire under one set of inputs. The comparison was useful because it exposed where the models diverged over time. It also kept the product honest: a smooth animation is evidence that rendering works, not evidence that the fire forecast is right.

In the saved benchmark, the simulator stages completed in about 8.2 seconds for Cell2Fire and 2.1 seconds for FARSITE. Those numbers describe that run, not total time from a cold browser request. Downloading and preparing terrain can dominate the first request; a valid cache makes the next one much faster.

## Turning research code into a public service

The production system is deliberately plain. A FastAPI service and a React/Mapbox application run on one virtual machine behind Caddy. Docker builds both fire engines at pinned revisions. GitHub Actions deploys from the main branch. SQLite stores jobs and usage counts so a restart does not erase work in progress.

The less visible work mattered most near launch: request timeouts, concurrency limits, stable error messages, path-traversal protection, strict CORS settings, and privacy rules for coordinates. Public analytics round locations to roughly one kilometre and avoid session recording. Exact ignition coordinates stay out of run logs.

This project changed how I think about applied modelling. The model is only one component of the claim a product makes. The claim also depends on where the inputs came from, how they were aligned, which version of the engine ran, what failed, and how uncertainty is shown to the person looking at the map.

If I continued the study, I would validate on many more fires and terrain types, publish calibration curves for the ensemble map, and make model disagreement a first-class part of the interface. The most trustworthy result may not be one crisp perimeter. It may be a map that shows where two reasonable models stop agreeing.

### [U.S. Airport Sentinel Network](https://mileticgoran.com/projects/pandemic-sentinel-network/)

**Date:** 2025

**Summary:** A stochastic model of 620 U.S. airports that tests where pandemic surveillance can buy the most time.

I built this project around a simple question: if the United States could monitor only a few airports for a new disease, which ones should it choose?

It was my final project for CS166 at Minerva University.

My first guess was betweenness centrality. Airports that sit on many shortest paths looked like natural sentinels. After 10,500 stochastic epidemic runs, that guess was wrong. Airports with the largest outgoing passenger flows detected outbreaks around day 30. Betweenness took until day 67. Random placement took until day 86.

The 56-day gap came from moving the same surveillance capacity. It did not require more tests or more staff.

_Interactive figure: network._

## The model

I combined 2024 domestic traffic data from the U.S. Bureau of Transportation Statistics with airport and route records from OpenFlights. The resulting graph has 620 airports. Each airport is a node, each direct route is an edge, and expected passenger movement sets the edge weight.

The graph matters because "busy" is only one way for an airport to be important. An airport can handle many passengers, connect otherwise distant parts of the network, or send a large flow of people outward. I tested those properties separately.

Each airport also runs a stochastic SEIRD model. Its population moves between susceptible, exposed, infectious, recovered, and deceased states. On every simulated day, passengers travel first. Local disease transmission happens after they arrive.

Travel is probabilistic. The number of passengers on a route is drawn from a Poisson distribution, and their health states are sampled from the population at the origin airport. This lets an exposed or infectious traveler start a distant outbreak before the origin looks badly affected.

I varied the number of sentinel airports, the screening policy, and the chance that a traveler would be tested. I sampled uncertain epidemiological parameters, including an R0 between 2.0 and 3.0, and repeated each setup with different random seeds. The finished study contains 10,500 epidemic runs and almost 1.9 million daily observations.

## Where the sentinels went

I compared five placement strategies: random airports, total passenger volume, outgoing passenger flow, betweenness centrality, and PageRank. Each strategy received the same surveillance budget.

_Interactive figure: detection._

Outgoing flow, PageRank, and total passenger volume all detected the outbreak around day 30 under uniform random screening. Random placement needed another 56 days on average.

Betweenness was the interesting miss. I expected it to perform well because it finds airports that lie on many shortest paths. The disease did not travel along shortest paths with equal probability. It followed passenger volume. An airport could be structurally central and still see too few infected travelers to make a good early sensor.

## Placement bought more time than testing

Increasing the probability of testing a traveler from 5% to 20% moved the average detection day from 58 to 46. That bought 12 days. Moving the same capacity from random airports to high-flow airports bought 56.

_Interactive figure: leverage._

The high-flow strategy selected ATL, ORD, DEN, DFW, DTW, MSP, LAS, CLT, IAH, and PHL. Together they handled about 22% of domestic passenger flow in the data. The exact list should change as traffic changes. The graph can be rebuilt with newer traffic data, then limited surveillance can move to the airports most likely to encounter an outbreak early.

## What early warning can do

The model measures detection, not epidemic control. Low-intensity screening removed fewer than 1% of infected travelers, so the final epidemic size and mortality barely changed across strategies. That let me isolate the value of information.

At first, the unchanged epidemic curves looked like a disappointing result. They clarified the purpose of a sentinel network. A detection signal creates time. Public health officials still have to use it to expand diagnostic capacity, prepare hospitals, procure supplies, sequence samples, or communicate risk.

The results also contained a statistical trap. Across all runs, earlier detection appeared to be associated with higher mortality. The relationship disappeared when I compared epidemics with similar R0 values. Faster diseases reached the sentinels sooner and killed more people, so the aggregate data produced a Simpson's paradox. Detection was correlated with severity because both depended on R0.

## What I would change

The model makes several strong assumptions. It uses 2024 traffic as a stand-in for mobility before a pandemic. It estimates the population served by each airport from passenger volume. It also leaves out behavioral changes, government restrictions, and any adaptive response after the first detection. The results compare placement strategies inside a controlled model. They are not a forecast.

I also simulated more than the original question required. Since I cared about the first positive test, many runs could have stopped there. I carried every run through 180 days instead. The study took about ten hours on an M3 machine. The extra compute revealed that detection times could differ sharply even when the epidemic curves later converged on nearly the same outcome.

A second version would make the response adaptive. The first positive test would trigger additional testing, hospital preparation, or targeted travel measures. That would let the model connect the warning time measured here to the decisions made with it.

### [Serbian News Aggregator](https://mileticgoran.com/projects/serbian-news-aggregator/)

**Date:** 2025-2026

**Summary:** Collects Serbian news and measures how editorial signals survive across classical NLP and transformer models.

The project began as a scraper. It became more useful when I treated the resulting archive as a measurement system: a way to ask what separates two newsrooms, how much of that difference is topic rather than style, and whether a model can reproduce the distinction.

The analysis uses **2,304 articles published in January 2026**: 1,963 from N1 Srbija and 341 from Biznis.rs. I developed it across three machine-learning assignments. They overlap heavily, so this page presents them as one cumulative study rather than three separate papers.

_Interactive figure: corpus._

## How the three iterations fit together

| Iteration | New question | Main addition |
| --- | --- | --- |
| 1 | Can article text identify the outlet? | TF-IDF, logistic regression, Naive Bayes, LinearSVC |
| 2 | How much can the representation be compressed? | LSA and a neural autoencoder |
| 3 | What signal is the classifier actually using? | BERTić, metadata ablation, NMF topics, retrieval, and mT5 generation |

The corpus, preprocessing, TF-IDF baseline, and most of the evaluation machinery recur in all three. The real progression is from **prediction**, to **compression**, to **interpretation**.

## The question kept changing

I did not begin with a theory about editorial style. I began with a practical annoyance: collecting articles from different sites produces records that look similar but are never quite interchangeable. Each source has its own page structure, naming conventions, missing fields, and repeated boilerplate. Building the scraper forced me to decide what an article actually is before I could analyze one.

Once the data was clean enough to compare, the first classification score was almost suspiciously high. That changed the project. “Can a model distinguish the outlets?” was no longer a very interesting question; clearly it could. The better question was **why it could**. Was it learning writing style, subject matter, author signatures, page furniture, or some mixture of all four?

Each iteration therefore tried to remove one easy explanation. The first established a careful baseline under class imbalance. The second asked whether the signal was spread across thousands of terms or concentrated in a much smaller space. The third opened the model up: remove metadata, inspect coefficients and topics, compare contextual embeddings, and finally ask whether the learned distinction could be controlled during generation.

## From web pages to a corpus

Two Scrapy spiders walk each outlet's archive, follow article pages, and emit the same record shape for title, URL, timestamp, category, author, body, images, and embedded media. Source-specific parsing stays inside the collector. Downstream analysis sees one stable table.

That separation is the part of the engineering I care about most. A crawler should be allowed to break when a publisher changes its markup without forcing every later notebook to change with it. The normalized article record became the boundary between acquisition and analysis. It also made failures visible: an empty author is a missing value; an empty body is a broken extraction.

The imbalance is substantial: N1 supplied almost six times as many articles. Biznis articles were also longer at the median: 2,607 characters versus 1,574 for N1. That matters because an apparently strong classifier can simply learn the majority class or other collection artifacts.

For classical models, I joined paragraphs, lowercased and normalized Serbian text, transliterated Cyrillic to Latin, removed Serbian stopwords, and built unigram and bigram TF-IDF features. A typical article activates only a tiny fraction of the roughly 39,000 resulting features.

Transliteration was a modeling choice, not just cleaning. It stops the same Serbian word written in two scripts from becoming two unrelated features, but it also removes script choice as a possible editorial signal. I accepted that trade-off because I wanted the first model to compare vocabulary rather than typography. Bigrams remained useful because phrases such as institutional names and market expressions carry more information than their individual words.

The weight of term $t$ in document $d$ is:

$$
\operatorname{tfidf}(t,d)
=
\bigl(1+\log f_{t,d}\bigr)
\log\left(\frac{N}{1+\operatorname{df}(t)}\right)
$$

This deliberately simple representation preserves the lexical choices that turned out to matter most.

## An honest evaluation contract

The first iteration used stratified five-fold cross-validation, then reduced the N1 class **inside each training fold only**. The validation fold kept the original 6:1 prevalence. Ten resamples per fold produced 50 fits per configuration.

The order is important. Balancing the whole dataset before splitting would make evaluation easier and would no longer answer how the model behaves on the distribution it will actually see. Resampling only the training fold gives the learner equal exposure to both outlets while leaving the test unchanged. Repeating the random undersampling also reveals whether a result depends on one fortunate selection of N1 articles.

```python
for train_idx, val_idx in folds.split(X, y):
    minority = train_idx[y[train_idx] == "biznis"]
    majority = train_idx[y[train_idx] == "n1"]

    sampled = rng.choice(
        majority,
        size=len(minority),
        replace=False,
    )
    balanced_train = np.r_[minority, sampled]

    model.fit(X[balanced_train], y[balanced_train])
    predictions = model.predict(X[val_idx])
```

Because accuracy can hide minority-class failure, I used macro F1 as the primary score:

$$
F_1^{\mathrm{macro}}
=
\frac{F_{1,\mathrm{N1}}+F_{1,\mathrm{Biznis}}}{2},
\qquad
F_1=2\frac{\mathrm{precision}\cdot\mathrm{recall}}
{\mathrm{precision}+\mathrm{recall}}.
$$

There is one important wrinkle. The later two assignments first created a balanced 682-article working set and then split it into folds. That is a different, easier evaluation distribution. The original run produced macro F1 **0.9685**; the later shared comparison pipeline produced **0.9880**. I do not treat that increase as a model improvement. I use 0.9880 only when comparing representations evaluated under the same later protocol.

This is also why I report the protocols rather than one “best” number. A score is partly a property of the model, but it is also a property of the split, prevalence, preprocessing, and sampling procedure. Two scores can look comparable while answering different questions.

## A sparse linear baseline was hard to beat

The first tuned model was ordinary L2-regularized logistic regression:

$$
P(y=1\mid x)=\sigma(w^\top x+b),
\qquad
\mathcal L_{\mathrm{reg}}
=
-\sum_i\left[y_i\log\hat y_i+(1-y_i)\log(1-\hat y_i)\right]
+\lambda\lVert w\rVert_2^2.
$$

With $C=100$ and a `liblinear` solver, it reached macro F1 **0.9685 +/- 0.0109** on the original-distribution validation folds. LinearSVC was effectively tied at **0.9677**, while tuned Multinomial Naive Bayes reached **0.8545**.

The useful lesson was not that the most elaborate model won. It was that outlet membership was already close to linearly separable in a sparse lexical space.

I initially expected the probabilistic Naive Bayes model to be a strong fit for word counts. It underperformed because its conditional-independence assumption is especially crude here: political names, institutional phrases, financial quantities, and section boilerplate arrive in correlated groups. Logistic regression and LinearSVC can instead assign weights directly to those overlapping indicators.

The very high linear score was useful and uncomfortable. It suggested that the dataset contained a real editorial signature, but it did not say whether that signature was interesting. A model that recognizes an outlet name in the footer is accurate and intellectually useless. The rest of the work was an attempt to tell those two cases apart.

## How much of 39,000 dimensions did the model need?

The second iteration held the classifier fixed and changed only the representation. Latent Semantic Analysis approximates the TF-IDF matrix with a rank-$k$ factorization:

$$
X\approx U_k\Sigma_kV_k^\top.
$$

I tested $k\in\{25,50,100,200\}$. Performance rose quickly from 25 to 50 components, then flattened. At 200 dimensions, LSA matched the roughly 39,000-dimensional TF-IDF baseline.

I chose compression as the second question because a sparse classifier can succeed through thousands of tiny, unrelated clues. If a few hundred latent dimensions retain the result, the distinction is probably more structured: recurring clusters of terms and topics rather than isolated tokens. The experiment does not identify those dimensions as human-readable concepts, but it measures how compact the predictive signal is.

```python
model = Pipeline([
    ("svd", TruncatedSVD(
        n_components=200,
        random_state=42,
    )),
    ("lr", LogisticRegression(
        penalty="l2",
        C=100,
        solver="lbfgs",
        max_iter=2000,
    )),
])
```

_Interactive figure: lsa_curve._

A nonlinear autoencoder compressed the LSA input to a 32-dimensional bottleneck. It reconstructed the input with low error, but classification fell to macro F1 **0.9683**. Reconstruction quality was not the same thing as preserving the decision boundary. In this small-data regime, the stable linear compression was better than the more complex neural one.

This result changed how I thought about “better representations.” The autoencoder optimized the average input reconstruction, not the particular information that separates outlets. It could preserve common language very well while discarding a small discriminative direction. A more expressive model was not automatically a more useful model because its objective was only adjacent to the task I cared about.

## What the classifier was really learning

The third iteration compared four feature spaces using the same logistic-regression head. Raw TF-IDF, LSA, and frozen BERTić sentence embeddings all landed within one standard deviation of each other. The modern encoder provided no measurable advantage under this protocol.

For BERTić, each article was truncated to the first 512 tokens and represented by mean pooling frozen token embeddings. That is a pragmatic baseline, not the strongest possible transformer setup. Long articles lose their endings, mean pooling flattens sentence structure, and the encoder never adapts to this corpus. Still, the comparison was useful: contextual semantics alone did not uncover an obvious advantage over sparse words.

_Interactive figure: model_comparison._

The diagnostic result came from removing bylines, dates, outlet names, wire-service attributions, and standalone numbers. Macro F1 dropped from **0.9880 to 0.9481**. About four points of the apparent separation came from publication metadata rather than editorial language.

The ablation mattered more to me than the difference between TF-IDF and BERTić. It turned a vague suspicion about leakage into a measured quantity. It also showed why interpretability should not be something added after training: examining the inputs changed the claim I was willing to make about the model.

The remaining signal was still strong, but it mixed two things that this dataset cannot fully separate: **how the outlets write** and **what they choose to cover**.

Coefficient inspection made the distinction concrete. N1-weighted terms formed a political and civic register: `vučić`, `vlasti`, `protiv`, `kosova`, and `stranke`. Biznis-weighted terms formed a market register: `odsto`, `kompanija`, `rast`, `tržištu`, `miliona`, and `cena`.

An eight-topic NMF decomposition told the same story. One markets-and-numbers topic accounted for 76.7% of the average topic mass in Biznis articles. N1 spread its attention across general politics, judicial reform, university protests, emergency response, and other civic beats.

That makes “editorial style” too strong a label for the result. Much of the boundary is editorial **selection**. Biznis.rs publishes more market reporting; N1 publishes more politics and civic affairs. To isolate style, I would need matched pairs about the same event, ideally written at the same time, with metadata removed. This corpus shows that the outlets occupy different textual distributions, not that it has discovered an invariant voice.

![A t-SNE projection of 2,304 frozen BERTić article embeddings, with N1 broadly dispersed and Biznis forming tighter clusters.](/assets/serbian-news-bertic-tsne.png)
*A two-dimensional t-SNE projection of frozen BERTić embeddings. It is an illustration of local geometry, not an independent performance result.*

## One headline, two outlet prefixes

The final experiment fine-tuned `google/mt5-small` on `(outlet + title) -> article body` pairs. At inference, the same headline was sent through the model twice. Only one token changed:

This was not an attempt to automate journalism. I wanted to turn the classifier around. Classification asks whether outlet information is present in an article; conditional generation asks whether the same information is usable as a control. If one prefix changes the vocabulary while the headline stays fixed, the model has learned at least some directional association with the outlet label.

```python
def make_input(source: str, title: str) -> str:
    prefix = "n1" if source == "n1" else "biznis"
    return f"{prefix}: {title}"
```

In mathematical shorthand:

$$
x=\texttt{<outlet>: title},
\qquad
\texttt{<outlet>}\in\{\texttt{n1},\texttt{biznis}\}.
$$

Across 150 generated samples, switching from the `n1:` prefix to `biznis:` nearly doubled the presence of Biznis-specific markers, from **29.3% to 57.3%**.

_Interactive figure: generator_markers._

That is evidence that the control prefix changed the generated vocabulary. It is not evidence that the model could write publishable news. The 300M-parameter model frequently produced poor Serbian grammar and invented names, dates, and numbers. Real/generated top-word overlap was only 4/50 for N1 and 0/50 for Biznis. The result is a controlled representation experiment, not a newsroom product.

## What I take from the project

The project made me more skeptical of impressive first results. When a simple model reaches the high nineties, the productive response is not immediately to replace it with a transformer. It is to look for shortcuts, rebuild the evaluation boundary, and ask which parts of the data made the task easy.

It also reinforced a preference for small models as investigative tools. Logistic-regression coefficients, low-rank components, ablations, and deliberately constrained generators each answered a different question. Together they produced a better account of the corpus than any single leaderboard number.

Most importantly, the scraper and the analysis are one system. Collection choices determine the claims the model can support. A missing paragraph, repeated wire copy, retained byline, or one-month sampling window is not merely a data-cleaning detail; it becomes part of the model's behavior. The system is trustworthy only when those choices remain visible from collection through evaluation.

## What I would change next

The next version should make the evaluation stricter before making the model larger:

1. Fit TF-IDF, LSA, and every learned transform inside each training fold.
2. Deduplicate syndicated or near-identical articles and group related stories into the same fold.
3. Hold out a later month to measure temporal generalization.
4. Add more outlets and match articles by topic so editorial style can be separated from beat selection.
5. Treat generation as a retrieval-grounded, human-evaluated task or leave it out entirely.

The strongest conclusion is modest: **simple lexical models captured nearly all measurable source signal, compression preserved most of it, and careful ablation was more informative than adding model complexity.**

[View the source on GitHub](https://github.com/miletic22/news-aggregator)

### [Budgeter](https://mileticgoran.com/projects/budgeter/)

**Date:** 2024

**Summary:** A small full-stack application for monthly budgets, transactions, and spending categories.

## Why I built it

Budgeter started with a modest goal: make it easy to see where a monthly budget is going without turning personal finance into a dashboard full of charts. A user can organize spending into categories, record transactions, and compare what has been spent with the budget that was set.

The small scope made the project a useful place to think about the boundaries of a full-stack application. A budget is not just a number on the screen. It is connected to a user, a month, a set of categories, and the transactions that change each category's balance.

## How it works

The client is built in React and the API in FastAPI. The backend separates authentication, users, budgets, categories, and transactions into their own routes. SQLAlchemy maps those concepts to the database, while Alembic keeps changes to the schema reproducible.

That separation matters most when a simple action crosses several parts of the system. Adding a transaction, for example, changes the history a user sees and the amount remaining in a category. Keeping the calculation in the API rather than recreating it throughout the interface gives the application one source of truth.

## What I learned

I originally used Flask for the backend and later replaced it with FastAPI. The rewrite was less about raw speed than about making the API easier to understand: request schemas, route contracts, and generated documentation made mistakes more visible while I was still building.

The project also taught me that a clean interface often depends on fairly unglamorous backend work. Database migrations, authentication, and predictable error responses do not appear in a screenshot, but they decide whether the interface remains simple as the application grows.

[View the source on GitHub](https://github.com/miletic22/budget-web-app)

### [Passport Validator](https://mileticgoran.com/projects/python-passport-validator/)

**Date:** 2023

**Summary:** Extracts passport data from a scan, validates its machine-readable zone, and isolates the portrait.

## What it does

Passport Validator takes an image of a scanned passport and returns the holder's structured information together with a separate portrait image. The project focuses on the machine-readable zone, or MRZ: the two lines of standardized characters printed at the bottom of a passport identity page.

Reading those lines is only the first step. OCR can produce text that looks plausible while changing a single character, and one character is enough to make a passport number or date wrong. The tool therefore treats extraction and validation as separate jobs.

## How it works

PassportEye locates the MRZ and returns its raw text. I then parse the fields myself according to ICAO 9303, including the document type, name, nationality, passport number, date of birth, expiration date, and country-specific optional data.

The validator implements the ICAO check-digit algorithm for the passport number, birth date, and expiration date. Each check digit is calculated from the field that precedes it, so the program can report whether an extracted value is internally consistent instead of merely repeating the OCR output.

The country code is resolved through `pycountry`, and `face_recognition` isolates the portrait from the page and saves it separately. The command-line entry point keeps the workflow simple: provide an image, parse and validate the MRZ, then extract the face.

## What I learned

The useful lesson was that standards make ambiguous documents tractable. OCR alone gives probabilities; ICAO 9303 adds positions, field lengths, and checksums that can challenge the OCR result. Combining a statistical tool with deterministic constraints produced something much more useful than either approach alone.

The project implements a focused subset of the standard rather than pretending to be a complete identity-verification system. It parses the fields present in the MRZ, validates three check digits, maps country codes, and preserves optional data without trying to infer what every country stores there.

[View the source on GitHub](https://github.com/miletic22/passport_validator)

## English notes

### [The Website Is Mostly Files](https://mileticgoran.com/notes/the-website-is-mostly-files/)

**Date:** 2026-10-22

I built this website with coding agents, but the main technical lesson was surprisingly old-fashioned: HTML and CSS are all that many websites need.

They are sometimes treated as the temporary layer underneath the “real” technology. For a site that mainly publishes writing, images, links, and a few interactive elements, they can be the finished product. Add JavaScript when the page needs to do something. Add a build step when the amount of content makes repetition annoying.

That is roughly how this site works. It is mostly HTML, CSS, Markdown, and a little JavaScript. A small Node script reads the content, assembles the pages, and writes static files. Projects and notes live in folders. English and Serbian live beside each other. The browser receives a finished page.

This is a deliberately boring architecture.

Much of the web still consists of documents. Personal sites, essays, portfolios, documentation, event pages, and many company websites mainly need to present information clearly and load reliably. A larger framework would become useful if this site developed complicated interactive state. Until then, the small static generator keeps the content close to the HTML it produces. I accept that I must maintain the generator and document its rules myself.

## The filesystem is the content API

I keep noticing more agentic systems move in this direction. Instructions live in files. Plans live in files. Agents leave notes for later runs. Skills, project context, and sometimes surprisingly useful forms of memory are represented as text in a directory.

Even parts of the user interface and the wider UI/UX can be controlled through files. Layout rules, themes, available features, and customer-specific language can all be represented as configuration rather than hard-coded branches. When a product is designed this way, an agent can customize it for a particular customer by changing a bounded set of files while leaving the shared system intact. The agent is not inventing a new interface from scratch. It is working inside choices the system already knows how to support.

Not every memory problem can or should be reduced to a folder. But many of them do not require a specialized memory service either. A file is durable, addressable, searchable, easy to edit, and easy to put under version control. A person and an agent can read the same thing. When it changes, I can see who changed it and what changed.

The more of a system that can be truthfully described by its files, the easier the system is to maintain. There is less important state hidden behind an interface, trapped in a running process, or recoverable only through a particular tool. An agent can enter the repository, inspect the current state, make a bounded change, and leave the system understandable for the next person or agent.

The same principle works for this website. Writing a Markdown file is easy. I can do it in any text editor, on any machine, without opening a publishing dashboard. The file remains useful even if I replace the build script or move the site somewhere else. The content does not belong to the interface that happens to publish it today.

Every page begins as a file. A short YAML block stores structured information such as the title, date, summary, and image paths. The body is Markdown.

```text
content/
  home/
    en.md
    sr.md
  notes/
    the-website-is-mostly-files/
      en.md
      sr.md
  projects/
    fuego-earth/
      en.md
      sr.md
      visuals.json
```

The build script turns that structure into the public site. A project folder must have matching English and Serbian files. A visualization can be declared in `visuals.json` and inserted from either language. Shared site information lives in one file. The rules are small enough to explain in a README.

This is useful for me because content stays portable and reviewable. It is also unusually useful for agents. The same files are the editing interface, the source of truth, and the context needed to make the next change.

An agent does not need to learn a private CMS interface or click through an admin panel. It can inspect an existing project, read the schema, create another folder, run the build, and show me a normal diff. The filesystem gives the agent both context and boundaries.

The boundary matters. “Improve my website” is an invitation to change everything. “Add a project using this folder contract, preserve both languages, and pass these checks” is a job with a visible end state.

## What I still have to design

Coding agents can travel across a surprising amount of material. They can inspect code, documentation, configuration, data, tests, and the generated result without treating each as a separate task.

That range is useful, but it is not system design.

I still decide which source is authoritative. I decide whether a chart should be generated from data or included as an image. I decide which fields belong in YAML, which belong in prose, and which should be derived during the build. I decide that every content change must update English and Serbian together. I decide what the browser should do when JavaScript is unavailable.

These decisions become constraints in the repository. Once they are written down, the agent can work quickly without guessing them again. If they remain only in my head or in an old chat, each new task begins with a partial reconstruction of the system.

This is the part of AI-assisted programming that interests me most. The agent can implement across the system, but someone still has to define the system it is implementing into.

## Give the agent the real material

I also learned to stop summarizing evidence that I could provide directly.

I recognized this in [Felix Rieseberg's essay about being more ambitious with models](https://felixrieseberg.com/how-to-be-more-ambitious-a-tutorial-for-humans-with-models/). He calls it going deeper. Telling a model to act like an expert often changes the vocabulary more than the work. Giving it the sources, methods, and tools that an expert would use changes the work itself.

This creates another system-design question: what counts as evidence? Claims should point back to sources. A chart should use the original values when they exist. A translation should follow the rules of the target language instead of replacing words one at a time.

The agent is better when it can trace a claim back to something outside its own answer.

## Agents need external checks

An agent can produce a convincing account of what it changed and why. That account is not evidence that the result is correct.

[Anthropic's guide to agent evaluations](https://www.anthropic.com/research/demystifying-evals-for-ai-agents) draws a useful distinction between a transcript and an outcome. A transcript tells us what steps the agent took. An outcome tells us what state it left behind. For software work, the second one matters more.

This changes how I define a finished task. The repository should be in the expected state. The content should parse. The site should build. Generated routes and internal links should exist. Both language versions should be complete. The result should work at the screen sizes and in the environments it is meant to support.

Rieseberg makes a related point: the model should not be the sole judge of its own work. Where possible, it should receive feedback from an external grader such as a test, type checker, browser assertion, link checker, or performance budget. The grader should measure the artifact, not the persuasiveness of the agent's explanation.

Not everything has an objective grader. Whether the Serbian sounds natural or the layout feels calm still requires judgment. But even subjective feedback can become part of the system. If the same problem recurs, I can turn it into a written rule, a reference example, or a testable constraint.

The goal is not to remove judgment. It is to stop wasting judgment on the same preventable mistake.

## A second interface for agents

The file-based architecture led to one more experiment. What if the visitor is also an agent?

The human site contains navigation, styling, images, and interactive elements. An agent trying to learn about me mainly needs the text and its structure. It can extract those from HTML, but the build already has the cleaner source.

So the site publishes an agent-readable version too. `llms-full.txt` and `README.md` combine the homepage, About page, projects, and notes into one Markdown document. A shorter `llms.txt` serves as an index.

This does not create a second source of truth. The agent-facing files are generated from the same content as the website, so they cannot quietly drift into a different biography.

I do not know whether anyone will ever ask an agent to inspect my website. But the cost is tiny, and the idea feels directionally right. Websites have always been interfaces for browsers and search engines. Agents are becoming another reader with slightly different needs.

## What I am still thinking about

People sometimes describe AI-assisted programming as “vibe coding,” which suggests that the code matters less because the model can keep producing more of it.

My experience with this site was almost the opposite. Cheap implementation made architectural choices more important. When five different approaches can be built in an afternoon, the difficult question is no longer “Can I implement this?” It is “Which version should exist, and what keeps the next change consistent with it?”

The repository now has 72 commits. That is a more honest account of the process than saying the website came from a prompt. The agents made exploration cheap. The system design made the surviving work coherent.

The site feels like mine because I still own its structure, its sources, its trade-offs, and the standard by which a change is considered finished.

### [What I Learned from Building Two Data Pipelines](https://mileticgoran.com/notes/what-i-learned-from-data-pipelines/)

**Date:** 2025-05-09

Last year I worked on two data pipelines with opposite problems.

At [Spark Amplify](https://www.sparkamplify.com/), I worked on a production pipeline that could adapt whole books for readers at different reading levels. One of them was Mark Twain's [*Adventures of Huckleberry Finn*](https://en.wikipedia.org/wiki/Adventures_of_Huckleberry_Finn). A large language model (LLM) rewrote the text, while later stages checked that the result was easier to read without losing the meaning, details, or voice of the original.

The other pipeline was Grid Info Tracker, a side project built with another engineer. Its job was to turn monthly grid reports and legal agreements into a queryable history of energy projects. It covered roughly three years of ERCOT GIS and battery releases, along with dates extracted from scanned agreements. Here the output was easy to name, but the input was a mess: shifting spreadsheets, inconsistent filenames, changing schemas, and scanned PDFs.

Both projects made the same weakness obvious. A pipeline can run successfully and still produce data you should not trust. The useful work is deciding what can go wrong, checking for it, and making those checks visible.

## When the output is subjective

Each Spark Amplify book moved through several stages. An LLM revised the text, then automated checks compared the revision with the original. Since we were processing more than a hundred texts, rare mistakes did not stay rare for long. The system had to catch them without someone rereading every passage after every step.

Fluency was a poor measure of success. A sentence could sound natural and still alter the story. In *Adventures of Huckleberry Finn*, for example, a character could not suddenly wear different clothes or speak in a voice that did not fit the book. The facts and tone mattered as much as readability.

We used BERT similarity scores to detect changes in meaning and word-count checks to catch revisions that had grown or shrunk too much. Neither metric could tell us whether a passage was good. They were useful because they turned some kinds of failure into numbers we could inspect.

That distinction changed how I thought about evaluation. Language quality is not binary, and one score cannot stand in for judgment. The practical approach was to define several signs of a bad result, set reasonable thresholds, and send uncertain cases back for review. The model did the rewriting; the surrounding system made the rewriting usable.

## Precision before intelligence

The grid project had a narrower question: what was the legally enforceable date in each agreement? Getting the answer into a database was harder than it sounded.

Many agreements were scans rather than digital PDFs, so ordinary text extraction failed. I tried Tesseract and several cloud OCR services. The most reliable early setup used `pdfplumber` to handle the PDF pages and PIL to prepare the images before OCR. Sharpening, deskewing, and converting the scans made a large difference.

I manually labeled about twenty documents and used them as a small test set. This was important because dates are unforgiving. A system that reads most of a page correctly can still be useless if it changes one digit in the date.

Once the OCR was reliable enough, an OpenAI model selected the enforceable date from the extracted text. A regular expression could find dates, but it could not reliably distinguish an effective date from a signing date or the date of an amendment. The agreements also followed different formats. The model was useful because the rule was fuzzy, not because it removed the need for rules.

The documents often ran for hundreds of pages. After inspecting them, I found that the relevant date usually appeared near the beginning or end, so the pipeline sent only the first and last ten pages to the model. That reduced cost and kept irrelevant text out of the prompt.

We later moved to Google's OCR and text-processing tools. The change was straightforward because the extraction steps were separate from the rest of the pipeline. That was the first time I saw modularity pay for itself so directly: a component stopped being the best option, and replacing it did not require a rewrite.

## Monthly data is an unstable interface

The ERCOT data looked simpler. We received new CSV files each month and needed to merge roughly three years of releases into consistent GIS and battery datasets.

Appending the files was the easy part. Each release carried small surprises. Column names changed. Extra rows appeared. A battery field would be empty one month and populated the next. Comments showed up in some rows and disappeared from others. Any script written around one month's file was making assumptions about the next one.

We needed separate scripts for the first import and for later monthly updates. Some transformations were written in `dbt`, which I had not used before. I worked mostly with pandas, SQL, and BigQuery, using joins and comparisons to see exactly how one release differed from another.

I began to treat each monthly file like a new version of an undocumented API. It had to be inspected before it was trusted. The merge code needed explicit checks for the columns and types it expected, and it needed to fail clearly when those expectations stopped being true.

This also changed my idea of clean data. Cleanliness is not a property a dataset has on its own. Data is clean enough for a particular use. A missing battery field may be harmless for one analysis and fatal for another. The pipeline has to encode that difference.

## What I would keep

I used AI in both projects, but for different jobs. At Spark Amplify it generated text. In the grid project it resolved ambiguity that was difficult to express as a fixed rule. In both cases, most of the engineering sat around the model: preparing inputs, defining acceptable output, testing edge cases, and deciding when a person should look again.

I used to count a pipeline as reliable when it finished without crashing. That is only the first test. A reliable pipeline also shows what it processed, which assumptions held, and where the result may be wrong.

The next dataset will fail in ways I cannot predict. The habit I want to keep is simple: decide how I will recognize a bad result before I start trusting the good ones.

## Српски

### Home

Зовем се [Горан Милетић](/about/). Инжењер сам вештачке интелигенције у компанији [Мејсон Лабс](https://usemason.ai/) и студирам науку о подацима и статистику на [Универзитету Минерва](https://www.minerva.edu/).

Занимају ме графови знања и системи за заједничко организационо знање: како организације расуте информације претварају у контекст који се дели и развија. Истражујем и системе за евалуацију, итеративно унапређивање, понашање агената и проверљивост, како би способни системи могли лакше да се мере, побољшавају и користе с поверењем.

### About

Инжењер сам вештачке интелигенције у компанији [Мејсон Лабс](https://usemason.ai/) и студирам рачунарске науке на [Универзитету Минерва](https://www.minerva.edu/), са усмерењем на науку о подацима и статистику. У Мејсону развијам AI агенте и системе који њихов рад чине поузданим, мерљивим и проверљивим.

Пре Мејсона, три године сам радио у тиму за податке Универзитета Минерва, где сам развијао аналитичке алате за студенте и наставнике. Покренуо сам систем за праћење студентских резултата заснован на 720.000 академских показатеља, претварао интерне Python алате у проширења за прегледач и користио податке за унапређење уводног курса рачунарских наука. У компанији Spark Amplify развијао сам и оцењивао NLP сервис који књиге прилагођава на шест нивоа читања, а направио сам и [Fuego.Earth](/projects/fuego-earth/), платформу за процену ширења шумских пожара чији је систем кеширања скратио поновни прорачун са око 30 секунди на 200 милисекунди.

Мој садашњи рад повезује графове знања, заједничко организационо памћење, системе за евалуацију, итеративно унапређивање, понашање агената и проверљивост. У завршном раду градим и оцењујем начине на које агенти могу да користе нагомилано знање једне компаније, а да њихов рад не постане непрозиран: тврдње остају поткрепљене изворима, понашање мерљиво, а учинак подложан систематском побољшавању.

Можете прочитати моје [белешке](/notes/), погледати [пројекте](/projects/) или ме пронаћи на [GitHub-у](https://github.com/miletic22), [LinkedIn-у](https://www.linkedin.com/in/gmiletic2/) и [X-у](https://x.com/GoranMi3).

## Српски projects

### [Парадокс стуба](https://mileticgoran.com/projects/pillar-paradox/)

**Date:** 2026

**Summary:** Симулација гомиле и еволутивна претрага испитују када стубови помажу, а када ометају пролазак кроз врата.

**Стуб испред излаза може да убрза успаничену гомилу. Исти стуб може и да претвори обичну евакуацију у трајни застој.** Занимљиво питање зато није само да ли се овај парадокс јавља у једној симулацији, већ зашто настаје, где престаје да важи и да ли претрага може да пронађе користан распоред који није опасан.

Направио сам симулатор гомиле заснован на моделу социјалних сила, еволутивном претрагом постављао кружне стубове, а затим посматрао симулирану гомилу и као флуид и као зрнасти материјал. Завршна студија обухвата хиљаде упарених симулација са различитим ширинама врата, величинама гомиле, жељеним брзинама, размацима и распоредом више стубова. Сваки победнички распоред проверен је на гомилама које претрага никада није видела.

Резултат више личи на мапу компромиса него на грађевинску препоруку. Добро подешен стуб може да повећа капацитет врата ширине један метар за око 20%, смањи притисак око излаза и преполови дуге застоје. Међутим, његов користан положај лежи одмах поред области у којој настаје потпуно заглављивање. Шира претрага није пронашла распоред који доноси велики добитак и мирној и гомили која снажно гура.

_Interactive figure: headline._

## Модел

Просторија је широка и дугачка 12 метара, са вратима ширине један метар у десном зиду. Свака особа је диск полупречника 0,25–0,35 m и масе 80 kg. Симулатор прати модел социјалних сила који су увели Helbing, Farkas и Vicsek: особа тежи ка излазу, осећа одбојну силу пре додира, а после додира и сабијање и трење.

$$
m\dot{\mathbf v}_i = m\frac{v_0\mathbf e_i-\mathbf v_i}{\tau} + \sum_{j\ne i}\mathbf f_{ij} + \sum_W\mathbf f_{iW}
$$

Жељена брзина $v_0$ одређује колико снажно гомила гура. При малим вредностима људи мирно прилазе вратима. При 5 m/s настављају да убрзавају иако гомила испред њих нема куда да се помери. Додир и бочно трење тада стабилизују привремене лукове преко отвора. Тако настаје познати ефекат „брже је спорије”: после одређене тачке, већа жељена брзина успорава пражњење просторије.

Симулатор је намерно довољно мали да сваки део може да се провери. Сценарио одређује просторију, врата, жељену брзину, људе, стубове и почетну вредност генератора случајних бројева. Упарена поређења користе исту почетну вредност са стубом и без њега, па се мења само геометрија.

```python
from pillar import Scenario, run_many, summarize

base = Scenario(desired_speed=5.0, seed=101)
pillar = base.with_(pillars=[(10.5, 6.0, 0.53)])

empty = run_many([base.with_(seed=s) for s in range(101, 181)])
treated = run_many([pillar.with_(seed=s) for s in range(101, 181)])

print(summarize(empty, base.max_time))
print(summarize(treated, pillar.max_time))
```

Мерио сам тренутак у ком последња особа излази, проток током средњих 80% излазака, притисак услед сабијања, време проведено изнад прага опасног притиска и размаке између узастопних излазака. Извођење у ком су људи остали унутра после 300 секунди обележено је као заглављено, уместо да неприметно нестане из просека.

## Први резултат је био и обећавајући и небезбедан

Првобитна претрага подешавала је један стуб за гомилу која снажно гура. Пронашла је пречник од 1,06 m, положај готово на оси врата и размак од 0,735 m између површине стуба и зида са излазом.

На 80 нових гомила које снажно гурају, овај стуб је смањио просечно време евакуације са 128 s на 106 s. Проток је порастао са 1,22 на 1,47 особа у секунди. Највећа густина се готово није променила, али је изглачани врх притиска пао са 3,7 на 2,6 пута вредност прага опасног притиска. Удео пауза дужих од три секунде пао је са 3,2% на 1,5%.

_Interactive figure: stampede-gif._

За мирне гомиле исти стуб је био погубан: 74 од 80 извођења нису завршена за 300 секунди. Просек би прикрио тај неуспех, па број потпуних заглављивања свуда приказујем одвојено.

_Interactive figure: calm-gif._

## Гомила посматрана као флуид

Путање појединаца чине симулацију очигледном, али не објашњавају механизам. Зато сам честице просторно упросечио у поља густине, брзине, притиска и грануларне температуре. Мала функција $\varphi$ распоређује сваку особу по непосредној околини:

$$
\rho(\mathbf x,t)=\sum_i \varphi(\mathbf x-\mathbf x_i), \qquad
\rho\mathbf u=\sum_i \mathbf v_i\varphi(\mathbf x-\mathbf x_i)
$$

Таква поља поштују одржање броја људи, $\partial_t\rho+\nabla\!\cdot(\rho\mathbf u)=0$. Нисам решавао нови континуални модел; овим дефиницијама сам мерио резултате постојеће симулације честица.

Мапе показују јасну промену геометрије. Без стуба све струјнице се спајају у једној тачки, а притисак се концентрише на зиду поред врата. Са стубом се ток дели, пролази са обе стране и поново спаја пред отвором. Највећа густина остаје око 3,2 особе/m², али највећи додирни притисак пада за 28%. Просечна брзина тока кроз отвор расте са 0,19 на 0,34 m/s.

_Interactive figure: fluid-gif._

Стуб не чини људе суштински способнијим да се крећу кроз густу гомилу. При истој густини криве брзине са стубом и без њега готово се поклапају. Мењају се распоред сила и угао прилаза. Око 3,1 особе/m² додирни притисак је приближно 29% мањи са стубом.

Грануларна температура — кретање појединаца у односу на локални просек — опада приближно 51 пут између 0,9 и 3,6 особа/m². У најгушћим деловима гомила губи ситна померања која би разбила лук на вратима. Практично се замрзава.

## Врата се понашају као отвор за зрнасти материјал

Када би капацитет врата био сразмеран ширини, двоструко шири отвор давао би двоструки проток. Симулатор се понаша сасвим другачије. За ширине од 0,8 до 2,4 m, проток у празној просторији веома добро описује облик Беверлуовог закона, који се користи за зрна која истичу из силоса:

$$
Q = C(D-D_0)^n, \qquad C=2,9,\quad D_0=0,42\text{ m},\quad n=1,6
$$

Интервал добијен поновљеним узорковањем за експонент је 1,5–1,7, близу дводимензионалној вредности $3/2$ за зрнасти материјал. Неискоришћена ширина $D_0$ износи око 0,7 пречника тела. Обичан степени закон има око осам пута већу грешку, а права са помаком око 34 пута већу.

_Interactive figure: doors._

Допринос стуба зависи од отвора. Повећава проток за 32% код врата ширине 0,9 m и 26% код ширине 1,0 m, око 10% код 1,4–1,6 m и само 5–6% код 2,0–2,4 m. Стуб подешен за врата од једног метра не може једноставно да се пренесе: без промене смањује проток за 16% код ширине 1,4 m и за 39% код 2,4 m.

Када је капацитет познат, време евакуације је готово потпуно одређено:

$$
T = 1,01\frac{N}{Q}+1,9\text{ s}, \qquad R^2=0,9998
$$

Парадокс се тиме своди на уже питање. Стуб помаже само када повећа ефективни капацитет врата, а да не створи трајно заглављивање.

## Шта откривају паузе

Просечни проток скрива чињеницу да људи излазе у налетима. Измерио сам сваки размак између узастопних излазака — око 12.000 по услову. Код гомиле која снажно гура, стуб је смањио просечну паузу са 0,86 на 0,71 s, коефицијент варијације са 2,0 на 1,3 и преполовио удео пауза дужих од три секунде.

То подржава умерен закључак: стуб скраћује уобичајене застоје и чини проток правилнијим. Не подржава тврдњу о крајњем репу расподеле. Догађаји дужи од десет секунди били су сувише ретки за процену степеног закона, а једна најдужа пауза је анегдота, не расподела.

## Корисни размак лежи поред провалије

Испитао сам размаке од 0,4 до 2,4 m и жељене брзине од 1 до 6 m/s. Сваки размак од 0,5 m или мањи заглавио је свако извођење при свакој брзини. Просечни пречник тела у моделу је 0,6 m, па је могуће објашњење да једна особа може да затвори пролаз широк приближно једно тело. Нисам мењао величину тела, тако да је то за сада само хипотеза.

При 5 m/s, размак од 0,735 m убрзава евакуацију око 24%. Код размака од 0,9 m и више добици су мањи и углавном унутар статистичке неизвесности. При 2,5 m/s и нижим брзинама ниједан размак од 0,4 до 1,1 m није бољи од празне просторије.

Ово је најважнији инжењерски резултат. Оптимум није у средини широке и стабилне области, већ уз саму границу неуспеха. Мала грешка у постављању — или разлика између симулиране и стварне величине тела — може да претвори користан стуб у препреку.

## Гомила није силос

Код силоса трење о зидове носи део оптерећења, притисак се временом засићује, а пражњење мало зависи од количине зрна изнад отвора. Овде се то не дешава.

_Interactive figure: sizes._

Без стуба проток пада са 1,74 особе/s за 50 људи на 1,13 за 250 људи. Највећи притисак расте са 6 на 27 критичних јединица. Свака особа производи сопствену погонску силу, па додавање људи додаје силу, а не само дубину. Корист од стуба опстаје изнад 50 људи, али систем не постаје независан од величине гомиле.

## Претрага са једним до пет стубова

Један стуб је првобитно подешен само за панику. Зато сам покренуо другу претрагу са строжим циљем: мирна, ужурбана и гомила која снажно гура имају једнаку тежину. Распоред са $n$ стубова има $3n$ слободних величина — размак од зида, бочни помак и полупречник сваког стуба. Преклапања се исправљају раздвајањем стубова.

За сваки број од једног до пет стубова, еволутивна претрага испитивала је 36 распореда по генерацији током 18 генерација. Сваки кандидат је оцењен на шест гомила. Најбољих 30 је поново рангирано на 16 других гомила, а победник коначно проверен на 40 потпуно нових. Циљ је био просечан однос времена са стубовима и времена празне просторије при жељеним брзинама 1,5, 3 и 5 m/s.

_Interactive figure: search._

Претрага је избегла катастрофу код мирне гомиле. Победници су били приближно неутрални за мирне и ужурбане гомиле, а 5–10% бржи за снажно гурање. Ипак, неизвесност у том режиму износила је ±7–9%, а добитак није растао са бројем стубова. Само је распоред са два стуба био јасно бољи када се три брзине упросече: 4% ± 3%.

Распореде је постајало теже протумачити. Један и два стуба остајали су близу врата. Додатни стубови одлазили су уназад и у страну и више личили на сметње него на поновљив образац. Од три стуба навише, једна или две од 40 мирних или ужурбаних гомила потпуно су се заглавиле.

## Именовани распореди и случајно поређење

Да би резултат претраге био читљивији, испитао сам седам именованих породица: ред паралелан са зидом, клин окренут ка гомили, вођице у облику левка, лук око врата, два смакнута реда, стубове са обе стране врата и низ који се пружа од зида ка просторији. Од једног до пет стубова добио сам 488 кандидата. Сваки је прошао исто раздвајање гомила за избор и коначну проверу.

_Interactive figure: patterns._

Обрасци који остављају главни прилаз слободним били су неутрални или благо корисни. Левци, лукови и редови углавном су између 5% бржег и непромењеног резултата. Обрасци који заузимају прилаз били су лошији. Клинови и смакнути редови погоршавали су се са додавањем стубова. Низ од зида заглавио је сваку мирну гомилу при свакој испитаној величини и у просеку био 66–68% спорији.

Мали број стубова био је бољи од великог у свакој породици која је уопште помагала. Два или три стуба довољна су за користан лук или левак. После тога додатна препрека чешће ствара нови начин отказа него нову корист.

Насумично сам извукао и 648 распореда са једним и пет стубова и провукао их кроз исте кораке избора. Насумична претрага изједначила се са еволутивном унутар неизвесности: за један стуб −4% ± 3% на новим гомилама према −2% ± 3%, а за пет стубова 0% ± 3% према −1% ± 4%.

То не доказује да еволутивна претрага не може да помогне. Значи да ово извођење није извукло више сигнала од случајног избора. Шест гомила по оцени даје бучан резултат, нарочито у паници. Простор решења може бити и широк и раван, са много скоро једнаких распореда и неколико оштрих провалија.

## Компромис који оптимизатор не уклања

_Interactive figure: tradeoff._

Од 35 изабраних распореда у завршном поређењу, шест је убрзало гомилу која снажно гура за више од 10%. Свих шест успорило је мирну гомилу најмање 11%. Првобитни подешени стуб остаје крајњи случај: око 18% бржи у паници и 60% спорији у мирној гомили.

Зато најбољи распоред зависи од циља. У овом моделу, један или два велика стуба најмање 0,8 m од зида опрезан су избор када гомила може бити и мирна и успаничена. Очекивани просечни добитак је само око 4%, а у паници око 5%. Ако је важна искључиво паника, један подешени стуб или ред од четири до пет стубова на око 0,9 m од зида може да донесе 15–20%, али је цена за мирну гомилу велика.

## Чему верујем, а чему не

Верујем у унутрашња поређења: почетне вредности су упарене, гомиле за претрагу и проверу су раздвојене, заглављена извођења су урачуната, а приказана неизвесност описује разлике међу гомилама. Верујем и измереном механизму унутар овог модела: стуб мења геометрију прилаза, снижава притисак код врата, скраћује уобичајене застоје и мења ефективни капацитет.

Не бих ове резултате претворио у препоруку за стварну зграду. Симулатор није подешен према експериментима евакуације. Његова зависност протока од ширине разликује се од уобичајених правила у пројектовању кретања пешака. Испитани су једна просторија, једна врата, кружни стубови и један модел социјалних сила. Најважније ограничење је правило кретања: особа активно заобилази само први стуб који заклања праву путању до врата, а на остале реагује одбојном силом. То може неправедно да казни распореде са више стубова.

Следећи одлучујући кораци зато нису већа претрага. Прво бих променио величину тела да проверим да ли се граница заглављивања помера заједно са њом, непосредно откривао додирне лукове, поновио студију у другом моделу гомиле са бољим планирањем путање и подесио проток према објављеним експериментима на уским пролазима. Тек тада има смисла питати да ли се неки распоред стубова преноси изван овог симулатора.

Парадокс је преживео студију, али у ужем и кориснијем облику: препрека може да поправи загушени излаз тако што разбија распоред сила који одржава застој. Да ли ће помоћи зависи од врата, размака, јачине гурања и начина на који модел бира путању. Сам број стубова је готово најмање важан део.

### [Fuego.Earth](https://mileticgoran.com/projects/fuego-earth/)

**Date:** 2026

**Summary:** Платформа која податке о гориву, терену и времену претвара у симулације ширења пожара и карте ризика.

Модел ширења пожара није користан само зато што може да се покрене. Неко мора да изабере место, припреми исправне податке о терену, гориву и времену, сачека прорачун и разуме шта резултат показује, а шта не.

[Fuego.Earth](https://www.fuego.earth) је покушај да се цео тај поступак учини доступним у прегледачу. На Универзитету Минерва направио сам га са тимом који су чинили My Tran, Álvaro Galván и Sofiia Bilyk. Корисник може да означи место настанка пожара, изабере датум и трајање, покрене Cell2Fire или FARSITE и погледа процењено ширење ватре из сата у сат. Платформа прави и скупове симулација и карте релативног ризика за питања на која један сценарио ширења не може да одговори.

Мој рад је углавном био на споју научног кода и поузданог производа: улазима за симулаторе, исправном просторном и временском усклађивању, скуповима симулација, понашању API-ја, кеширању, постављању система у рад и правилима која повезују све те делове.

_Interactive figure: spread._

## Од једног клика до мапе ширења

Оно што корисник види делује једноставно. Означи место настанка пожара, изабере временски оквир и покрене симулацију. Иза тог клика Fuego.Earth мора да прикупи и усклади више врста података.

LANDFIRE даје моделе горива и терен за изабрану област. Временски подаци стижу из извора као што су HRRR и Open-Meteo. Други делови платформе користе сателитска осматрања, покривач тла, температуру површине и путеве. Ти извори нису направљени да се непосредно уклопе. Имају различите координатне системе, резолуције, временске зоне, формате датотека и начине означавања недостајућих вредности.

Прва верзија је превише те сложености препуштала самим моделима. Исправка за један извор могла је неприметно да промени други поступак. Слој је на карти могао да изгледа уверљиво иако је био померен за неколико поља. Ознака времена могла је да се односи на један интервал, а вредности на суседни.

Систем смо зато свели на једно унутрашње правило. Модули за преузимање доносе сирове податке. Обрада усклађује сваки слој на исту мрежу, са заједничким обухватом, резолуцијом, координатним системом и правилима за недостајуће вредности. Резултат је објекат `Landscape`. Сваки производ користи њега, уместо да мора да зна одакле је стигла свака датотека.

Та граница је била важнија од било ког појединачног модела. Просторно усклађивање је постало проверљиво, више производа могло је да користи исти припремљени терен, а API је остао једноставан. Постало је могуће и да се уз резултат сачува порекло података: који извор, временски оквир и поступак обраде су га произвели.

## Два модела, један начин употребе

Cell2Fire и FARSITE не примају исте улазе и не враћају исте резултате. Један очекује одређени скуп ASCII мрежа, табела горива, поља са местом паљења и редова са временским подацима. Други има сопствене формате за терен и време. Разликују се и временски кораци и геометрија резултата.

Fuego.Earth поставља сваки модел иза истог интерфејса за ширење пожара. Систем припрема датотеке које тражи изабрани модел, покреће га и претвара резултат у заједнички GeoJSON временски низ. Веб-апликација треба да зна само да сваки корак има време, границу изгореле површине, временске услове и неколико сажетих показатеља.

То звучи као обично повезивање софтвера, али управо ту научне апликације често постају непоуздане. Међу раним грешкама били су типови горива који су недостајали у табели, ознака пожара која се померала при удаљавању карте, превише редова временских података за дуге симулације и временске ознаке које су описивале погрешан интервал. Ниједна од тих грешака није морала очигледно да обори систем. Могле су да произведу уверљив, али погрешан резултат.

Из тога сам научио да исправност обухвата и све што окружује модел. Ни најнапреднији физички модел не може да исправи растер померен ка истоку или временски низ померен за један сат.

## Једна симулација није вероватноћа

Једна симулација одговара на условно питање: ако претпоставимо ово место настанка пожара, ове временске услове, овај терен и овај модел, куда би ватра могла да се прошири? Она не говори колика је вероватноћа да ће свако место бити захваћено.

За приказ скупа могућих исхода, Fuego.Earth мења неизвесне услове кроз више покретања модела Cell2Fire. Свако поље мреже бележи у ком уделу сценарија је било захваћено ватром. У интерфејсу се та величина зове **вероватноћа захватања ватром**, како би се разликовала од засебне **карте вероватноће настанка пожара**. Та друга карта приказује релативни ризик спајањем чинилаца као што су вегетација, нагиб, експозиција падине, температура, ветар, влажност и близина путева или насеља.

Ова могућност је захтевала промену у архитектури производа. Захтев који покреће сто симулација не сме да зависи од једне везе са прегледачем. Претворен је у посао који се извршава у позадини, са приказом напретка, трајним стањем, ограничењима и поновном употребом већ припремљених података. Кеш није био само убрзање. Морао је да остане исправан после пада система, да одбаци непотпуне датотеке терена и да спречи да се резултат једног покретања појави у другом.

## Поређење модела са стварним пожаром

Историјске границе изгорелих површина користили смо као проверу симулатора. Један сачуван пример прати пожар 5501 током 36 сати и пореди оба модела са VEDA осматрањима после 12, 24 и 36 сати.

_Interactive figure: benchmark._

После 36 сати, осмотрена изгорела површина износила је 16,17 km². FARSITE је дао 17,92 km², уз F1 меру од 0,656 и однос пресека и уније (IoU) од 0,488. Cell2Fire је дао 23,86 km², уз F1 меру од 0,610 и IoU од 0,439.

То не значи да је FARSITE једноставно бољи модел. Cell2Fire је после 24 сата имао бољу F1 меру, а ово је један пожар под једним скупом улаза. Поређење је било корисно зато што је показало где се модели временом разилазе. Такође је поставило важну границу за производ: глатка анимација доказује да приказ ради, али не и да је процена ширења пожара тачна.

У сачуваном примеру, сам рад симулатора трајао је око 8,2 секунде за Cell2Fire и 2,1 секунду за FARSITE. Ти бројеви описују то покретање, не укупно време првог захтева из прегледача. Преузимање и припрема терена могу да одузму највише времена при првом захтеву; исправан кеш знатно убрзава следећи.

## Од истраживачког кода до јавне услуге

Јавна верзија система је намерно једноставна. FastAPI сервис и React/Mapbox апликација раде на једном виртуелном серверу иза Caddy сервера. Docker гради оба модела на тачно одређеним верзијама кода. GitHub Actions поставља ново издање са главне гране. SQLite чува послове и број покретања, тако да поновно покретање не брише рад који је у току.

Пред објављивање су најважнији постали мање видљиви делови: временска ограничења захтева, ограничење броја истовремених симулација, стабилне поруке о грешкама, заштита путања до датотека, строга CORS правила и приватност координата. Јавна аналитика заокружује локације на приближно један километар и не снима корисничку сесију. Тачне координате места паљења не улазе у дневник покретања.

Овај пројекат је променио начин на који размишљам о примењеном моделирању. Модел је само један део тврдње коју производ износи. Та тврдња зависи и од порекла улаза, начина усклађивања, верзије модела, забележених грешака и начина на који се неизвесност показује особи која гледа карту.

Када бих наставио истраживање, проверио бих систем на много већем броју пожара и врста терена, објавио калибрационе криве за скуп симулација и неслагање модела учинио видљивим делом интерфејса. Најпоузданији резултат можда није једна оштра линија. Можда је то карта која показује где два разумна модела престају да се слажу.

### [Мрежа за рано откривање пандемија](https://mileticgoran.com/projects/pandemic-sentinel-network/)

**Date:** 2025

**Summary:** Стохастички модел 620 америчких аеродрома који испитује где епидемиолошки надзор може да обезбеди највише времена за реакцију.

Овај пројекат сам засновао на једном једноставном питању: ако би САД могле да прате само неколико аеродрома ради откривања нове болести, које би требало изабрати?

Ово је био мој завршни пројекат за предмет CS166 на Универзитету Минерва.

Прво сам мислио да ће најбољи избор бити аеродроми са највећом централношћу посредовања. Они леже на многим најкраћим путањама кроз граф, па су деловали као природна места за надзор. После 10.500 стохастичких симулација епидемије, испоставило се да та претпоставка није била добра. Аеродроми са највећим одлазним путничким током откривали су епидемију око 30. дана. Централности посредовања било је потребно до 67. дана, а насумичном избору до 86.

Разлика од 56 дана настала је само променом места на којима су исти надзорни капацитети распоређени. Није било потребно више тестова ни више особља.

_Interactive figure: network._

## Модел

Спојио сам податке о домаћем ваздушном саобраћају у САД из 2024. године са подацима о аеродромима и рутама из скупа OpenFlights. Тако је настао граф са 620 аеродрома. Сваки аеродром је чвор, свака директна линија је грана, а очекивани број путника одређује тежину гране.

Граф је важан јер број путника није једина мера значаја аеродрома. Неки аеродроми повезују удаљене делове мреже. Из других велики број људи одлази ка многим дестинацијама. Та својства сам испитао одвојено.

На сваком аеродрому ради и посебан стохастички SEIRD модел. Становништво прелази из групе подложних у изложене, заразне, опорављене или преминуле. Сваког симулираног дана путници се прво крећу кроз мрежу, а затим се зараза шири у подручјима која аеродроми опслужују.

Број путника на свакој рути узоркује се из Поасонове расподеле. Њихова здравствена стања узоркују се из становништва у месту поласка. Тако изложен или заразан путник може да покрене удаљено жариште пре него што стање на полазном аеродрому делује озбиљно.

Мењао сам број надзорних аеродрома, начин тестирања и вероватноћу да ће путник бити тестиран. Узорковао сам и неизвесне епидемиолошке параметре, укључујући R0 између 2,0 и 3,0, па сваку поставку понављао са различитим почетним вредностима генератора случајних бројева. Завршна анализа обухватила је 10.500 симулација и скоро 1,9 милиона дневних записа.

## Избор надзорних аеродрома

Упоредио сам пет начина избора: насумичне аеродроме, укупан број путника, одлазни путнички ток, централност посредовања и PageRank. Сваки је имао исти надзорни буџет.

_Interactive figure: detection._

Одлазни ток, PageRank и укупан број путника довели су до откривања око 30. дана када је сваки путник имао исту вероватноћу да буде тестиран. Насумичном избору било је потребно још 56 дана у просеку.

Најзанимљивији је био слаб резултат централности посредовања. Очекивао сам добар исход јер ова мера проналази аеродроме који леже на многим најкраћим путањама. Међутим, зараза није путовала сваком најкраћом путањом са истом вероватноћом. Пратила је број путника. Аеродром је могао да буде важан у структури графа, а да ипак сусретне премало заражених путника за рано упозорење.

## Распоред је донео више времена од јачег тестирања

Повећање вероватноће тестирања путника са 5% на 20% померило је просечно откривање са 58. на 46. дан. Тако је добијено 12 дана. Премештање истих капацитета са насумично изабраних аеродрома на аеродроме са великим одлазним током донело је 56 дана.

_Interactive figure: leverage._

Стратегија заснована на одлазном току изабрала је ATL, ORD, DEN, DFW, DTW, MSP, LAS, CLT, IAH и PHL. Тих десет аеродрома обухватало је око 22% домаћег путничког саобраћаја у коришћеним подацима. Списак би требало да се мења заједно са саобраћајем. Граф се може поново изградити са новијим подацима, па ограничене надзорне капацитете преместити на аеродроме који ће највероватније рано сусрести епидемију.

## Чему служи рано упозорење

Модел мери време откривања, а не сузбијање епидемије. Тестирање слабог интензитета уклонило је мање од 1% заражених путника, па су се коначни обим епидемије и смртност једва разликовали између стратегија. Тако сам могао да издвојим вредност добијене информације.

Непромењене епидемијске криве су у почетку деловале као разочаравајући резултат. Он је ипак прецизно показао чему мрежа за рано откривање служи. Сигнал купује време, али надлежни то време морају да искористе за ширење дијагностичких капацитета, припрему болница, набавку залиха, секвенцирање узорака или правовремено обавештавање јавности.

У резултатима се појавила и једна статистичка замка. Када се све симулације посматрају заједно, раније откривање делује повезано са већом смртношћу. Та веза нестаје када се упореде епидемије са сличном вредношћу R0. Болести које се брже шире раније стижу до надзорних аеродрома и односе више живота. Зато је у збирним подацима настао Симпсонов парадокс. Време откривања и тежина епидемије били су повезани јер су оба зависила од R0.

## Шта бих променио

Модел почива на неколико јаких претпоставки. Користи саобраћај из 2024. године као замену за кретање пре пандемије. Број становника у подручју које аеродром опслужује процењује из броја путника. Не обухвата промене понашања, мере државе ни прилагодљив одговор после првог откривања. Резултати пореде начине избора аеродрома унутар контролисаног модела. Нису прогноза.

Симулирао сам и више него што је првобитно питање захтевало. Пошто ме је занимао први позитиван тест, многа извођења су могла да се заврше у том тренутку. Ипак сам свако рачунао током 180 дана. Анализа је трајала око десет сати на M3 рачунару. Додатно рачунање је показало да време откривања може знатно да се разликује иако се епидемијске криве касније приближе готово истом исходу.

У следећој верзији бих увео прилагодљив одговор. Први позитиван тест би покренуо додатно тестирање, припрему болница или циљане мере у саобраћају. Тако би модел могао да повеже време добијено раним упозорењем са одлукама које се у том року доносе.

### [Систем за прикупљање и анализу српских вести](https://mileticgoran.com/projects/serbian-news-aggregator/)

**Date:** 2025-2026

**Summary:** Прикупља вести из Србије и мери како се редакцијски сигнали понашају у класичним NLP и трансформерским моделима.

Пројекат је почео као систем за прикупљање вести. Постао је занимљивији када сам архиву почео да посматрам као мерни инструмент: начин да испитам шта раздваја две редакције, колико те разлике потичу од теме, а колико од стила, и да ли модел може да их понови.

Анализа обухвата **2.304 чланка објављена у јануару 2026. године**: 1.963 са портала N1 Србија и 341 са портала Biznis.rs. Развијао сам је кроз три задатка из машинског учења. Велики део материјала се понавља, па их овде представљам као једно истраживање које се постепено развијало.

_Interactive figure: corpus._

## Како се три итерације надовезују

| Итерација | Ново питање | Главни додатак |
| --- | --- | --- |
| 1 | Може ли текст чланка да открије редакцију? | TF-IDF, логистичка регресија, Наивни Бајес и LinearSVC |
| 2 | Колико се простор обележја може сажети? | LSA и неуронски аутоенкодер |
| 3 | Који сигнал класификатор заиста користи? | BERTić, уклањање метаподатака, NMF теме, претрага сродних текстова и mT5 генерација |

Корпус, припрема текста, TF-IDF основа и највећи део поступка оцењивања понављају се у сва три рада. Стварни развој тече од **предвиђања**, преко **сажимања**, до **тумачења**.

## Питање се стално мењало

Нисам почео са теоријом о редакцијском стилу, већ са практичним проблемом: прикупљање чланака са различитих портала даје записе који личе једни на друге, али никада нису потпуно уједначени. Сваки извор има сопствену структуру странице, називе поља, недостајуће податке и понављајуће делове текста. Израда система за прикупљање натерала ме је да прво одредим шта је уопште један чланак.

Када су подаци постали довољно уређени за поређење, први резултат класификације био је готово сумњиво висок. Тада се променио и пројекат. Питање „може ли модел да разликује редакције?” више није било нарочито занимљиво; очигледно је могао. Важније је било **зашто може**. Да ли учи стил писања, избор тема, потписе аутора, делове странице или мешавину свега тога?

Зато је свака итерација покушала да уклони по једно лако објашњење. Прва је поставила пажљиву основу у условима неуравнотежених класа. Друга је испитала да ли је сигнал расут кроз хиљаде појмова или сабијен у много мањи простор. Трећа је отворила модел: уклони метаподатке, погледај коефицијенте и теме, упореди контекстуалне векторе и на крају испитај да ли се научена разлика може усмеравати при генерисању текста.

## Од веб-страница до корпуса

Два Scrapy spider-а пролазе кроз архиву сваког портала, отварају странице чланака и производе записе истог облика: наслов, URL, време објављивања, рубрику, аутора, тело текста, слике и уграђени садржај. Посебности сваког извора остају у систему за прикупљање, док анализа добија једну уједначену табелу.

Управо ми је то раздвајање најважнији инжењерски део пројекта. Када издавач промени HTML странице, систем за прикупљање сме да се поквари, али та промена не треба да примора сваку каснију анализу на преправку. Уједначени запис чланка постао је граница између прикупљања и анализе. Тако су и грешке постале видљиве: празан аутор је недостајућа вредност, а празно тело текста је неуспело издвајање.

Неравнотежа је велика: N1 је објавио скоро шест пута више чланака. Текстови на Biznis.rs били су и дужи по медијани: 2.607 знакова према 1.574 на N1. Због тога наизглед добар модел лако може да научи већинску класу или споредне трагове начина прикупљања.

За класичне моделе спојио сам пасусе, претворио текст у мала слова, пресловио ћирилицу у латиницу, уклонио српске стоп-речи и изградио TF-IDF обележја од појединачних речи и парова речи. Један чланак активира тек мали део од приближно 39.000 обележја.

Пресловљавање је било моделска одлука, а не само чишћење. Оно спречава да иста српска реч написана у два писма постане два неповезана обележја, али истовремено уклања избор писма као могући редакцијски сигнал. Прихватио сам ту размену јер сам желео да први модел пореди речник, а не типографију. Парови речи су остали важни зато што називи институција и тржишни изрази носе више значења као целина него као одвојене речи.

Тежина појма $t$ у документу $d$ је:

$$
\operatorname{tfidf}(t,d)
=
\bigl(1+\log f_{t,d}\bigr)
\log\left(\frac{N}{1+\operatorname{df}(t)}\right)
$$

Ова намерно једноставна представа чува избор речи који се показао као најважнији сигнал.

## Поштено оцењивање

У првој итерацији користио сам стратификовану унакрсну проверу са пет делова, а тек затим смањивао N1 класу **унутар дела за обучавање**. Скуп за проверу задржавао је првобитни однос од приближно 6:1. Десет поновљених узорака по делу дало је 50 обучавања за сваку поставку.

Редослед је важан. Ако бих уравнотежио цео скуп пре поделе, оцењивање би постало лакше и више не би одговарало на питање како се модел понаша на расподели коју ће заиста видети. Узимање једнаког броја примера само у делу за обучавање даје моделу подједнаку изложеност обема редакцијама, док скуп за проверу остаје непромењен. Понављање насумичног узорковања показује и да ли резултат зависи од једног срећног избора N1 чланака.

```python
for train_idx, val_idx in folds.split(X, y):
    minority = train_idx[y[train_idx] == "biznis"]
    majority = train_idx[y[train_idx] == "n1"]

    sampled = rng.choice(
        majority,
        size=len(minority),
        replace=False,
    )
    balanced_train = np.r_[minority, sampled]

    model.fit(X[balanced_train], y[balanced_train])
    predictions = model.predict(X[val_idx])
```

Пошто тачност може да прикрије слаб учинак на мањинској класи, главна мера је макро F1:

$$
F_1^{\mathrm{macro}}
=
\frac{F_{1,\mathrm{N1}}+F_{1,\mathrm{Biznis}}}{2},
\qquad
F_1=2\frac{\mathrm{precision}\cdot\mathrm{recall}}
{\mathrm{precision}+\mathrm{recall}}.
$$

Постоји важна разлика између итерација. У друга два рада најпре је направљен уравнотежен радни скуп од 682 чланка, па је тек онда подељен на делове. То је другачији и лакши распоред за оцењивање. Први поступак дао је макро F1 **0,9685**, а каснији заједнички поступак **0,9880**. Тај раст не представљам као побољшање модела; број 0,9880 користим само за поређење представа оцењених истим каснијим поступком.

Зато наводим поступке, а не само један „најбољи” број. Резултат делом припада моделу, али припада и подели података, односу класа, припреми текста и начину узорковања. Два броја могу изгледати упоредиво, а да заправо одговарају на различита питања.

## Једноставан линеарни модел било је тешко надмашити

Први подешени модел била је обична логистичка регресија са L2 регуларизацијом:

$$
P(y=1\mid x)=\sigma(w^\top x+b),
\qquad
\mathcal L_{\mathrm{reg}}
=
-\sum_i\left[y_i\log\hat y_i+(1-y_i)\log(1-\hat y_i)\right]
+\lambda\lVert w\rVert_2^2.
$$

Са $C=100$ и `liblinear` алгоритмом достигао је макро F1 **0,9685 +/- 0,0109** на скуповима за проверу са првобитном расподелом. LinearSVC је био практично изједначен са **0,9677**, док је подешени Мултиномијални Наивни Бајес достигао **0,8545**.

Најважније није било то што је најсложенији модел победио. Напротив, припадност редакцији већ је била скоро линеарно раздвојива у ретком лексичком простору.

У почетку сам очекивао да ће вероватносни Наивни Бајес добро одговарати бројању речи. Био је слабији зато што је његова претпоставка о условној независности овде посебно груба: политичка имена, називи институција, финансијски бројеви и понављајући делови странице долазе у повезаним групама. Логистичка регресија и LinearSVC могу непосредно да доделе тежине тим преклопљеним показатељима.

Веома висок резултат линеарног модела био је истовремено користан и непријатан. Указивао је на стварни редакцијски траг у скупу, али није говорио да ли је тај траг занимљив. Модел који препозна назив портала у подножју странице може бити тачан и потпуно бескористан за разумевање. Остатак рада био је покушај да раздвојим та два случаја.

## Колико је од 39.000 димензија било потребно?

У другој итерацији класификатор је остао исти, а мењала се само представа текста. Латентна семантичка анализа (LSA) приближава TF-IDF матрицу факторизацијом ранга $k$:

$$
X\approx U_k\Sigma_kV_k^\top.
$$

Испробао сам $k\in\{25,50,100,200\}$. Учинак је брзо порастао од 25 до 50 компоненти, а затим се готово зауставио. Са 200 димензија LSA је изједначила TF-IDF основу од приближно 39.000 димензија.

Сажимање сам изабрао као друго питање јер ретки класификатор може да успе ослањајући се на хиљаде ситних, међусобно неповезаних трагова. Ако неколико стотина скривених димензија очува резултат, разлика је вероватно уређенија: понављајуће групе појмова и тема, а не изоловани токени. Оглед не тврди да су те димензије људски разумљиви концепти, али мери колико је предиктивни сигнал збијен.

```python
model = Pipeline([
    ("svd", TruncatedSVD(
        n_components=200,
        random_state=42,
    )),
    ("lr", LogisticRegression(
        penalty="l2",
        C=100,
        solver="lbfgs",
        max_iter=2000,
    )),
])
```

_Interactive figure: lsa_curve._

Нелинеарни аутоенкодер је сажео LSA улаз у уско грло од 32 димензије. Улаз је реконструисао са малом грешком, али је класификација пала на макро F1 **0,9683**. Добра реконструкција није исто што и очување границе одлучивања. Са овако мало података, стабилно линеарно сажимање било је боље од сложенијег неуронског модела.

Овај резултат је променио моје схватање „бољих представа”. Аутоенкодер је оптимизовао просечну реконструкцију улаза, а не баш ону информацију која раздваја редакције. Могао је веома добро да сачува уобичајени језик и истовремено одбаци мали, али важан правац. Изражајнији модел није аутоматски био и кориснији јер је његов циљ био само приближан питању које ме је занимало.

## Шта је класификатор заиста научио

Трећа итерација упоредила је четири простора обележја са истом логистичком регресијом. Сирови TF-IDF, LSA и замрзнути BERTić вектори реченица завршили су унутар једне стандардне девијације. Савремени енкодер није показао мерљиву предност у овом поступку.

За BERTić је сваки чланак скраћен на првих 512 токена, а његова представа добијена просеком замрзнутих вектора токена. То је практична почетна поставка, не најјачи могући трансформерски поступак. Дуги текстови губе завршетак, усредњавање изравнава структуру реченица, а енкодер се не прилагођава овом корпусу. Поређење је ипак било корисно: само контекстуално значење није донело очигледну предност над ретким речима.

_Interactive figure: model_comparison._

Најважнија дијагностичка провера била је уклањање потписа аутора, датума, назива редакције, назива новинских агенција и самосталних бројева. Макро F1 пао је са **0,9880 на 0,9481**. Око четири поена привидне раздвојивости долазило је од метаподатака, а не од редакцијског језика.

Ово уклањање било ми је важније од разлике између TF-IDF и BERTić модела. Нејасну сумњу о цурењу информација претворило је у измерену вредност. Показало је и зашто тумачење не треба додавати тек после обучавања: увид у улазе променио је тврдњу коју сам био спреман да изнесем о моделу.

Преостали сигнал и даље је снажан, али спаја две ствари које овај скуп не може потпуно да раздвоји: **како редакције пишу** и **о чему одлучују да пишу**.

Коефицијенти модела учинили су разлику видљивом. Речи које су нагињале ка N1 припадале су политичком и грађанском регистру: `vučić`, `vlasti`, `protiv`, `kosova` и `stranke`. Речи које су нагињале ка Biznis.rs припадале су тржишном регистру: `odsto`, `kompanija`, `rast`, `tržištu`, `miliona` и `cena`.

NMF модел са осам тема испричао је исту причу. Тема тржишта и бројева чинила је 76,7% просечне тематске тежине у текстовима Biznis.rs. N1 је распоређивао пажњу на општу политику, правосуђе, студентске протесте, ванредне ситуације и друге грађанске теме.

Због тога је „редакцијски стил” прејака ознака за овај резултат. Велики део границе потиче од редакцијског **избора тема**. Biznis.rs објављује више тржишних вести, а N1 више политичких и друштвених. За издвајање самог стила били би потребни упарени текстови о истом догађају, настали у слично време и без метаподатака. Овај корпус показује да редакције заузимају различите текстуалне расподеле, а не да је пронашао њихов непроменљиви глас.

![Дводимензионална t-SNE пројекција 2.304 замрзнута BERTić вектора, са шире распршеним N1 чланцима и збијенијим групама Biznis.rs.](/assets/serbian-news-bertic-tsne.png)
*Дводимензионална t-SNE пројекција замрзнутих BERTić вектора. Она илуструје локалну геометрију, али није независан доказ квалитета модела.*

## Један наслов, два редакцијска префикса

Завршни оглед је додатно обучио `google/mt5-small` на паровима `(редакција + наслов) -> тело чланка`. При употреби је исти наслов два пута послат моделу, а промењен је само један део улаза:

Ово није био покушај аутоматизације новинарства. Желео сам да окренем класификатор у супротном смеру. Класификација пита да ли се информација о редакцији налази у чланку; условно генерисање пита да ли та информација може да послужи као контрола. Ако један префикс промени речник док наслов остаје исти, модел је научио бар део усмерене везе са ознаком редакције.

```python
def make_input(source: str, title: str) -> str:
    prefix = "n1" if source == "n1" else "biznis"
    return f"{prefix}: {title}"
```

Математички записано:

$$
x=\texttt{<редакција>: наслов},
\qquad
\texttt{<редакција>}\in\{\texttt{n1},\texttt{biznis}\}.
$$

У 150 генерисаних примера, промена префикса `n1:` у `biznis:` скоро је удвостручила присуство израза својствених Biznis.rs, са **29,3% на 57,3%**.

_Interactive figure: generator_markers._

То показује да је контролни префикс променио речник модела. Не показује да је модел умео да пише објављиве вести. Модел од 300 милиона параметара често је производио лошу српску граматику и измишљао имена, датуме и бројеве. Преклапање 50 најкарактеристичнијих речи стварног и генерисаног корпуса било је само 4/50 за N1 и 0/50 за Biznis.rs. Ово је контролисан оглед са представама, а не производ за редакцију.

## Шта сам извукао из пројекта

Пројекат ме је учинио опрезнијим према упечатљивим првим резултатима. Када једноставан модел достигне вредности близу јединице, продуктиван одговор није да га одмах заменимо трансформером. Треба тражити пречице, поново проверити границу оцењивања и утврдити који су делови података учинили задатак лаким.

Такође је ојачао моју склоност ка малим моделима као истраживачким алатима. Коефицијенти логистичке регресије, компоненте ниског ранга, уклањање група обележја и намерно ограничено генерисање одговарали су на различита питања. Заједно су дали боље објашњење корпуса него било који појединачни резултат.

Најважније је то што систем за прикупљање и анализа чине једну целину. Одлуке при прикупљању одређују које тврдње модел може да подржи. Недостајући пасус, поновљена агенцијска вест, задржан потпис аутора или узорак из само једног месеца нису обични детаљи чишћења; они постају део понашања модела. Систем је поуздан тек када те одлуке остају видљиве од прикупљања до оцењивања.

## Шта бих следеће променио

Наредна верзија треба прво да пооштри оцењивање, па тек онда да увећа модел:

1. TF-IDF, LSA и сваку научену трансформацију треба прилагођавати само на делу за обучавање у сваком пресеку.
2. Синдиковане и скоро истоветне текстове треба уклонити, а сродне приче држати у истом делу скупа.
3. Каснији месец треба оставити као временски одвојен скуп за проверу.
4. Треба додати више редакција и упоређивати текстове о истој теми како би се стил раздвојио од избора области.
5. Генерисање треба везати за претрагу извора и људско оцењивање - или га потпуно изоставити.

Најснажнији закључак је умерен: **једноставни лексички модели ухватили су скоро сав мерљив сигнал о редакцији, сажимање је очувало већину тог сигнала, а пажљива дијагностика дала је више од повећавања сложености модела.**

[Погледајте изворни код на GitHub-у](https://github.com/miletic22/news-aggregator)

### [Планер буџета](https://mileticgoran.com/projects/budgeter/)

**Date:** 2024

**Summary:** Мала full-stack апликација за месечне буџете, трансакције и категорије потрошње.

## Зашто сам га направио

Планер буџета настао је са скромним циљем: да покаже куда одлази месечни буџет без претварања личних финансија у претрпан преглед графикона. Корисник може да распореди трошкове по категоријама, бележи трансакције и упореди потрошени износ са планираним буџетом.

Ограничен обим пројекта био је добра прилика да размишљам о границама full-stack апликације. Буџет није само број на екрану. Повезан је са корисником, месецом, категоријама и трансакцијама које мењају преостали износ у свакој категорији.

## Како ради

Клијент је написан у React-у, а API у FastAPI-ју. Backend раздваја аутентификацију, кориснике, буџете, категорије и трансакције у засебне руте. SQLAlchemy повезује те појмове са базом података, док Alembic чува измене њене структуре поновљивим.

То раздвајање постаје важно када једноставна радња обухвати више делова система. Додавање трансакције, на пример, мења и историју коју корисник види и преостали износ у категорији. Када се прорачун налази у API-ју, уместо да се понавља широм интерфејса, апликација има један извор истине.

## Шта сам научио

Backend је првобитно био написан у Flask-у, а касније сам га заменио FastAPI-јем. Та промена није била само питање брзине. Шеме захтева, јасни уговори рута и аутоматски генерисана документација учинили су грешке видљивијим још током развоја.

Пројекат ме је подсетио и да једноставан интерфејс често зависи од прилично неприметног рада у позадини. Миграције базе, аутентификација и предвидљиви одговори на грешке не виде се на снимку екрана, али одлучују да ли ће интерфејс остати једноставан док апликација расте.

[Погледајте изворни код на GitHub-у](https://github.com/miletic22/budget-web-app)

### [Провера пасоша](https://mileticgoran.com/projects/python-passport-validator/)

**Date:** 2023

**Summary:** Из скенираног пасоша издваја податке, проверава машински читљиву зону и издваја фотографију.

## Шта ради

Програм прима слику скенираног пасоша и враћа уређене податке о власнику, као и засебну слику његове фотографије. Пројекат се усредсређује на машински читљиву зону, односно два реда стандардизованих знакова при дну странице са личним подацима.

Само читање тих редова није довољно. OCR може да произведе текст који делује уверљиво, а ипак промени један знак. Један погрешан знак довољан је да број пасоша или датум буду нетачни. Зато су издвајање текста и провера података два одвојена корака.

## Како ради

PassportEye проналази машински читљиву зону и враћа њен сирови текст. Затим самостално рашчлањујем поља према стандарду ICAO 9303: врсту документа, име, држављанство, број пасоша, датум рођења, датум истека и необавезне податке чије значење одређује свака држава.

Програм примењује ICAO алгоритам контролне цифре на број пасоша, датум рођења и датум истека. Контролна цифра израчунава се из поља које јој претходи, па програм може да покаже да ли је издвојена вредност унутрашње доследна, уместо да само понови резултат OCR-а.

Ознака државе разрешава се помоћу библиотеке `pycountry`, а `face_recognition` проналази фотографију на страници и чува је као засебну слику. Рад из командне линије остаје једноставан: учитати слику, рашчланити и проверити зону, па издвојити фотографију.

## Шта сам научио

Најважнија поука била је да стандард може нејасан документ да претвори у решив проблем. OCR даје вероватне знакове; ICAO 9303 додаје положаје, дужине поља и контролне суме којима можемо да проверимо тај резултат. Спој статистичког алата и непроменљивих правила био је знатно кориснији од сваког приступа појединачно.

Пројекат намерно примењује ограничен део стандарда и не представља се као потпун систем за проверу идентитета. Рашчлањује поља из машински читљиве зоне, проверава три контролне цифре, повезује ознаке са државама и чува необавезне податке без нагађања шта свака држава у њима записује.

[Погледајте изворни код на GitHub-у](https://github.com/miletic22/passport_validator)

## Српски notes

### [Овај сајт је углавном скуп фајлова](https://mileticgoran.com/notes/the-website-is-mostly-files/)

**Date:** 2026-10-22

Овај сајт сам направио уз помоћ програмерских AI агената, али главна техничка лекција била је изненађујуће старомодна: за многе сајтове довољни су HTML и CSS.

Понекад их посматрамо као привремени слој испод „праве” технологије. За сајт који углавном објављује текстове, слике, линкове и понеки интерактивни елемент, они могу бити коначно решење. JavaScript треба додати када страница заиста треба нешто да ради. Build корак има смисла када количина садржаја учини понављање напорним.

Овај сајт отприлике тако и ради. Углавном га чине HTML, CSS, Markdown и мало JavaScript-а. Кратка Node скрипта чита садржај, склапа странице и уписује статичке фајлове. Пројекти и белешке живе у фолдерима. Енглеска и српска верзија стоје једна поред друге. Прегледач добија готову страницу.

То је намерно досадна архитектура.

Велики део веба и даље чине документи. Лични сајтови, есеји, портфолији, документација, странице догађаја и многи сајтови компанија пре свега треба јасно да представе информације и поуздано се учитају. Већи framework би постао користан ако би овај сајт добио сложено интерактивно стање. До тада, мали статички генератор држи садржај близу HTML-а који од њега настаје. Цена тог избора је то што сам одржавам генератор и документујем његова правила.

## Систем фајлова је интерфејс за садржај

Све чешће примећујем да агентски системи иду у овом смеру. Упутства живе у фајловима. Планови живе у фајловима. Агенти остављају белешке за наредна покретања. Вештине, контекст пројекта, а понекад и изненађујуће корисни облици меморије представљени су као текст у неком фолдеру.

Чак се и делови корисничког интерфејса и ширег UI/UX-а могу контролисати кроз фајлове. Правила распореда, теме, доступне функције и текст прилагођен одређеном клијенту могу бити описани конфигурацијом, уместо гранама кода писаним за сваки случај. Када је производ тако осмишљен, агент може да га прилагоди одређеном клијенту изменом ограниченог скупа фајлова, док заједнички систем остаје нетакнут. Агент не измишља нови интерфејс од почетка, већ ради унутар избора које систем већ уме да подржи.

Не може и не треба сваки проблем меморије свести на фолдер. Али за многе није потребан ни посебан сервис за меморију. Фајл је трајан, има адресу, може да се претражује, лако се мења и једноставно улази у систем за контролу верзија. И човек и агент могу да читају исту ствар. Када се нешто промени, могу да видим ко је промену направио и шта је тачно промењено.

Што се већи део система може истинито описати његовим фајловима, то је систем лакше одржавати. Мање важног стања остаје сакривено иза интерфејса, заробљено у покренутом процесу или доступно само кроз одређени алат. Агент може да уђе у репозиторијум, прегледа тренутно стање, направи ограничену измену и остави систем разумљивим следећем човеку или агенту.

Исти принцип важи и за овај сајт. Markdown фајл је лако написати. Могу да га уредим у било ком програму за текст, на било ком рачунару, без отварања посебног панела за објављивање. Фајл остаје употребљив чак и ако заменим build скрипту или преместим сајт на друго место. Садржај не припада интерфејсу који га данас објављује.

Свака страница почиње као фајл. Кратак YAML блок чува структурисане податке као што су наслов, датум, опис и путање до слика. Тело странице је Markdown.

```text
content/
  home/
    en.md
    sr.md
  notes/
    the-website-is-mostly-files/
      en.md
      sr.md
  projects/
    fuego-earth/
      en.md
      sr.md
      visuals.json
```

Скрипта за изградњу претвара ту структуру у јавни сајт. Фолдер пројекта мора да садржи усклађене енглеске и српске фајлове. Визуализација може да се опише у `visuals.json` и убаци у било коју језичку верзију. Заједнички подаци о сајту живе у једном фајлу. Правила су довољно кратка да стану у README.

То ми одговара јер садржај остаје преносив и лак за преглед. Необично добро одговара и агентима. Исти фајлови су интерфејс за уређивање, извор истине и контекст потребан за следећу измену.

Агент не мора да учи затворен CMS интерфејс нити да пролази кроз административни панел. Може да прегледа постојећи пројекат, прочита шему, направи нови фолдер, покрене build и покаже ми обичан diff. Систем фајлова агенту истовремено даје контекст и границе.

Границе су важне. Захтев „побољшај ми сајт” позива агента да мења све. Захтев „додај пројекат по овим правилима, сачувај оба језика и прођи ове провере” има видљиво завршно стање.

## Шта и даље морам ја да осмислим

Програмерски агенти могу да пређу преко изненађујуће разноврсног материјала. Могу да прегледају код, документацију, подешавања, податке, тестове и генерисани резултат, без претварања сваког од њих у засебан задатак.

Та ширина је корисна, али није исто што и дизајн система.

Ја и даље одлучујем који извор је меродаван. Одлучујем да ли графикон треба генерисати из података или га приложити као слику. Одлучујем која поља припадају YAML-у, која прози, а која треба извести током изградње. Одлучујем да свака измена садржаја мора истовремено да ажурира енглеску и српску верзију. Одлучујем шта прегледач треба да уради када JavaScript није доступан.

Те одлуке постају ограничења записана у репозиторијуму. Када су једном документоване, агент може брзо да ради без поновног нагађања. Ако остану само у мојој глави или у старом разговору, сваки нови задатак почиње делимичном реконструкцијом система.

То ми је најзанимљивији део програмирања уз помоћ AI-ја. Агент може да имплементира промену кроз читав систем, али неко и даље мора да одреди у какав систем он имплементира.

## Дај агенту изворни материјал

Научио сам и да не препричавам доказе које могу непосредно да приложим.

Исту идеју сам препознао у [есеју Феликса Ризеберга о амбициознијем раду са моделима](https://felixrieseberg.com/how-to-be-more-ambitious-a-tutorial-for-humans-with-models/). Он то назива дубљим приступом. Када моделу кажемо да се понаша као стручњак, често се промени само речник. Када му дамо изворе, методе и алате које би стручњак заиста користио, мења се и сам рад.

Одатле следи још једно питање дизајна система: шта сматрамо доказом? Тврдње треба да воде до својих извора. Графикон треба да користи изворне вредности када оне постоје. Превод треба да прати правила циљног језика, а не да замењује речи једну по једну.

Агент ради боље када тврдњу може да повеже са нечим што постоји изван његовог одговора.

## Агентима су потребне спољне провере

Агент може да пружи уверљиво објашњење онога што је променио и разлога због којих је то урадио. То објашњење није доказ да је резултат исправан.

[Anthropic водич за евалуацију AI агената](https://www.anthropic.com/research/demystifying-evals-for-ai-agents) прави корисну разлику између транскрипта и исхода. Транскрипт показује које је кораке агент предузео. Исход показује какво је стање оставио за собом. За рад на софтверу важније је ово друго.

Зато другачије дефинишем завршен задатак. Репозиторијум треба да остане у очекиваном стању. Садржај мора исправно да се обради. Сајт мора да се изгради. Генерисане путање и интерни линкови морају да постоје. Обе језичке верзије морају бити потпуне. Резултат мора да ради на величинама екрана и у окружењима за која је намењен.

Ризеберг износи сродну идеју: модел не треба да буде једини судија сопственог рада. Када је могуће, повратна информација треба да дође од спољне провере, као што су тест, type checker, browser assertion, провера линкова или ограничење перформанси. Провера треба да мери сам резултат, а не уверљивост објашњења које је агент дао.

Нема све објективног судију. Да ли српски звучи природно и да ли распоред делује мирно и даље захтева процену. Али чак и субјективна повратна информација може постати део система. Ако се исти проблем понавља, могу да га претворим у записано правило, референтни пример или ограничење које је могуће проверити.

Циљ није уклонити људску процену. Циљ је да је не трошимо стално на исту грешку коју смо могли да спречимо.

## Још један интерфејс, овог пута за агенте

Архитектура заснована на фајловима довела је до још једног експеримента. Шта ако је и посетилац агент?

Сајт за људе садржи навигацију, стилове, слике и интерактивне елементе. Агенту који покушава да сазна нешто о мени углавном су потребни текст и његова структура. Може их извући из HTML-а, али build већ има чистији извор.

Зато сајт објављује и верзију прилагођену агентима. `llms-full.txt` и `README.md` спајају почетну страницу, страницу о мени, пројекте и белешке у један Markdown документ. Краћи `llms.txt` служи као индекс.

Тако не настаје други извор истине. Фајлови за агенте генеришу се из истог садржаја као и сајт, па не могу неприметно да постану другачија биографија.

Не знам да ли ће ико икада послати агента да прегледа мој сајт. Али трошак је мали, а идеја делује исправно. Сајтови су одувек били интерфејси за прегледаче и претраживаче. Агенти постају још један читалац са нешто другачијим потребама.

## О чему још размишљам

Програмирање уз помоћ AI-ја понекад се назива „vibe coding”, што звучи као да је код мање важан зато што модел увек може да произведе још кода.

Моје искуство са овим сајтом било је готово супротно. Јефтина имплементација учинила је архитектонске одлуке важнијим. Када пет различитих приступа може да се направи за једно поподне, тешко питање више није „Могу ли ово да имплементирам?” већ „Која верзија треба да постоји и шта ће следећу измену задржати у складу са њом?”

Репозиторијум сада има 72 commit-а. То искреније описује процес него тврдња да је сајт настао из једног prompt-а. Агенти су појефтинили истраживање различитих праваца. Дизајн система је сачувао доследност онога што је преживело.

Сајт делује као мој зато што и даље одлучујем о његовој структури, изворима, компромисима и мерилу по којем је нека измена заиста завршена.

### [Шта сам научио правећи два система за обраду података](https://mileticgoran.com/notes/what-i-learned-from-data-pipelines/)

**Date:** 2025-05-09

Прошле године радио сам на два система за обраду података која су имала готово супротне проблеме.

У компанији [Spark Amplify](https://www.sparkamplify.com/) радио сам на систему који је читаве књиге прилагођавао различитим нивоима читања. Једна од њих била је [*Доживљаји Хаклберија Фина*](https://en.wikipedia.org/wiki/Adventures_of_Huckleberry_Finn) Марка Твена. Велики језички модел (LLM) прерађивао је текст, а наредни кораци су проверавали да ли је резултат лакши за читање, без губитка смисла, детаља и тона оригинала.

Други систем био је Grid Info Tracker, лични пројекат на којем сам радио са још једним инжењером. Његов задатак био је да месечне извештаје о електроенергетској мрежи и правне споразуме претвори у базу за праћење промена на енергетским пројектима током времена. Обухватио је око три године ERCOT извештаја са GIS подацима и подацима о батеријским постројењима, као и датуме издвојене из скенираних споразума. Овде је било јасно шта желимо да добијемо, али су извори били хаотични: табеле променљиве структуре, неуједначени називи датотека и скенирани документи.

Оба пројекта су показала исту слабост. Систем може да заврши без грешке, а да резултат ипак није поуздан. Најважнији део посла био је да утврдимо шта може да пође наопако, како то да препознамо и како да проверу учинимо видљивом.

## Када квалитет не може лако да се измери

Свака књига у систему компаније Spark Amplify пролазила је кроз више корака. LLM би прерадио текст, а затим су аутоматске провере поредиле нову верзију са оригиналом. Обрађивали смо више од сто текстова, па су и ретке грешке убрзо постајале чест проблем. Морали смо да их пронађемо без поновног ручног читања сваког одломка после сваког корака.

Течна и природна реченица није нужно била добра. Могла је да звучи уверљиво, а да промени причу. У *Доживљајима Хаклберија Фина*, на пример, лик није смео одједном да носи другу одећу или да говори тоном који не припада књизи. Чињенице и глас ликова били су подједнако важни као читљивост.

Користили смо BERT оцену сличности како бисмо уочили промене у значењу, као и број речи да бисмо открили верзије које су превише проширене или скраћене. Ниједна од тих мера није могла сама да потврди да је одломак добар. Вределе су зато што су поједине врсте грешака претварале у бројеве које можемо да пратимо.

Тако сам почео другачије да размишљам о евалуацији. Квалитет језика није бинарна особина и један резултат не може да замени процену. У пракси је било боље да дефинишемо више знакова лошег резултата, поставимо разумне границе и сумњиве примере вратимо на преглед. Модел је прерађивао текст; систем око њега чинио је ту прераду употребљивом.

## Прецизност пре интелигенције

Пројекат о електроенергетској мрежи имао је уже питање: ког датума је сваки споразум правно ступио на снагу? Унети тај одговор у базу података било је теже него што звучи.

Многи споразуми били су скенирани, а не дигитални PDF документи, па обично издвајање текста није радило. Испробао сам Tesseract и неколико OCR услуга у клауду. У почетку се најпоузданијим показало решење које је користило `pdfplumber` за рад са страницама PDF-а и PIL за припрему слика пре OCR-а. Изоштравање, исправљање искошених страница и промена формата знатно су побољшали резултат.

Ручно сам означио око двадесет докумената и користио их као мали контролни скуп. То је било важно јер датуми не трпе приближност. Систем може тачно да прочита готово целу страну, а да ипак буде бескористан ако погреши само једну цифру у датуму.

Када је OCR постао довољно поуздан, OpenAI модел је из издвојеног текста бирао датум ступања на снагу. Регуларни израз је могао да пронађе датуме, али није поуздано разликовао датум потписивања, датум измене и датум од којег споразум важи. Ни структура докумената није била уједначена. Модел је помагао зато што је правило било нејасно, а не зато што нам правила више нису била потребна.

Документи су често имали стотине страница. Ручним прегледом приметио сам да се потребан датум обично налази близу почетка или краја, па је систем моделу слао само првих и последњих десет страна. Тако смо смањили трошак и из упита избацили већину небитног текста.

Касније смо прешли на Google алате за OCR и обраду текста. Промена је била једноставна јер су кораци за издвајање текста били одвојени од остатка система. Тада сам први пут непосредно видео колико модуларност вреди: једна компонента више није била најбољи избор, а њена замена није захтевала преписивање целог решења.

## Месечни извештај као нестабилан интерфејс

Подаци организације ERCOT у почетку су деловали једноставније. Сваког месеца добијали смо нове CSV датотеке, а требало је спојити око три године извештаја у уједначене скупове GIS података и података о батеријским постројењима.

Само спајање датотека било је најлакши део. Свако издање доносило је ситна изненађења. Називи колона су се мењали. Појављивали су се додатни редови. Поље о батеријама било би празно једног месеца, а попуњено следећег. Коментари су постојали у неким редовима, а затим нестајали из наредне датотеке. Скрипта написана према једном месечном извештају увек је претпостављала нешто о следећем.

Биле су нам потребне посебне скрипте за први увоз и за каснија месечна ажурирања. Део трансформација писао се помоћу алата `dbt`, који до тада нисам користио. Највише сам користио pandas, SQL и BigQuery, а спајањима и поређењима проверавао сам како се једно издање разликује од другог.

Временом сам сваку месечну датотеку почео да посматрам као нову верзију интерфејса без документације. Пре употребе морала је да се провери. Код за спајање морао је јасно да наведе које колоне и типове очекује и да пријави разумљиву грешку када те претпоставке више не важе.

То је променило и моје схватање уредних података. Уредност није особина коју скуп података има сам по себи. Подаци су довољно уредни за одређену намену. Празно поље о батерији може бити небитно за једну анализу, а пресудно за другу. Систем мора да зна ту разлику.

## Шта бих поновио

У оба пројекта користио сам вештачку интелигенцију, али за различите послове. У компанији Spark Amplify стварала је текст. У пројекту о електроенергетској мрежи решавала је недоумице које је било тешко свести на непроменљиво правило. На оба места, највећи део инжењерског рада био је око модела: припрема улаза, дефинисање прихватљивог резултата, провера граничних случајева и одлука о томе када човек треба поново да погледа резултат.

Раније сам систем за обраду података сматрао поузданим ако заврши рад без пада. То је само прва провера. Поуздан систем мора да покаже и шта је обрадио, које су претпоставке важиле и где резултат можда није тачан.

Следећи скуп података имаће грешке које сада не могу да предвидим. Желим да задржим једну једноставну навику: пре него што поверујем добрим резултатима, морам да одлучим како ћу препознати лоше.
