[{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/algorithms/","section":"Tags","summary":"","title":"Algorithms"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/computational-geometry/","section":"Tags","summary":"","title":"Computational Geometry"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/posts/","section":"Posts","summary":"","title":"Posts"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/software-engineering/","section":"Tags","summary":"","title":"Software Engineering"},{"content":"Hi, I’m Constantin, a software engineer based in Hamburg. This blog is where I document what I learn about software engineering, cloud development, AI-supported development, and the occasional topic beyond code. I write these notes for my future self, and for anyone exploring similar questions.\n","date":null,"permalink":"https://constantinsblog.eu/","section":"Software Engineering, Cloud, and AI Notes","summary":"","title":"Software Engineering, Cloud, and AI Notes"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/","section":"Tags","summary":"","title":"Tags"},{"content":"Two line segments either intersect or they do not. At least, that is the question I thought I was asking.\nIn Komp-Geom, my Kotlin Multiplatform geometry library, a pairwise segment test can report a contact that a Bentley–Ottmann sweep over the same finite input misses. It happens near a precision boundary, where coordinates have been perturbed by tiny amounts. I first noticed the problem in a sweep, but I do not think of it as a sweep-line problem anymore. A sweep merely makes the consequences visible: if two numerical decisions disagree, an entire chain of later decisions can go wrong.\nThe question I want to answer is broader. What does it mean for a geometry library to make consistent decisions about its input, and what should it promise when a result lies near a precision boundary?\nA precision boundary is a decision boundary #A Double stores a finite-precision number, and arithmetic on doubles rounds intermediate results. An epsilon is something else: a policy that says when an application will treat two values as close enough. An epsilon does not measure the rounding error of every expression. It can also deliberately blur the distinction between different input coordinates.\nKomp-Geom\u0026rsquo;s EpsilonDoubleEquivalence considers finite values a and b approximately equal when\n|a − b| ≤ ε · max(1, |a|, |b|)\nThe default geometric epsilon is 10⁻¹⁰. Near zero this has an absolute component, while at larger magnitudes the threshold scales with the operands. Operations can receive a different DoubleEquivalence. This is useful when a local comparison should respect a caller\u0026rsquo;s notion of closeness.\nBut closeness is not identity. With ε = 10⁻⁸, take a = 0, b = 0.75 × 10⁻⁸, and c = 1.5 × 10⁻⁸. The first two are close, as are the last two, but the first and last are not. Approximate equality is not transitive. It must not be the identity or comparator for an ordered collection. Apache Commons Geometry also distinguishes approximate eq() from strict equals() and hashing. Its precision context is useful for local tolerance-aware operations, but keeping eq() out of object identity does not, by itself, guarantee that every algorithm\u0026rsquo;s predicates and constructed coordinates agree near a boundary.\nMore importantly, comparing numbers is not the same as deciding geometry. An orientation test asks on which side of a directed line a point lies. An incidence test asks whether a point belongs to an edge. An intersection test asks whether two edges meet. A construction calculates a new point. If those operations disagree, the output can change its connectivity, not just its last decimal digit.\nThis reaches far beyond a sweep #Consider a convex hull algorithm examining three nearly collinear points. If its orientation test changes sign, it can keep a point that should be discarded or remove a point that should be a hull vertex. A polygon algorithm deciding whether a point is inside, on the boundary, or outside must give its boundary cases compatible answers. In a triangulation, an incorrect orientation or incircle sign can change the combinatorial structure. A polygon overlay or arrangement must agree on whether two edges meet at one vertex before it can assemble faces. These are examples of the same general dependency, not claims that all these algorithms currently fail in Komp-Geom.\nI saw this in a different form while working on my Minkowski-sum thesis. When nearly identical computed coordinates became separate vertices in a DCEL, edges could fail to connect and faces could fail to close. The resulting error was topological. Rounding coordinates more aggressively helped that implementation in some cases, but it was a recovery strategy, not a general definition of geometric correctness.\nNot every numerical operation needs exact topology. An approximate distance for display may be enough. The stronger requirements arise when that number decides which objects exist or connect.\nWhy the sweep exposed the gap #A pairwise test classifies a contact with the configured tolerance and computes its point using Double arithmetic. The sweep must then place that point in an event queue. Komp-Geom shears coordinates for event ordering and checks incidence back in the original coordinate system. Its status tree now uses a stable, position-based order rather than an epsilon-dependent comparator. But event coordinates and segment heights are still calculated with rounded doubles, while incidence checks remain tolerance-aware.\nThe differential test exposes a disagreement along that path. A constructed event might no longer satisfy a later check that the pairwise test passed. The diagram shows one possible mechanism, not a proven diagnosis of the observed case.\nThe choices define different kinds of geometry #There is no single “more precise” switch to flip. The first two ideas address different parts of a calculation and can work together. The third deliberately changes the geometry to a chosen resolution.\nFiltered robust predicates: make reliable decisions #A predicate answers a small question, such as “Is this point to the left or right of that line?” Three points that are almost in a straight line make the answer difficult because rounding might change which side the third point appears to be on. A filtered predicate first tries the fast calculation. If the possible rounding error is too small to change the answer, it accepts it. Otherwise, it calculates more carefully before deciding.\nShewchuk\u0026rsquo;s adaptive predicates use this approach for questions including orientation. They determine the correct answer for the floating-point numbers provided as input. They cannot tell us whether those numbers perfectly describe a measured object in the real world, and they do not by themselves calculate an exact intersection point. Unlike an epsilon comparison, the filter does not simply call a small result zero.\nPredicates and constructions: decide, then locate #Knowing that two segments cross is one thing. Calculating where they cross is another. The first is a decision, or predicate. The second is a construction: it creates a new coordinate. CGAL\u0026rsquo;s robustness guidance treats these as separate responsibilities, and its kernel design asks which operations an algorithm needs.\nEven if the input endpoints are Double values, their intersection may fall between the numbers a Double can represent. An approximate Vec2 is often fine for drawing a dot on a screen. It is not necessarily safe as the identity of a shared vertex or as a key for ordering later events. If an algorithm will make more decisions about that point, it must retain a representation or a way of comparing it that stays consistent with the earlier decision.\nFixed-grid geometry: choose a resolution #Suppose a map records locations on a one-metre grid. Two distinct points might both be moved to the same grid position. They now are one point in the reduced geometry. A narrow gap can close, and a short line can collapse. This is not a rounding error to be hidden. It is a deliberate rule about which details the map keeps.\nProper snap rounding also needs rules for the lines and shapes connected to those points. JTS\u0026rsquo;s precision reducer documents a mode that preserves valid reduced topology while warning that nearby polygons may merge. A grid is useful when an application has a meaningful resolution, but it is not a drop-in replacement for saying two values are approximately equal.\nThe choice for Komp-Geom #For Komp-Geom, I would first evaluate exact-input topology for finite Double coordinates, using filtered predicates for common cases and more precise computation when a result is uncertain. This would change the API\u0026rsquo;s geometric semantics, not just its numerical accuracy: a near-miss currently accepted as a contact because of tolerance would no longer count as an intersection. An intersection returned as Vec2 could still be approximate, but that rounded point must not silently determine event order or vertex identity.\nI would start with segment intersection and the sweep as a test of the whole contract, then apply the same predicate and construction rules to other topology-sensitive algorithms. I would keep EpsilonDoubleEquivalence for local approximate comparisons and consider fixed-grid geometry as a separate model.\nThe sweep revealed the gap, but the goal is broader: every decision that changes geometric topology must follow a coherent model.\n","date":"26 September 2026","permalink":"https://constantinsblog.eu/posts/precision-boundaries-komp-geom/","section":"Posts","summary":"A sweep-line discrepancy reveals a broader problem: numerical decisions define the topology of geometric algorithms. Here is the precision foundation I want for Komp-Geom.","title":"When Geometry Algorithms Disagree: Precision Boundaries in Komp-Geom"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/artificial-intelligence/","section":"Tags","summary":"","title":"Artificial Intelligence"},{"content":" Type /graphify in your AI coding assistant and it maps your entire project (code, docs, PDFs, images, videos) into a knowledge graph you can query instead of grepping through files.\nThis claim is prominently displayed in the Graphify repository. It immediately caught my attention. grep and tools such as rg are old, simple, and dependable. A knowledge graph sounds much more sophisticated, but sophistication alone does not make a tool useful.\nThere is also a more interesting question behind the marketing comparison. AI coding agents do not explore repositories exactly as humans do. They issue many searches, read fragments of files, follow symbols across modules, and repeatedly add results to their context. Perhaps a graph can give an agent a better starting point and avoid some of that work.\nI tested that idea on the FastAPI repository. I asked the same coding agent twelve questions, once with access to Graphify and once without it. I measured time, tool use, files inspected, tokens, and cost.\nWhat Graphify actually builds #Graphify is not a vector database. According to its description of the extraction process, it builds a graph in several passes:\nTree-sitter extracts code structure, including classes, functions, imports, and calls. Audio and video can be transcribed locally. Documentation, papers, images, and transcripts can receive a semantic extraction pass from an LLM. The resulting nodes represent things such as files, symbols, and concepts. Edges describe relationships such as calls, imports, and implements. Graphify labels relationships as EXTRACTED, INFERRED, or AMBIGUOUS, which is important because not every graph relationship has the same evidential value. It then uses the Leiden algorithm to group densely connected nodes into communities.\nThe project produces a machine-readable graph, a report, and an interactive HTML visualization. This is the graph generated from my FastAPI checkout:\nGraph generated by Graphify from the FastAPI repository Open interactive graph The visualization is impressive, but my experiment is not about whether Graphify can draw a graph. It is about whether that graph helps an agent answer realistic repository questions with less work.\nThe large-corpus warning matters #Graphify\u0026rsquo;s current skill warns when a corpus contains more than 500 supported files or 2,000,000 words and asks the user to narrow the scope. FastAPI crossed the file threshold, but I deliberately continued with the whole repository to include implementation code, tests, and documentation.\nThat gave Graphify broad information, but it also increased construction cost and included material irrelevant to some questions. The warning is sensible product behavior, though it qualifies the promise to map an \u0026ldquo;entire project.\u0026rdquo; Building one enormous graph is not automatically better than indexing the part of a repository relevant to the work.\nExperiment setup #Graphify\u0026rsquo;s own README uses a FastAPI graph as its main example, so I used the same project. The checkout contained FastAPI version 0.141.1 at commit 50113da. The exported sessions also preserve the files and line references used by the agent.\nI used:\nGraphify 0.9.55 OpenAI\u0026rsquo;s gpt-5.6-luna for the coding-agent sessions The pi coding agent as the agent harness One fresh session per question I checked the repository\u0026rsquo;s product and benchmark claims at commit c7ec108, which identifies itself as version 0.9.62. The experiment used 0.9.55, so current behavior or claims may differ.\nI generated the graph with:\nuv tool install \u0026#39;graphifyy[sql,openai]\u0026#39; graphify extract . --openai graphify cluster-only . --openai Graph generation consumed 3.8 million input tokens and 349,000 output tokens. The mixed corpus contains extensive documentation and other assets, so this result describes the tested options and corpus, not the cheapest possible code-only setup.\nI then used two agent configurations:\nWithout Graphify: normal repository tools such as search and file reads With Graphify: the same tools plus the Graphify skill and generated graph Both configurations received the same model, question, response format, and repository state. The treatment was ordinary repository exploration versus repository exploration augmented with the Graphify skill, generated graph, and Graphify query tools. This means the comparison measures the practical effect of adding Graphify to the agent, rather than isolating the graph from the skill instructions and tools.\nQuestions #I divided the questions into three groups. I avoided Graphify-specific terms because I wanted the prompts to resemble questions asked while entering an unfamiliar project.\nSimple navigation #These questions test whether Graphify adds unnecessary overhead to work that source search already handles well.\nQ1: Where is the FastAPI class defined? Q2: Where is the application startup logic? Q3: Which file defines the relevant configuration option? Q4: Where are HTTP exceptions converted into responses? Q3 is underspecified. I retained it as an ambiguity case study, but excluded it from the aggregate totals and question-type averages. Its result remains in the raw tables for inspection. I would replace it with a named configuration option in a future run.\nRelationship questions #These require following relationships across the codebase.\nQ5: Trace the path from an incoming request to a route handler. Q6: How are dependencies resolved before a route is called? Q7: How does middleware interact with exception handling? Q8: How is a registered route connected to OpenAPI schema generation? Change-impact questions #These ask the agent to estimate the parts of the repository affected by a change.\nQ9: Which files would need to change to add a new middleware configuration option? Q10: Where would you modify the code to change dependency error handling? Q11: What parts of the project would be affected by changing route registration? Q12: Which tests should be updated when changing OpenAPI generation? These questions do not have one definitive list of files. Different implementations can be valid, so I treat them as exploration tasks rather than questions with a simple answer key.\nPrompt #Every session received this prompt:\nI am new to the FastAPI project and have a Question: [Question] Provide: 1. A concise answer to the question. 2. The reasoning path you followed. 3. Relevant files, symbols, and line numbers. 4. Any uncertainty or assumptions. What I measured #For each session, I recorded:\nTime: elapsed time to the first useful answer. I counted the first answer that addressed the question and included at least one verifiable file or symbol reference. I did not require the answer to be fully correct because correctness was not independently graded. Files: unique repository files inspected Tool calls: all tool invocations Graph calls: calls that queried Graphify Total input: uncached input plus prompt-cache reads and writes across model calls Output: generated output tokens Observed cost: provider-estimated cost in US dollars, including the cache behavior recorded during the session Total input is not the number of source-code tokens read from disk. It is the input processed across the complete agent session. Repeated conversation context, tool results, and Graphify output all contribute to it. The exported session data does not separate skill instructions, graph-query output, source-tool output, and repeated context, so I cannot determine which component caused the difference between configurations.\nResults #Overall result #Across the eleven included questions, the totals were:\nMetric Without Graphify With Graphify Change with Graphify Time 468 s 511 s +9.2% Files inspected 89 76 -14.6% Tool calls 175 160 -8.6% Total input 1,974,282 2,951,731 +49.5% Output 27,638 27,204 -1.6% Observed cost $0.1687 $0.1847 +9.5% Graphify reduced the number of files inspected in eight included questions, with one tie and two cases where it inspected more files. It reduced tool calls in seven questions and increased them in four. It was faster in four questions and slower in seven, used less input in none of them, and had lower observed cost in two.\nThat combination is the central result. Graphify usually gave the agent a narrower set of source files, but querying and processing the graph introduced enough context and work to erase the expected token savings. On the measured efficiency outcomes, Graphify performed worse overall. Because I did not grade answer quality, the experiment cannot determine whether the additional cost bought better or more complete answers.\nFiles inspected and tool calls are also proxy metrics, not user outcomes. One graph query can return more text than several targeted searches, and inspecting fewer files is not useful if the answer is slower, more expensive, or less complete. This experiment measures exploration effort, not answer correctness. On time, input, and observed cost, Graphify performed worse overall. I cannot tell whether that additional expenditure produced better answers.\nThe output token totals were almost identical. The large difference is on the input side, where the Graphify configuration processed nearly one million additional tokens across the eleven included sessions.\nResults by question type #Averages reveal a more useful pattern than the overall total:\nQuestion type Configuration Time Files Tool calls Total input Output Observed cost Simple navigation Without Graphify 26.0 s 5.3 11.7 97,772 1,910 $0.0103 Simple navigation With Graphify 39.0 s 4.0 11.7 195,548 1,550 $0.0122 Relationships Without Graphify 35.0 s 5.3 13.8 181,467 2,647 $0.0156 Relationships With Graphify 52.8 s 4.5 18.3 295,205 2,885 $0.0180 Change impact Without Graphify 62.5 s 13.0 21.3 238,775 2,830 $0.0189 Change impact With Graphify 45.8 s 11.5 13.0 296,067 2,754 $0.0191 Simple navigation did not need a graph #For the three included simple-navigation questions, Graphify inspected 1.3 fewer files per question on average, but it took 13 seconds longer and processed 100% more input. Finding class FastAPI is exactly the kind of task where an exact source search is difficult to beat.\nQ1 is representative. The Graphify-enabled agent queried the graph, searched for the exact declaration, read fastapi/applications.py, and checked the public re-export in fastapi/__init__.py. The graph suggested the area, but the agent still needed targeted source searches and file reads to verify the answer and line numbers.\nThis is not a failure of the graph. It is a mismatch between tool and task. A symbol search already has an excellent signal-to-noise ratio.\nRelationship questions were the weakest category #I expected Graphify to perform best on relationship questions. Instead, this category had the largest slowdown. Average time increased from 35.0 to 52.8 seconds, tool calls increased from 13.8 to 18.3, and input increased by 62.7%.\nBoth configurations generally found the same central implementation paths. For example, both Q5 answers traced the request through the application, middleware, router, dependency resolution, endpoint invocation, and response serialization. Both cited fastapi/applications.py, fastapi/routing.py, and fastapi/dependencies/utils.py.\nThe graph did not eliminate source inspection. In every Graphify-enabled session, the agent queried Graphify and then used searches and file reads to confirm the returned relationships. That verification is desirable, especially for inferred edges, but it means the graph acts as an additional navigation layer rather than a replacement for source search.\nBroad change-impact questions were more promising #The change-impact category tells a different story. Graphify reduced average time from 62.5 to 45.8 seconds and tool calls from 21.3 to 13.0. It also reduced the average number of inspected files from 13.0 to 11.5.\nQ12 had the clearest improvement. The non-Graphify session took 101 seconds and 29 tool calls while searching the test suite. The Graphify session took 47 seconds and 15 tool calls. Both still inspected many files, but the graph appears to have provided a useful initial map of the OpenAPI-related test surface.\nThis category contains only four single runs, and Q12 is a conspicuous slow baseline run. Without repeated runs or an answer-quality score, I cannot tell how much of the apparent gain came from the graph and how much came from normal agent variance. The tradeoff also remained. Change-impact questions consumed 24.0% more input with Graphify, and the average observed cost was nearly identical. I treat this as a lead for a better experiment, not evidence that Graphify improves change-impact analysis.\nHow this compares with Graphify\u0026rsquo;s benchmarks #Graphify publishes two relevant benchmarks.\nToken reduction #The how-it-works document reports:\nCorpus Files Reported reduction Karpathy repositories, papers, and images 52 71.5x Graphify source and the Transformer paper 4 5.4x Synthetic httpx library 6 approximately 1x At first sight, 71.5 times fewer tokens seems incompatible with my 49.5% increase. The measurements use different baselines. Graphify\u0026rsquo;s benchmark.py compares an estimate of the full corpus with an estimate of a scoped graph subquery. My baseline agent used rg, find, and targeted reads, while my measurement also includes conversation history, tool results, and every model call.\nGraph compression can therefore be real while end-to-end agent input increases. A retrieval tool should be compared with the targeted search and reads that a capable coding agent already has, not only with putting an entire repository into context.\nCode intelligence #Graphify\u0026rsquo;s published benchmark report includes a closer comparison. On six graded questions over the roughly one-million-line ERPNext repository, a fixed coding agent reportedly improved key-fact coverage from 70.8% with grep and read tools to 82.0% when given one Graphify tool. The report states approximately 140,000 tokens per query.\nThe grading method is better than mine because answers are checked against atomic key facts. Still, this is a self-published result from Graphify\u0026rsquo;s own harness with only six questions and aggregate results. It uses a different model, repository, and Graphify implementation, while my experiment measures end-to-end effort rather than key-fact coverage.\nThe results are not contradictory, but neither demonstrates broad usefulness. Together they suggest a narrower hypothesis worth testing: Graphify\u0026rsquo;s value may increase with repository size and broad questions, while its overhead dominates simple navigation that ordinary search can already handle.\nWhat I can and cannot conclude #Supported by the measurements #For the eleven included FastAPI questions and this configuration:\nGraphify reduced the number of files inspected in eight questions, with one tie. Graphify reduced the number of tool calls in seven questions. It did not reduce total input in any question. It was slower overall. Its observed cost was 9.5% higher overall, before including graph construction. It helped most with broad test and change-impact exploration. The agent continued to use source search and file reads after querying the graph. Not supported by the measurements #I cannot conclude that:\nGraphify produced more correct answers. The non-Graphify answers were more correct. One run per question captures normal model variance. The result generalizes to every repository, model, or Graphify version. The missing correctness benchmark is the largest limitation. A future experiment should define atomic expected facts before running the sessions. For change-impact tasks, historical pull requests could provide a useful reference by comparing predicted files with the merged change.\nOther limitations #One run per question and configuration #Model behavior is stochastic. Repeating each question several times and randomizing run order would separate tool effects from ordinary run-to-run variation.\nCache behavior #The session exports contain prompt-cache reads and writes. Cached input can be priced differently from uncached input, so Total input and monetary cost should not be expected to change proportionally. Adding the Graphify skill also changes the prompt prefix and may affect cache effectiveness.\nThe observed cost uses the provider estimates recorded during each session, including the cache behavior that occurred during that run. Because I did not control execution order, warm-up, or provider cache state, the cost comparison describes these runs rather than a stable uncached price comparison. I combined all input categories into Total input rather than claiming a controlled cache comparison.\nThe questions vary in precision #Q3 is too vague, and the change-impact questions permit several valid interpretations. A future version should name concrete options and expected behavior.\nUp-front construction was not amortized #The per-question tables exclude the 3.8 million input and 349,000 output tokens used to generate the graph. Even without assigning a monetary cost to construction, the Graphify sessions had higher observed cost overall. Reusing the graph could amortize construction, but it would not recover the observed per-query premium in these runs.\nGraph freshness also has an operational cost. Updates after pulls and merges, CI ownership, and generated artifacts could matter on a large team, but I did not measure that burden. It remains another unproven part of the large-repository value proposition.\nWhen I would use Graphify #Based on this experiment and Graphify\u0026rsquo;s published design, I would only evaluate it further when:\nThe repository is very large and unfamiliar. Questions cross many packages or layers. I repeatedly need change-impact analysis. Code, architecture documents, papers, and other media need to be searched together. The same graph will be reused enough to amortize construction. I need to trace relationships that are awkward to express as a lexical search. I would probably skip it when:\nI need to locate a known class, function, or configuration name. The repository is small enough for targeted search and reads. I only have one or two questions. The graph would immediately become stale. I cannot justify sending documentation or other assets through a semantic extraction backend. A practical hybrid workflow #The experiment suggests that “Graphify instead of grep” is the wrong mental model. A safer workflow is:\nQuery the graph for candidate symbols, files, and paths. Prefer EXTRACTED relationships over inferred ones when possible. Verify important graph relationships in the source. Use exact search for declarations and references. Narrow the graph to relevant directories for focused work. Treat the graph as an index, not as the final authority. This is also how the Graphify-enabled agent behaved. The graph provided orientation, while source reads provided evidence.\nConclusion: useful map, not a grep replacement #Graphify is more than a fancy HTML graph generator. It builds a queryable structural index and presents relationships that can be awkward to discover lexically. That capability is real. Its usefulness to a coding agent is not established by capability alone.\nIt did not replace grep in my experiment. In every Graphify-enabled session, the agent queried the graph and then used source searches or file reads to verify the result. For straightforward navigation and even several cross-file relationship questions, the graph generally acted as an additional retrieval step before source verification. The measured result was more input and more latency. I did not measure whether answer quality improved enough to justify either.\nThe up-front cost also matters. I spent 3.8 million input tokens and 349,000 output tokens to build the graph, then the Graphify-enabled sessions processed 49.5% more input than the baseline and had higher observed cost in nine of the eleven included questions. On the measured efficiency outcomes, that investment did not pay back. More reuse would spread the build cost, but the observed per-query cost was also higher in these runs, so reuse alone does not fix the economics shown here. An independent quality evaluation could change that economic judgment if it showed that the premium consistently bought better answers.\nMy current verdict is more skeptical than \u0026ldquo;useful for large repositories.\u0026rdquo; I would not adopt Graphify based on the efficiency evidence in this experiment alone. I would run a stronger evaluation on a large, long-lived codebase with repeated cross-cutting work, independently graded answers, and historical changes as ground truth. Until such a test shows better answers or reliable time savings, targeted source search remains the simpler and better-supported default.\nMost importantly, this experiment changed the question I would ask of any AI code-intelligence tool. “Does it compress the repository?” is not enough. The useful question is: Does it help an agent reach a well-supported answer faster and more cheaply than targeted search against the source?\nFor this FastAPI experiment, the answer is not yet a convincing yes.\nRaw results #The individual measurements and complete exported sessions are included below so the comparison can be inspected and reproduced.\nWithout Graphify # Q Time Files Tool calls Graph calls Total input Output Observed cost Raw Q1 23 2 8 0 50,483 881 $0.0059 session Q2 28 8 12 0 86,642 2,610 $0.0117 session Q3 (excluded) 42 6 14 0 104,745 1,921 $0.0107 session Q4 27 6 15 0 156,190 2,239 $0.0132 session Q5 32 4 10 0 125,938 2,987 $0.0154 session Q6 37 4 14 0 197,980 2,289 $0.0163 session Q7 36 8 19 0 218,382 2,650 $0.0163 session Q8 35 5 12 0 183,568 2,662 $0.0144 session Q9 60 17 17 0 218,802 3,013 $0.0167 session Q10 46 7 21 0 178,837 2,165 $0.0149 session Q11 43 8 18 0 319,530 3,319 $0.0262 session Q12 101 20 29 0 237,930 2,823 $0.0177 session With Graphify # Q Time Files Tool calls Graph calls Total input Output Observed cost Raw Q1 52 2 7 3 122,192 1,141 $0.0081 session Q2 19 5 9 3 110,969 988 $0.0083 session Q3 (excluded) 41 6 12 3 147,471 1,807 $0.0126 session Q4 46 5 19 4 353,484 2,520 $0.0203 session Q5 61 3 20 3 314,420 3,192 $0.0191 session Q6 41 5 17 3 281,416 2,545 $0.0169 session Q7 64 7 15 3 323,928 2,882 $0.0172 session Q8 45 3 21 3 261,055 2,920 $0.0186 session Q9 41 12 11 3 312,040 2,646 $0.0180 session Q10 45 8 12 3 294,761 2,035 $0.0161 session Q11 50 7 14 3 337,304 3,281 $0.0229 session Q12 47 19 15 3 240,162 3,054 $0.0192 session ","date":"15 September 2026","permalink":"https://constantinsblog.eu/posts/graphify-llm-query-tool/","section":"Posts","summary":"A measured comparison of Graphify and ordinary source search across twelve FastAPI repository questions, including time, tokens, cost, and whether the knowledge graph showed enough value to justify its overhead.","title":"Graphify vs. grep: Does a knowledge graph help an AI coding agent?"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/cloud/","section":"Tags","summary":"","title":"Cloud"},{"content":"Information pursuant to Section 5 DDG #Constantin Ponfick\nc/o Impressumservice Dein-Impressum\nStettiner Str. 41\n35410 Hungen\nGermany\nPerson responsible for editorial content pursuant to Section 18 (2) MStV #Constantin Ponfick\nc/o Impressumservice Dein-Impressum\nStettiner Str. 41\n35410 Hungen\nGermany\nContact # Telephone: 0157 9234 1658 Email: info@constantinsblog.eu Consumer dispute resolution pursuant to Section 36 VSBG #I am neither willing nor obliged to participate in dispute-resolution proceedings before a consumer arbitration board.\nData protection #Information about the handling of personal data is available in the Privacy Policy.\nLast updated: September 2026\n","date":null,"permalink":"https://constantinsblog.eu/imprint/","section":"Software Engineering, Cloud, and AI Notes","summary":"","title":"Legal Notice"},{"content":"Controller #Constantin Ponfick\nc/o Impressumservice Dein-Impressum\nStettiner Str. 41\n35410 Hungen\nGermany\nEmail: info@constantinsblog.eu Telephone: 0157 9234 1658 Hosting and access data #This website is published as a static website through GitHub Pages. GitHub expressly states that, when a GitHub Pages site is visited, the visitor\u0026rsquo;s IP address is logged and stored for security purposes, regardless of whether the visitor is signed in to GitHub. Other technically necessary connection and usage data may also be processed, including the date and time of access, requested URL, referrer, and information about the device and browser used.\nThe processing serves to provide, maintain, and secure the website. The legal basis for processing by me is Art. 6 (1) (f) GDPR; my legitimate interest is the secure and reliable operation of the website. According to its own information, GitHub acts as the controller for its services. GitHub\u0026rsquo;s documentation applicable to GitHub Pages does not specify a concrete retention period for GitHub Pages access logs. For further information, please see GitHub\u0026rsquo;s Privacy Statement and GitHub Pages documentation.\nCookies, local storage, and analytics #This website does not use analytics or advertising services and does not set non-essential cookies. The display preference (for example, light or dark appearance) may be stored locally in your browser. This information is not transmitted to me.\nContact by email or telephone #If you contact me by email or telephone, I process the information you provide solely to handle your request and communicate with you about it. The legal basis is Art. 6 (1)(f) GDPR; if your request concerns entering into or performing a contract, Art. 6 (1)(b) GDPR also applies.\nI delete the request as soon as it is no longer needed for communication, unless statutory retention obligations apply.\nExternal links #This website contains links to external websites, particularly social networks. When you follow such a link, you leave this website. The operator of the destination website is solely responsible for processing there.\nYour rights #Subject to the applicable legal requirements, you have the right to access, rectification, erasure, restriction of processing, data portability, and objection to processing. You may withdraw consent at any time with effect for the future.\nYou also have the right to lodge a complaint with a data-protection supervisory authority. In particular, you may contact the authority at your habitual residence, place of work, or the place of the alleged infringement.\nLast updated: September 2026\n","date":null,"permalink":"https://constantinsblog.eu/privacy-policy/","section":"Software Engineering, Cloud, and AI Notes","summary":"","title":"Privacy Policy"},{"content":"I wanted to publish this blog without maintaining a server, but I also did not want to make its source repository public. The repository contains more than the finished website. It includes the history of the blog, unfinished articles, configuration, and potentially personal notes that I may add later.\nHugo already creates a useful boundary for this problem. The source stays in one place, while the public/ directory contains the static files visitors need. My deployment keeps the source in a private repository and publishes only that generated directory through a second, public repository.\nThe result is a public website with private source material and no additional server to operate. This is not a way to make a website private. Anything Hugo includes in the generated output remains public.\nThis post explains how to reproduce that setup with GitHub Actions, an SSH deploy key, and GitHub Pages.\nThe two-repository setup #The repositories have different responsibilities:\nRepository Visibility Contents blog Private Hugo content, configuration, scripts, and the GitHub Actions workflow gh-pages-blog Public Generated files from Hugo\u0026rsquo;s public/ directory The deployment follows one path from source to website:\nflowchart LR source[Private blog repository] --\u003e|push to main| actions[GitHub Actions] actions --\u003e|build and check| output[Hugo public directory] actions --\u003e|SSH push| repository[Public deployment repository] repository --\u003e|publish main from root| pages[GitHub Pages] pages --\u003e domain[constantinsblog.eu] I treat the public repository as an artifact repository. I do not write posts or edit generated HTML there. If the repository were lost, I could recreate its contents by building the private source repository again.\nTo follow this approach, create:\nA private repository containing the Hugo site A public repository that will contain the generated site A main branch in the public repository The public repository can start with an initial commit. The first deployment will replace its contents with the generated website.\nGive the workflow access to the public repository #The workflow runs in the private repository but needs permission to push to the public one. I use a dedicated SSH deploy key rather than a personal key or a token with access to several repositories.\nGenerate a new key pair locally:\nssh-keygen -t ed25519 -C \u0026#34;blog-pages-deployment\u0026#34; -f ./blog-pages-deploy-key This creates a private key named blog-pages-deploy-key and a public key named blog-pages-deploy-key.pub.\nIn the public deployment repository, open Settings → Deploy keys, add the public key, and enable Allow write access. The key is then restricted to that repository.\nIn the private source repository, open Settings → Secrets and variables → Actions and add the private key as a repository secret named GH_PAGES_DEPLOY_KEY.\nThe private key should never be committed to either repository. After adding both parts to GitHub, remove the local key files if they are no longer needed. If the private key is ever exposed, remove the deploy key from the public repository and replace it.\nBuild Hugo in GitHub Actions #The workflow starts after a push to main. I also allow manual runs, which are useful when testing deployment changes:\nname: Build and publish Hugo site on: push: branches: [main] workflow_dispatch: permissions: contents: read The explicit permission is intentionally narrow. The workflow only needs to read the private source repository. The SSH deploy key supplies the separate write permission for the public repository.\nThe first steps check out the source and install a pinned Hugo version:\njobs: publish: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v5 with: submodules: recursive fetch-depth: 0 - name: Install Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#39;0.165.0\u0026#39; extended: true Pinning the version avoids depending on whatever happens to be installed on the runner. I enable Extended Hugo because the theme requires it.\nThe build itself is strict:\n- name: Build site run: \u0026gt;- hugo --gc --cleanDestinationDir --minify --printI18nWarnings --printPathWarnings --panicOnWarning --cleanDestinationDir removes files from previous builds, while --panicOnWarning prevents Hugo from publishing after a warning. The result is written to public/, which remains ignored by the private source repository.\nCheck the generated site #A successful Hugo command does not guarantee that every generated internal link points to an existing file. I therefore added a small local checker as another publication gate:\n- name: Check generated internal links run: python3 scripts/check-internal-links.py public The script parses the generated HTML and verifies same-site href and src targets against the contents of public/. It does not request external websites or attempt to validate every possible browser resource. Its purpose is narrower: catch missing internal pages and assets before deployment.\nThe checker uses only Python\u0026rsquo;s standard library, so it does not add another package installation step. It is useful here because it operates on Hugo\u0026rsquo;s final output, after content paths, language prefixes, and output formats have been resolved.\nA simpler setup can omit this check. The deployment architecture does not depend on it, but I prefer to stop the workflow rather than knowingly replace a working site with generated output that fails this basic validation.\nPush the generated files to the public repository #After the build and validation succeed, the workflow loads the private deploy key:\n- name: Configure Pages deploy key uses: webfactory/ssh-agent@v0.10.0 with: ssh-private-key: ${{ secrets.GH_PAGES_DEPLOY_KEY }} It can now clone the public repository over SSH:\n- name: Checkout Pages repository run: | git clone --branch main \\ git@github.com:cponfick/gh-pages-blog.git \\ \u0026#34;$GITHUB_WORKSPACE/gh-pages-blog\u0026#34; My complete workflow also handles a repository with no branches, but creating the initial main branch when setting up the public repository keeps the normal deployment path much simpler.\nThe generated output then replaces the contents of the cloned repository:\n- name: Publish generated files run: | rsync --archive --delete \\ --exclude=\u0026#39;.git/\u0026#39; \\ public/ gh-pages-blog/ git -C gh-pages-blog config user.name \u0026#34;github-actions[bot]\u0026#34; git -C gh-pages-blog config user.email \\ \u0026#34;41898282+github-actions[bot]@users.noreply.github.com\u0026#34; git -C gh-pages-blog add --all if git -C gh-pages-blog diff --cached --quiet then echo \u0026#34;No changes to publish\u0026#34; exit 0 fi git -C gh-pages-blog commit -m \u0026#34;Publish blog\u0026#34; git -C gh-pages-blog push origin main The important option is rsync --delete. If I remove a page or asset from the Hugo source, the corresponding generated file must also disappear from the public repository. Without --delete, old files could remain publicly accessible after they were removed from the current site.\nThe .git directory is excluded because it belongs to the deployment repository rather than the generated website. The workflow also avoids empty commits, so each commit in the public repository represents an actual change to the published output.\nConfigure GitHub Pages #In the public repository, open Settings → Pages → Build and deployment and configure:\nSource: Deploy from a branch Branch: main Folder: / (root) This setting matches the workflow. The contents of Hugo\u0026rsquo;s public/ directory are copied directly to the root of main, then an ordinary Git push causes GitHub Pages to publish that branch.\nThe push is made with the SSH deploy key. This detail matters because GitHub documents that commits pushed by a workflow using the repository\u0026rsquo;s GITHUB_TOKEN do not trigger a Pages build. The deploy key provides cross-repository access and makes the update a normal push to the Pages source branch.\nI also keep an empty static/.nojekyll file in the Hugo source. Hugo copies it to the generated root as .nojekyll, which tells branch-based GitHub Pages to serve the existing static output without processing it with Jekyll.\nAdd a custom domain #For my custom domain, the Hugo source contains static/CNAME with the hostname:\nconstantinsblog.eu Hugo copies this file into public/, and the workflow publishes it at the root of the public repository. This ensures that the domain information remains part of every deployment rather than being removed the next time rsync --delete replaces the generated files.\nThe corresponding DNS records must point the domain to GitHub Pages. GitHub\u0026rsquo;s Pages settings show the required records and manage HTTPS after the domain has been verified. The domain itself may still have a registration cost, but the setup does not require a separate virtual machine or web-hosting plan.\nTrade-offs and security boundaries #GitHub also supports artifact-based Pages deployments with actions/upload-pages-artifact and actions/deploy-pages. If the GitHub plan permits Pages deployment from the private source repository, that approach removes the second repository and SSH key.\nI chose the separate public repository because it gives me an explicit boundary between private source and public output. It also works when the website must be published from a public repository. The trade-offs are additional setup, generated commits, and responsibility for protecting the deploy key.\nMost importantly, repository visibility does not protect anything copied into public/. Drafts, notes, metadata, and static files become public if Hugo includes them in the build. I therefore keep drafts marked appropriately and treat the generated directory as something anyone can inspect.\nThe resulting boundary #With this setup, publishing starts with a push to the private repository and ends with GitHub Pages serving a commit from the public repository. The build, validation, and synchronization happen without a deployment command on my laptop and without a server for me to maintain.\nThe useful boundary is not between a private and public website. It is between private source material and public build output. Hugo creates that boundary, GitHub Actions checks it, and the deployment repository contains only the files GitHub Pages needs to serve.\n","date":"6 September 2026","permalink":"https://constantinsblog.eu/posts/private-hugo-blog-github-pages/","section":"Posts","summary":"How I keep a Hugo blog\u0026rsquo;s source in a private repository while publishing the generated site with GitHub Actions and GitHub Pages.","title":"Private Source, Public Website: Deploying a Hugo Blog with GitHub Actions"},{"content":"I run a public ModSecurity and OWASP CRS WAF in front of an IAM-private application container. My goal is to make the WAF the intended public entry point and the only deployed component that holds the origin credential. \u0026ldquo;Private\u0026rdquo; here means that Scaleway requires X-Auth-Token authentication; the generated application endpoint remains reachable at the provider edge.\nThis post documents how I implemented and debugged that architecture with Scaleway Serverless Containers. It is not an argument that every application needs a WAF. The problems described here—host-based routing, upstream TLS verification, and redirect handling—come from putting a credentialed reverse proxy in front of a private origin. This is the final configuration I arrived at after resolving a routing loop, upstream TLS failures, and leaked redirect URLs.\nThere are two distinct names:\nName Example Job Public hostname app.example.com Browser address and redirect destination. Generated upstream hostname private-app.provider.example Routes the WAF request through the serverless ingress to the application. Keeping those roles separate was the key to the whole design. The debugging sequence became: route the upstream request to the right container, verify that connection, then translate browser-facing redirects back to the public domain.\nWhy ModSecurity and the OWASP Core Rule Set #The WAF image uses ModSecurity with the OWASP Core Rule Set (CRS), served by NGINX. ModSecurity supplies the inspection engine; CRS provides maintained, broadly useful rules for common web attack patterns. That is a better starting point than writing a collection of application-specific regular expressions at the proxy.\nI chose this combination because it is open source, portable, and available as an official NGINX container image. It also keeps the concern at the HTTP boundary: the application remains responsible for authentication, authorization, validation, and business rules, while the WAF adds a layer of generic request inspection.\nA rule set is not plug-and-play protection. Start in DetectionOnly mode, review audit logs, add narrow exclusions for understood false positives, and enable blocking only when the observed traffic supports it. Pin the image by version and digest so rule updates are intentional and testable.\nIt is also not a DDoS solution or network isolation. A private Scaleway container still has a provider endpoint, but requests to it require a valid X-Auth-Token. Platform-level rate limiting and volumetric protection are separate concerns.\nFirst, route upstream requests with the generated hostname #My initial proxy configuration explicitly preserved the incoming host, a common choice when an application needs to see its public hostname:\nproxy_set_header Host $http_host; This was an override rather than an NGINX default: the proxy module normally sets the upstream Host to $proxy_host.\nThat is unsafe in this topology. If app.example.com resolves to the WAF, sending that host back through the provider\u0026rsquo;s shared ingress can route the WAF\u0026rsquo;s origin request to itself:\nWAF -\u0026gt; shared ingress -\u0026gt; WAF -\u0026gt; shared ingress -\u0026gt; ... In my case, the loop appeared as the misleading error 400 Request Header Or Cookie Too Large. No browser cookie needed to be large; each pass appended forwarding headers until NGINX rejected the request.\nThe origin request must instead use the generated hostname:\nproxy_set_header Host $proxy_host; When proxy_pass uses the generated application endpoint, $proxy_host is that endpoint\u0026rsquo;s hostname. The serverless ingress can therefore deliver the request to the application container.\nThen, verify the origin certificate, including its chain #The WAF should not disable TLS verification simply because the upstream is a generated provider endpoint. It sends an origin credential, so it must verify it reached the intended origin.\nproxy_ssl_server_name on; proxy_ssl_name $proxy_host; proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; proxy_ssl_verify on; proxy_ssl_verify_depth 3; SNI (proxy_ssl_server_name) and proxy_ssl_name make the TLS request match the generated endpoint. proxy_ssl_verify validates its certificate against the container image\u0026rsquo;s CA bundle.\nThe explicit verification depth is easy to overlook. Managed endpoints can present a leaf certificate followed by several intermediate certificates before a trusted root. NGINX\u0026rsquo;s proxy_ssl_verify_depth defaults to 1, which may reject a valid chain; setting a depth that accommodates the presented chain preserves verification rather than weakening it.\nOnce routing worked, upstream failures still appeared in the browser only as a 502. The proxy error log exposed the actual cause, and these messages pointed in very different directions:\nconnect() failed -\u0026gt; DNS, network, address-family, or firewall issue SSL handshaking failed -\u0026gt; SNI, CA bundle, certificate, or verification-depth issue For containers, writing errors to standard error makes them available in Scaleway Cockpit Logs:\nerror_log /dev/stderr info; Finally, rewrite redirects at the WAF boundary #After fixing routing and TLS, the next symptom was an absolute application redirect using the generated hostname:\nLocation: https://private-app.provider.example/login The browser must not be sent to that internal implementation address. Rewrite that response at the public boundary instead of changing the upstream Host header:\nproxy_redirect https://$proxy_host/ https://${PUBLIC_HOST}/; PUBLIC_HOST should be an explicit, deployment-specific value such as app.example.com, not a reflected request header. That keeps redirect destinations allowlisted and makes the public address deliberate configuration.\nproxy_redirect changes only Location and Refresh response headers. The application still needs its public URL when it generates links in HTML, API responses, emails, or OAuth callbacks. Configure an explicit external base URL in the application where possible. Alternatively, pass the allowlisted public name as X-Forwarded-Host and configure the application to trust that header only from the WAF. Domain-scoped cookies need separate handling, such as application configuration or proxy_cookie_domain.\nA compact configuration #The relevant excerpt from my WAF configuration is below. It omits the surrounding TLS listener, ModSecurity rule configuration, request limits, and other deployment-specific settings.\nIn the official CRS image, save this as an NGINX template, for example /etc/nginx/templates/conf.d/default.conf.template; its startup process expands ${ORIGIN_AUTH_TOKEN} and ${PUBLIC_HOST}. Plain NGINX does not expand environment variables in configuration files without this preprocessing.\nlocation = /livez { access_log off; default_type text/plain; return 200 \u0026#34;ok\\n\u0026#34;; } location / { proxy_pass https://private-app.provider.example; proxy_http_version 1.1; # Scaleway ingress routing proxy_set_header Host $proxy_host; # Request context and WAF-to-origin authentication proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_set_header X-Forwarded-Port 443; proxy_set_header X-Forwarded-Host ${PUBLIC_HOST}; proxy_set_header X-Auth-Token ${ORIGIN_AUTH_TOKEN}; # Authenticated TLS to the origin proxy_ssl_server_name on; proxy_ssl_name $proxy_host; proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; proxy_ssl_verify on; proxy_ssl_verify_depth 3; # Browser-facing URLs proxy_redirect https://$proxy_host/ https://${PUBLIC_HOST}/; proxy_connect_timeout 10s; proxy_read_timeout 120s; proxy_send_timeout 120s; } The origin token is the secret key of a dedicated Scaleway IAM API key. Give its IAM application the ContainersPrivateAccess permission and no unrelated access, using an IAM condition if it must be restricted more narrowly than the project. Store the key in the container platform\u0026rsquo;s secret configuration, never in the image or repository.\nThe short connection timeout and longer read timeout are intentional: waiting can be reasonable after a connection succeeds, for example during a cold start; a connection that cannot be made promptly is normally a different problem.\nSeparate WAF liveness from origin readiness #The WAF and application need different health checks:\nGET /livez -\u0026gt; is the WAF process accepting requests? GET /readyz -\u0026gt; can the WAF reach the authenticated application health endpoint? Use the local /livez endpoint for the WAF container\u0026rsquo;s startup or liveness probe. According to Scaleway\u0026rsquo;s health-check documentation, repeated liveness failures mark the container as errored and stop traffic from being routed to it rather than restarting it. A failed origin must therefore not remove a healthy WAF from service; the WAF is the component needed to inspect the failure.\nUse proxied /readyz in deployment smoke tests. It covers the entire path: WAF, upstream TLS, serverless routing, origin authentication, and application health. A final redirect assertion completes the basic checks:\ncurl -fsS https://app.example.com/livez curl -fsS https://app.example.com/readyz curl -sSI https://app.example.com/ \\ | tr -d \u0026#39;\\r\u0026#39; \\ | grep -Fxi \u0026#39;Location: https://app.example.com/login\u0026#39; The useful mental model is to treat upstream routing, TLS identity, and browser-facing URLs as separate responsibilities. The generated hostname is for Scaleway\u0026rsquo;s ingress, TLS validates that generated endpoint, and the public hostname is explicit application and browser context rather than the upstream routing key. Keeping those concerns apart avoids proxy loops and prevents redirects from leaking the origin URL.\n","date":"5 September 2026","permalink":"https://constantinsblog.eu/posts/serverless-waf-reverse-proxy/","section":"Posts","summary":"How I configured and debugged host-based routing, upstream TLS, and redirects between a public WAF and an IAM-private Scaleway Serverless Container.","title":"Debugging a WAF in Front of Scaleway Serverless Containers"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/nginx/","section":"Tags","summary":"","title":"Nginx"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/reverse-proxy/","section":"Tags","summary":"","title":"Reverse Proxy"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/scaleway/","section":"Tags","summary":"","title":"Scaleway"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/security/","section":"Tags","summary":"","title":"Security"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/serverless/","section":"Tags","summary":"","title":"Serverless"},{"content":"On 15 July 2019 I handed in my bachelor thesis, Berechnung der Minkowski-Summe für einfache Polygone (Calculating the Minkowski Sum of Simple Polygons). It is a fairly mathematical document, but the problem it solves has an appealingly physical interpretation: what shape do you get when you slide one shape over another and collect every position it can reach?\nThis post is the version I wish I had written alongside the thesis. It focuses on the central idea rather than reproducing all of the definitions, proofs, and implementation details.\nThe shape made by adding every point #For two sets of points A and B in the plane, the Minkowski sum is\nA ⊕ B = {a + b | a ∈ A, b ∈ B}\nThe addition is vector addition. Pick one point from each shape, add their coordinates, and the resulting point belongs to the sum. Repeating that for every pair fills the result.\nThat definition is simple, but implementing it is not. There are infinitely many pairs of points, and even the finite set of polygon vertices does not tell us the whole answer by itself. The useful observation is that the boundary carries enough structure to construct the result.\nThis operation appears in places where geometry and algorithms meet:\nIn motion planning, a robot restricted to translation can be replaced by a reference point. For an obstacle O and robot shape R, the forbidden positions of that point form O ⊕ (−R), where −R is the robot reflected about its reference point. In CNC and 3D printing, offset geometry by the tool or nozzle footprint to derive paths for its reference point. In collision detection and image processing, use the same operation as a geometric form of dilation. The easy case: convex polygons #For two convex polygons, there are two natural algorithms.\nThe straightforward one forms every vertex sum vᵢ + wⱼ, then computes the convex hull. With n and m vertices, this creates n · m candidates before the hull algorithm removes the ones that cannot be on the boundary.\nThe better algorithm exploits the fact that the edges of a counterclockwise convex polygon are already cyclically ordered by direction. In the thesis\u0026rsquo;s formulation, start at the lexicographically smallest vertex by x and then y in each polygon, and add these two starting vertices. Then merge the two edge sequences by polar angle. At every step, append the next vertex sum after advancing along the edge with the smaller angle. If both edges point in the same direction, advance in both polygons. The resulting edge is the sum of the two edge vectors. Every input edge is consumed exactly once, so the running time is O(n + m).\nThis is a small but important algorithmic lesson: the geometry gives us an ordering for free. Once we use it, we no longer need to generate and sort all possible vertex pairs.\nWhy concave polygons change everything #Concavity breaks that one-pass walk. The sum of two concave polygons may have indentations, several boundary components in intermediate constructions, and edges that overlap or intersect. A local choice of the next edge is no longer enough to tell us which parts will survive in the final boundary.\nThe main part of my thesis therefore uses the convolution method. Its essence is to construct a deliberately redundant arrangement first, and only then decide which parts belong to the Minkowski sum.\nThe algorithm in three ideas #1. Translate edges by vertices #Assume that both polygon boundaries are oriented counterclockwise. For an oriented edge pᵢpᵢ₊₁ of polygon P and a vertex qⱼ of polygon Q, translate the edge by that vertex. This produces the segment [pᵢ + qⱼ, pᵢ₊₁ + qⱼ]. The segment is included only when the edge direction lies in the angular interval swept by the two incident edges at qⱼ. The construction is repeated symmetrically for every edge of Q and matching vertex of P.\nThe selected edge-vertex translations form the convolution. Its segments join into closed curves, but those curves need not be simple. If at least one input polygon is convex, the thesis\u0026rsquo;s algorithm produces one convolution cycle. Two concave polygons may require several cycles. They are candidates for the answer\u0026rsquo;s boundary and may cross one another or enclose regions that should not be included.\nA single convolution cycle contains at most O(nm) segments. Constructing all required cycles for two concave polygons takes O(n² · m²) time in the algorithm presented in the thesis.\n2. Turn segments into a planar data structure #The segments are split wherever they intersect and stored in a doubly connected edge list (DCEL). A DCEL records vertices, directed half-edges, their twins, the next edge around a face, and the face each edge bounds.\nThis data structure is the bridge between geometry and topology. Instead of asking complicated questions about every point, we can walk around each face of the arrangement and reason about its boundary. The implementation also has to handle coincident or overlapping segments. This unglamorous detail becomes central when coordinates are represented by Java doubles.\n3. Use winding numbers to select the result #The convolution describes more than the Minkowski sum. The final step is a classification problem. For each face, the algorithm computes its winding number: informally, how many times the oriented convolution winds around a point in that face.\nFaces with a positive winding number are the regions belonging to P ⊕ Q. Equivalently, the implementation follows the half-edges beside the unbounded face to obtain the outer boundary, then treats bounded zero-winding faces as holes. A face with winding number two is still part of the result, not a hole. In other words, the algorithm does not try to guess the final boundary while constructing it. It constructs all plausible boundaries, labels the regions, and filters the arrangement using a topological invariant.\nThat separation is the central idea of the thesis:\nGeometry proposes the edges. Topology decides which regions survive.\nWhat made the implementation difficult #The mathematical description is neat. The code is where the edge cases live.\nI implemented and tested the algorithms in Java 8 using the ADSToolbox framework from the University of Bayreuth.\nFrom rounding to explicit comparison rules #The existence of floating-point rounding errors was not surprising. What surprised me was how quickly a tiny numerical difference could become a topological error. If three segments should meet at one point but the calculations produce two almost identical points, the DCEL no longer sees one shared vertex. Half-edges can remain disconnected, faces may not close, and winding numbers can become wrong.\nExact equality between calculated double values is therefore usually the wrong question. The thesis implementation addressed this defensively. It first attempted the calculation without rounding. If the DCEL consistency checks failed, it retried with progressively coarser decimal rounding, down to four decimal places. I also considered BigDecimal, but adapting the surrounding double-based framework was beyond the scope of the thesis.\nI later explored this problem more deliberately in my open source Kotlin Multiplatform library, Komp-Geom. Instead of scattering direct comparisons and arbitrary tolerances throughout the algorithms, the library represents the comparison policy with a DoubleEquivalence interface. Its default implementation considers two values equivalent when\n|a − b| ≤ ε · max(1, |a|, |b|)\nwith a default ε = 10⁻¹⁰. Near zero, this behaves like an absolute tolerance. For larger values, it scales with their magnitude. The same policy provides equality, zero, and ordering operations, and geometric operations can receive a different equivalence when their scale or requirements differ.\nThis is a better design than hiding raw == checks throughout a geometry library, but it is not a universal cure. Approximate equality depends on the scale and purpose of a calculation, and it is not transitive in the mathematical sense. Algorithms that require stronger guarantees may need exact or adaptive geometric predicates. The important lesson for me is that number comparison belongs in the algorithm\u0026rsquo;s design and API, not in a last-minute collection of special cases.\nSome other practical lessons from the thesis have also stayed with me:\nNormalize inputs early. Consistent orientation and removing redundant vertices simplify every later step. Avoid redundant objects. The convolution can grow quickly, so unnecessary allocations and repeated calculations become expensive. Choose data structures for the access pattern. In one part of the algorithm, random access to a sequence is important. A linked list would quietly turn a linear operation into a quadratic one. Test topology, not only coordinates. In addition to hand-computed JUnit cases, the implementation checked that every half-edge had a predecessor and successor, each face boundary was a simple polygon, and every boundary edge implied the same winding number for its face. The convolution method\u0026rsquo;s running time is bounded by O(n² · m²) for two simple polygons. Both the construction of all convolution cycles for two concave inputs and the worst-case DCEL have this bound in the thesis. The experiments also show why the arrangement dominates in practice. In one example, the DCEL contained 5,794 half-edges, while only 27 of them formed the outer boundary of the result. It is not the last word in Minkowski-sum algorithms, but it is a general method for the non-convex case considered in the thesis.\nLooking back #What I like about this project is that the final shape is the visible result of several layers of reasoning: vector addition, edge orientation, planar subdivisions, and winding numbers. None of those ideas is sufficient on its own. Together they turn a continuous geometric definition into a finite algorithm.\nThe obvious next step, which I identified in the thesis, is a reduced convolution. It omits segments generated by reflex vertices when those segments cannot contribute to the boundary. This produces fewer intersections and less bookkeeping, but it also breaks the closed convolution cycles, so winding numbers alone can no longer classify the result. Additional geometric filters are needed instead. Implementing and comparing that trade-off would be a natural follow-up project.\nWriting this down years later also makes the broader lesson clearer: when a direct construction becomes messy, it can be more effective to build a rich intermediate representation and extract the answer from it with the right invariant.\n","date":"10 February 2026","permalink":"https://constantinsblog.eu/posts/minkowski-sum-bachelor-thesis/","section":"Posts","summary":"A practical tour of the algorithm behind my 2019 bachelor thesis: extracting the Minkowski sum of two simple polygons from their convolution.","title":"Adding Shapes: My Bachelor Thesis on Minkowski Sums"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/thesis/","section":"Tags","summary":"","title":"Thesis"},{"content":"On 10 November 2021, I submitted my master\u0026rsquo;s thesis: Implementation, Evaluation, and Comparison of the GLDA Topic Model. At the time, I thought I was writing about a fairly specific piece of natural language processing research. Looking back in the ChatGPT era, it feels more like a small snapshot of the moment when language models were learning to treat words as geometry.\nThe thesis was about Gaussian latent Dirichlet allocation, or GLDA. That name is not exactly designed to attract readers. The underlying question, however, is still a good one:\nCan we discover what a collection of documents is about without being told the topics in advance?\nThat question sits underneath search, recommendation systems, document exploration, and many of the systems we now casually call “AI”. The interesting part is not only whether a model produces useful output. It is how it represents meaning, what it groups together, and what it loses along the way.\nFrom a bag of words to a space of meanings #The classic answer to topic modeling is latent Dirichlet allocation (LDA). A document is treated as a mixture of topics, and a topic is a probability distribution over words. A news article might be 40% politics, 30% economics, and 30% international affairs. The model is not given those labels. It infers them from word usage across the corpus.\nLDA is elegant, but it treats words as discrete identities. The word car is one item in a vocabulary, and automobile is another. Unless their surrounding usage causes the model to connect them indirectly, there is no built-in sense that they are related.\nWord embeddings offered a different intuition. Instead of representing a word as an ID, represent it as a point in a high-dimensional space. Words that occur in similar contexts tend to end up near each other. Meaning becomes something that can be approximated with distances and directions.\nThat was the central change in GLDA: replace LDA\u0026rsquo;s distributions over discrete word types with Gaussian distributions over word-embedding vectors. A topic was no longer just a list of vocabulary items. It became a region in an embedding space, with a mean and a covariance describing its shape.\nThis sounds like a small modification. It is not. Once words become 50- or 100-dimensional vectors, the inference problem becomes much more expensive. Probability mass functions become probability densities. Covariance matrices enter the calculation. Numerical linear algebra becomes part of what used to look like a straightforward topic model.\nThe part where probability meets engineering #The thesis had two practical goals. First, I implemented two inference methods for GLDA, collapsed Gibbs sampling and stochastic variational inference, in the InfoFormalizer framework. Second, I built an experiment pipeline to compare GLDA with a conventional LDA implementation under the same conditions. The reported experiments used the Gibbs sampler. I excluded stochastic variational inference from the evaluation because differences from its reference implementation left me unable to verify its correctness with enough confidence.\nThe framework mattered as much as the model. Comparing machine-learning algorithms is surprisingly easy to do unfairly. Different preprocessing, different corpora, different evaluation tools, or an undocumented parameter can make a result look much better than it really is. I wanted the data preparation, training, metrics, and downstream tasks to live in one reproducible pipeline.\nThe implementation also made the computational trade-off impossible to ignore. LDA was comparatively cheap in these experiments. With count-based topic-word statistics, its common inference operations avoided the full-covariance linear-algebra work required by GLDA. Naive GLDA inference and the stochastic variational approach involved operations such as matrix inversion and determinant evaluation that scaled cubically with the embedding dimension. For the Gibbs sampler used in the experiments, a Cholesky decomposition and the alias sampling trick reduced the relevant complexity, but training remained expensive.\nOn the laptop I used for the experiments, the difference was very concrete: every LDA experiment finished within 15 minutes, while GLDA experiments on the movie-review data with 100-dimensional embeddings took five to ten hours. More semantic information is not free.\nThat lesson has aged well. Modern language models hide enormous computational costs behind a friendly chat box, but the basic question remains: is the extra compute buying us a capability we actually need?\nA topic model can be understandable without being useful #I evaluated the models on three very different corpora:\nBBC News, with recognizable categories such as sport, business, politics, and technology. NIPS papers, a narrower and more technical collection. The Large Movie Review Dataset (ACL IMDB), where the labels describe sentiment rather than subject matter. Try judging a topic yourself #A topic model describes each topic through its highest-ranked words. These are two actual topics from the BBC News experiments. Both models were trained with 20 topics:\nLDA: mr people government party uk told labour work make election GLDA-50: fact reason hoped concerned doubt suggested similarly hoping aware worried Which one tells you more about the corpus? I would label the first topic as UK politics. The second list is internally related, but it is much harder to connect it to a subject covered by BBC News. This does not prove that one model is universally better. It shows why evaluating an unsupervised model requires a decision about what kind of structure matters.\nThere were two ways to judge the topics. The first was qualitative: could a person look at the top words and understand a theme? The second was quantitative: did metrics such as topic coherence or a downstream classifier suggest that the model had found something useful?\nThose answers did not always agree. GLDA often produced human-readable topics on the news corpus, but it also found broad, recurring topics made up of words such as verbs, names, or temporal expressions. Because similar words in embedding space can appear in similar contexts, GLDA sometimes grouped words that were semantically related in a broad sense but not especially informative about the corpus.\nThe difference was particularly visible in the sentiment task. Distributional embeddings can place antonyms close together because they often occur in similar contexts. For example, “the actor was good” and “the actor was bad” have similar grammatical structure. That is useful if the question is about distributional similarity. It is less useful if the question is to separate positive from negative reviews.\nThe classification results made the point more sharply. On BBC News, LDA and the 50-dimensional GLDA model performed similarly. With 60 topics, LDA reached 90% accuracy and GLDA-50 reached 91% in my experiment. On ACL IMDB, however, LDA reached 76.2% while GLDA-50 reached 64.0% at the same topic count. The model that captured more general semantic similarity was not automatically the better feature generator for sentiment.\nThis was one of the most useful conclusions of the work: a model can find structure without finding the structure that your application needs.\nThe timing is hard to ignore #I handed in the thesis before ChatGPT existed as a public product. GPT-3 was already part of the conversation, and word embeddings were well established, but the current LLM era had not yet made language models a daily tool for millions of people.\nSince then, the center of gravity in NLP has moved. Static embeddings such as the GloVe vectors I used assign one vector to a word type. Transformer language models build contextual representations. In English, for example, they can represent the word “bank” differently in “river bank” and “bank account”. Large models can also generate explanations, summaries, labels, and even plausible names for the topics they discover.\nThat progress changes how I think about the thesis, but it does not make the underlying problem obsolete.\nAn LLM can summarize a corpus, but its answer is not necessarily a transparent map of the corpus. It may confidently invent a category, merge distinct themes, or describe the most salient documents rather than the most representative structure. A topic model is much less eloquent, but its assumptions and measurements are easier to inspect. In conventional LDA, you can inspect topic-word distributions. In GLDA, you can inspect the Gaussian topic parameters and the words most representative of each topic, alongside document-topic vectors, preprocessing, and the evaluation pipeline.\nThere is also a useful distinction between representation and explanation. Embeddings help a model organize language in a geometric space. An LLM can turn that organization, or its own internal representation, into a fluent explanation. Fluency is valuable, but it is not the same thing as faithfulness.\nToday I would probably revisit the experiments with contextual document and token representations, stronger baselines, and better hyperparameter tuning. I would also ask people to judge the topics directly instead of assuming that a coherence metric, such as the UMass or cNPMI measures I used, corresponds to human understanding. Those are natural extensions of the thesis\u0026rsquo;s unfinished work.\nWhat stayed with me #The biggest surprise was not that GLDA was slower than LDA. The equations made that fairly predictable. The surprise was how difficult it was to define “better” for an unsupervised model.\nA higher score on a coherence metric does not necessarily mean a more useful topic. A better classifier does not prove that the model discovered the most interesting structure. A topic that appears in many documents might be noise, or it might be the background context that makes the specialized topics meaningful.\nThe experiments also reinforced a principle I still use in software engineering: comparisons are only meaningful when the path to the result is visible. A model name, a benchmark number, or a polished demo is not enough. We need to know what data entered the system, what assumptions were made, what was measured, and where the method fails.\nThat is why I am glad I worked on GLDA before the current wave of LLMs. The project was small compared with today\u0026rsquo;s models, but it forced me to look closely at the machinery behind a claim about language and meaning. It taught me that putting words into a vector space is powerful, but it does not remove ambiguity. It only gives ambiguity a coordinate system.\nAnd sometimes, before asking a model to explain a corpus, it is still worth asking a simpler question first: what structure is actually there?\n","date":"5 January 2026","permalink":"https://constantinsblog.eu/posts/gaussian-lda-master-thesis/","section":"Posts","summary":"A look back at my 2021 master\u0026rsquo;s thesis on Gaussian LDA, word embeddings, and the surprisingly difficult question of whether a topic model has understood anything.","title":"Before ChatGPT: What My Master's Thesis Taught Me About Meaning in Text"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/machine-learning/","section":"Tags","summary":"","title":"Machine Learning"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/natural-language-processing/","section":"Tags","summary":"","title":"Natural Language Processing"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/topic-modeling/","section":"Tags","summary":"","title":"Topic Modeling"},{"content":"What happens when two calculated points are almost, but not quite, equal? In computational geometry, that small difference can change the result of an algorithm. Komp-Geom is where I explore questions like this and build tools for working through them in Kotlin.\nThe project has three parts:\nKomp-Geom is the Kotlin Multiplatform library, with geometric primitives, operations, and algorithms. Komp-Geom Visualizer shows what the algorithms do, making tricky edge cases easier to investigate. Komp-Geom Examples uses small Java programs to show how to work with the library. Unit tests check whether an algorithm returns the expected result. But seeing it work is more fun: here the visualizer shows the intersections found by Bentley–Ottmann in orange. Why I am building it #What interests me about computational geometry is how closely the algorithms and their implementation are connected. A formula alone is not enough if rounding errors change a comparison or an awkward API makes the result hard to use. I want to understand where these problems arise and build a library that feels natural to use from Kotlin.\nEach part helps in a different way: I implement the algorithms in the library, inspect their behavior in the visualizer, and use the examples to see what the API feels like from an application.\nWhat I am working on # Geometric primitives in one, two, and three dimensions Floating-point comparisons that account for small rounding errors Algorithms such as closest pair and convex hull Kotlin Multiplatform APIs with immutable structures and mutable variants for performance-sensitive cases Tests, documentation, and benchmarks across supported platforms Komp-Geom is still in development. I am adding algorithms, working on clearer visualizations, and revisiting the API when the examples reveal something awkward.\nCode and demo # Library\nKomp-Geom Source code, documentation, and tests for the Kotlin Multiplatform library.\nVisualizer\nKomp-Geom Visualizer Browse the source or try the algorithms directly in your browser.\nOpen live demo ↗ Examples\nKomp-Geom Examples Small Java programs that use the library in practice.\n","date":null,"permalink":"https://constantinsblog.eu/projects/komp-geom/","section":"Projects","summary":"Computational geometry you can explore: a Kotlin Multiplatform library, a visualizer, and Java examples.","title":"Komp-Geom"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/tags/personal-projects/","section":"Tags","summary":"","title":"Personal Projects"},{"content":"Not every idea fits in a blog post. Here I share projects I spend more time on and the questions that keep me working on them.\n","date":null,"permalink":"https://constantinsblog.eu/projects/","section":"Projects","summary":"","title":"Projects"},{"content":"I am Constantin, a software engineer based in Hamburg, Germany. This blog is where I document what I learn while building software, working with cloud infrastructure, and exploring ideas outside my day job. I write these things down because useful lessons are easy to forget, and because they might help someone facing a similar problem.\nBackground #I studied computer science at the University of Bayreuth. During my master\u0026rsquo;s program, I focused on machine learning, especially natural language processing.\nAfter completing my degree, I started my career as a backend developer in the IoT space at a machine-building company. This gave me experience with distributed systems, cloud architecture, and operating software beyond a local development environment.\nOver the years, I have worked with technologies across the AWS and Azure ecosystems, including infrastructure as code, containerized applications, and Kubernetes. My experience also includes Spring Boot, relational databases, Docker, Helm, AWS CDK, and Terraform.\nHow I approach software engineering #I care about building software that remains stable, maintainable, and understandable over time. New features matter, but so do predictable deployments, clear code, useful tests, and deliberate technical decisions.\nWhat I write about #Most posts are about software engineering, cloud architecture, and personal projects. I am especially interested in the decisions behind a solution, the alternatives that did not work, and the lessons worth carrying into future work. When I draw on professional experience, I keep it general and do not share details about the projects or systems themselves.\nI also explore AI-supported development. I am not convinced by all the hype around AI, but I do expect it to influence how we write, deploy, and maintain software. I want to understand where these tools genuinely improve engineering work, where they introduce new risks, and how to use them without giving up careful technical judgment.\nThe topics here may expand over time as I find other experiences worth documenting.\nBeyond software #Beyond my professional work, I enjoy watching football and playing basketball. Basketball keeps me active and combines two things I value: teamwork and strategy. Although I am not as good as I once was or would like to be, I still appreciate the challenge and the opportunity to improve.\nElsewhere #I believe software development is an endeavor best shared. If you want to discuss something I have written, exchange experiences, or simply say hello, please reach out.\n","date":null,"permalink":"https://constantinsblog.eu/about/","section":"Software Engineering, Cloud, and AI Notes","summary":"","title":"About Constantin"},{"content":"","date":null,"permalink":"https://constantinsblog.eu/categories/","section":"Categories","summary":"","title":"Categories"}]