<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Rajesh Sampathkumar - All Posts</title>
    <link>https://rajeshrs.in/blog.html</link>
    <description>AI, Aviation, Geopolitics, Philosophy, and more - A polymathic exploration</description>
    <language>en-us</language>
    <lastBuildDate>Thu, 23 Jul 2026 04:43:58 GMT</lastBuildDate>
    <atom:link href="https://rajeshrs.in/feed.xml" rel="self" type="application/rss+xml"/>

    <item>
      <title>What changed in Praval between 0.7.22 and 0.8.1</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-07-19-praval-0.8.1.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-07-19-praval-0.8.1.html</guid>
      <pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate>
      <description>How Praval gained a common model runtime, MCP tools, multimodal input, voice, stronger Agent coordination, and exact-wheel validation while preserving its original design.</description>
      <content:encoded><![CDATA[<p>Praval 0.7.22 had the agent system I wanted. Agents had names and responsibilities, Reef carried Spores between them, and tools, memory, storage, traces, and persisted human approval supported complete workflows. The model layer was less coherent. OpenAI, Anthropic, and Cohere adapters had accumulated their own request handling, streaming, tool execution, and approval logic, so the selected provider affected too much application code.</p>
<p>The 0.8 work corrected that boundary. It added common model contracts, Gemini, OpenAI-compatible servers, MCP tool clients, multimodal input, request-based voice, a separate embedding runtime, stronger lifecycle handling, and a larger set of executable notebooks. Across 320 changed files, the purpose was consistent: make one Agent useful on its own, then let the same Agent participate in a larger system without changing how its model work is executed.</p>
<p>I uploaded 0.8.0 before its matching Git tag and GitHub release were complete, then withdrew it. PyPI does not allow a deleted distribution filename to be reused, so I continued with version 0.8.1. The mistake led to a firmer rule: a release now means one source commit, one wheel, matching documentation, and one release record.</p>
<p>The <a href="https://pravalagents.com/blog/index.html?post=praval-0.8.1">Praval 0.8.1 release notes</a> provide the feature summary, installation command, and links to the documentation.</p>
<h2 id="rebuilding-the-model-execution-path">Rebuilding the model execution path</h2>
<p>I moved shared execution into <code>ModelRuntime</code> and defined its public contracts in <code>praval.models</code>. The runtime builds and validates requests, resolves model capabilities, normalizes responses and streams, executes tools, applies approval, records usage, and creates spans. Provider adapters now translate between that contract and their native APIs.</p>
<p>The direct Agent interface follows this division. <code>chat()</code> still returns a string for existing applications. <code>generate()</code> and <code>agenerate()</code> return a <code>ModelResponse</code> with content, provider, finish, usage, and tool details, while <code>stream()</code> and <code>astream()</code> expose a common sequence of events. The decorated Agent API, <code>start_agents()</code>, Reef, and established imports remain supported, so applications can adopt the richer response and async APIs when they need them.</p>
<p>OpenAI, Anthropic, Cohere, Gemini, and OpenAI-compatible endpoints now sit behind this contract. A capability registry records whether a model supports tools, structured output, reasoning controls, streaming, and media. Praval rejects unsupported requests instead of silently weakening them, and local-server presets begin conservatively because an OpenAI-compatible HTTP shape says little about the model behind it. Structured output remains precise: the provider constrains generation and returns JSON in <code>ModelResponse.content</code>; the application parses and validates it when a local schema is authoritative.</p>
<p>Typed content parts carry images, files, audio, and video where the model supports them. OpenAI-backed Agents add <code>transcribe()</code> and <code>speak()</code> for bounded speech requests, while persistent realtime audio remains a separate future problem. <code>EmbeddingRuntime</code> also stays separate from chat because a vector collection depends on its embedding model and dimensions. It supports SentenceTransformers, OpenAI, Gemini, and compatible endpoints, and reports when a collection must be reindexed.</p>
<h2 id="keeping-tools-approval-and-collaboration-explicit">Keeping tools, approval, and collaboration explicit</h2>
<p>A model can propose an action, but the application must validate and authorize it. Praval now routes decorated tools, shared tools, external JSON schemas, and provider tool calls through one Agent registry. <code>Agent.add_tool_spec()</code> attaches schemas that originate outside Praval without bypassing argument handling, errors, tracing, async rules, or approval.</p>
<p>HITL uses that provider-neutral path. Before a protected handler runs, Praval stores the tool request and interrupted runtime state in SQLite. A reviewer can approve the original arguments, edit them, or reject the action, and another process can resume the continuation. If approval is required and HITL is not configured, execution stops. This gives the model room to prepare an action without giving it the authority to perform that action.</p>
<p>MCP fits the same design. The optional client discovers tools from stdio or Streamable HTTP servers, namespaces them, and registers async handlers with the Agent. Those handlers inherit approval by default, timeouts, size limits, tracing, redaction, and cleanup. The first release handles text and structured tool results. Resources, prompts, hosting, managed OAuth, binary results, automatic reconnect, and synchronous session bridging remain outside its scope.</p>
<p><code>ModelRuntime</code> operates inside an Agent; Reef connects Agents. Spores carry structured messages and correlation data, async handlers can perform network work, and <code>wait_for_completion()</code> replaces fixed sleeps. An in-process Reef serves one application, while RabbitMQ supports Agents in separate processes or machines. This separation keeps deterministic workflows independent of model execution and lets a research or release system assign work to specialists with different evidence and permissions.</p>
<p>Memory, storage, observability, and lifecycle support both single-Agent and team designs. Praval separates short-term, episodic, semantic, and long-term memory, and its async storage layer supports the filesystem, PostgreSQL, Redis, S3-compatible storage, and Qdrant. Spans now finalize before one-time storage and can go to the console, SQLite, or OTLP HTTP. <code>PravalApp</code> closes the Agents and Reef it creates, without pretending to be a general dependency container.</p>
<p>The four capstones test these boundaries as systems. Research specialists gather and challenge evidence, a support team combines policy with customer history, release reviewers run checks in a temporary workspace, and a live marketing studio uses OpenAI, screenshot input, structured assets, a protected claim, persisted approval, and campaign memory. Each case exposes the message trail and application state instead of hiding the framework behind notebook helpers.</p>
<h2 id="testing-the-wheel-developers-install">Testing the wheel developers install</h2>
<p>I did not want the new surface area supported by tests that passed only inside the source tree. The 0.8 cycle fixed the causes of expected failures, made unexpected passes fail, removed broad coverage omissions, added focused coverage floors, and made formatting, linting, typing, documentation, and package checks fatal. Provider contract tests now exercise the same requests, streams, tools, usage, errors, and capability rules across adapters. MCP, Reef, storage, observability, continuation, media, and shutdown paths have their own failure tests.</p>
<p>Every Python demo and notebook is registered with its dependencies, services, provider needs, timeout, and certification mode. The runner creates a clean environment, installs the supplied wheel, clears <code>PYTHONPATH</code>, verifies the package path and wheel hash, and runs outside the repository. Thirteen course notebooks explain the framework in sequence, and four capstones show complete systems rather than isolated calls.</p>
<p>Paid provider checks remain manual because a fake cannot prove that a service accepted media, generated a protected tool request, or returned usable audio. The live voice path sends a fixture through STT, an Agent, TTS, and a second transcription. Other checks cover real streaming, tools, structured output, media, embeddings, and HITL with credentials and model names supplied by the developer. These calls do not run on every push.</p>
<p>Publication now begins with the exact CI wheel. Documentation is built against that installed package with source imports disabled, release evidence stays outside <code>dist/</code>, and GitHub receives the same wheel uploaded to PyPI. That discipline ties the runtime work, examples, documentation, and public artifact to the same code.</p>
<p>Praval 0.8.1 is available on <a href="https://pypi.org/project/praval/0.8.1/">PyPI</a> and in the matching <a href="https://github.com/aiexplorations/praval/releases/tag/v0.8.1">GitHub release</a>. The <a href="https://github.com/aiexplorations/praval/tree/main/examples/notebooks">notebook course</a> shows the framework in execution, and the <a href="https://pravalagents.com/docs/latest/">reference documentation</a> covers the public API.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>praval</category>
      <category>ai-agents</category>
      <category>multi-agent-systems</category>
      <category>model-runtime</category>
      <category>human-in-the-loop</category>
      <category>mcp</category>
      <category>voice-agents</category>
      <category>python</category>
    </item>
    <item>
      <title>Building Vajra 3D Shape Search: Text Queries over Indexed Point Clouds</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-06-30-vajra-3d-shape-search.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-06-30-vajra-3d-shape-search.html</guid>
      <pubDate>Tue, 30 Jun 2026 00:00:00 GMT</pubDate>
      <description>How Vajra 3D searches indexed point-cloud objects from text queries using a closed-source embedding model, Vajra HNSW, BM25 metadata search, RRF fusion, and a Three.js demo.</description>
      <content:encoded><![CDATA[<p>I have been thinking about search as a primitive for a while. Most search systems start with documents. That is natural enough: documents already have words, sections, titles, and a structure that maps easily to lexical search. But a lot of the physical world does not arrive as prose. It arrives as shapes, parts, assemblies, scans, meshes, drawings, and point clouds.</p>
<p>If I have a repository of 3D objects, I do not want to browse it like a filesystem. I want to type something like <code>hex bolt</code>, <code>donut shaped object</code>, <code>flat washer</code>, <code>chair with a back</code>, or <code>gear-like part</code>, and get the appropriate shape records back. I may then want to inspect the result visually, rotate it, compare it to neighbors, or use it as a starting point for a CAD or PLM workflow. Although I have used the first person here to describe the use case, the retrieval process is more common and more of an everyday problem than the less common problem of generating point clouds and 3D objects. It is understandable that a lot of people would want to think about using these models to <em>generate</em> point clouds or other 3D objects, but often, retrieval is the more important problem for most companies, large or small. Ergo, Vajra 3D Shape Search.</p>
<p>And that is an important distinction here. Vajra 3D is not a 3D generator. It does not take text and hallucinate a mesh. It searches a repository of already-indexed 3D shapes. The demo then renders the retrieved objects as point clouds so that the result is easy to inspect in the browser.</p>
<p>That distinction matters, because generation is an act of synthesis - you're taking a latent representation of the user's prompt and trying to generate geometry from it. However, search is an act of retrieval - you're looking to find relevant objects based on text input, from a repository of known 3D objects. For engineering workflows, retrieval is often the safer and more useful operation. If a vendor sends a part, a PLM system has a part number, and a geometry repository has known assets, the first question is not, "Can we invent a part?" The question is, "Can we find the right existing object?"</p>
<h2 id="the-formulation-shift">The Formulation Shift</h2>
<p>The hard part is not drawing a shape. The hard part is retrieving the right indexed shape from language. Behind this simple requirement, there are a lot of interesting ideas on how to bring about text based 3D object retrieval via a search system like Vajra search.</p>
<p>To build Vajra 3D, I framed the problem this way:</p>
<ol>
<li>Each object has a point cloud, which is a collection of (x,y,z) coordinates that together represent a 3D object.</li>
<li>Each object also has labels, aliases, descriptions, and metadata. This is crucial, and Vajra relies on these details.</li>
<li>A model maps point clouds and text into a shared embedding space. <em>Side note:</em> Building the core hypothesis around this model, and using the model in the demo I've built for this post were key work prior to this blog post.</li>
<li>Vajra indexes the object embeddings with HNSW.</li>
<li>Vajra indexes the metadata text with BM25.</li>
<li>Hybrid search combines the two rankings.</li>
<li>The frontend renders the selected result as a point cloud.</li>
</ol>
<p>That gives us something very practical: a search box for a 3D object repository.</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 559px"><img src="/blog/ai-explorations/posts/2026-06-30-vajra-3d-shape-search/images/mermaid/mermaid-01-6e7415f27908.svg" alt="Mermaid diagram 1 for Building Vajra 3D Shape Search: Text Queries over Indexed Point Clouds" width="559" height="774" decoding="async"></div>

<p>The user experiences this as a search field and a viewer. Underneath it, the system is doing both semantic retrieval and lexical retrieval over the same shape repository.</p>
<h2 id="what-the-demo-does">What The Demo Does</h2>
<p>The public demo has two Vajra pages on <a href="/projects/vajra-demo.html">rajeshrs.in</a>:</p>
<ol>
<li><a href="/projects/vajra-demo.html">Vajra Docs Search</a>, which searches a documentation corpus.</li>
<li><a href="/projects/vajra-3d-demo.html">Vajra 3D Shape Search</a>, which searches indexed synthetic 3D shapes.</li>
</ol>
<p>The 3D page is intentionally direct. You type a query, choose a mode, and inspect the results. The selected result is rendered as a point cloud with mouse-based rotation and zoom.</p>
<p>The three modes are:</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th>What it searches</th>
<th>Why it is useful</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>dense</code></td>
<td>Text query embedding against point-cloud embeddings</td>
<td>Finds shapes by semantic similarity in the model space</td>
</tr>
<tr>
<td><code>lexical</code></td>
<td>Labels, aliases, descriptions, and metadata with BM25</td>
<td>Finds exact or near-exact terms like <code>hex bolt</code> or <code>washer</code></td>
</tr>
<tr>
<td><code>hybrid</code></td>
<td>RRF fusion of dense and lexical ranks</td>
<td>Uses both signals, which is usually what a search UI should do</td>
</tr>
</tbody>
</table>
<p>The visual point cloud is not the model output. It is the representation of the retrieved object. That is the right mental model for this demo: search first, visualization second.</p>
<h2 id="building-the-model">Building The Model</h2>
<p>For this first version, I wanted something small enough to train and reason about, but real enough to test the whole retrieval loop. The model is a compact text-to-point-cloud dual encoder. One side consumes text. The other side consumes point clouds. Both sides produce 128-dimensional embeddings in the same space.</p>
<p>The current model was trained on synthetic shape data. The catalog has 148 shape classes across primitive shapes, household-like objects, fasteners, and mechanical components. Each class has text aliases and descriptions. That matters because users do not all type the canonical label. Someone might type <code>torus</code>, <code>ring</code>, or <code>donut shaped object</code>, and the system should still have a chance of landing near the same class.</p>
<p>The point clouds are generated procedurally for the purpose of this demo and these were used to train the model. Of course, a real world fine-tune of this model based on the same architecture, could potentially involve a lot of data engineering, in addition to synthetic data like I've used. For training, the model consumes normalized point clouds. For display, the demo uses a denser point cloud so the browser rendering is easier to see. In the current demo setup:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Current choice</th>
</tr>
</thead>
<tbody>
<tr>
<td>Shape classes</td>
<td>148</td>
</tr>
<tr>
<td>Public demo corpus</td>
<td>296 searchable records</td>
</tr>
<tr>
<td>Embedding dimension</td>
<td>128</td>
</tr>
<tr>
<td>Model point input</td>
<td>1024 normalized points</td>
</tr>
<tr>
<td>Display point cloud</td>
<td>2048 points</td>
</tr>
<tr>
<td>Model status</td>
<td>Closed source at this time</td>
</tr>
</tbody>
</table>
<p>Which brings me to something important. The model details are deliberately not the star of the post. The core idea is the retrieval system around it. Still, the model is doing the key translation: it gives text and shape a shared coordinate system. Without that, text search over geometry would collapse back to keyword matching.</p>
<p>There is another design choice here that I think is important. The model artifact is not public at this time. It is baked into the backend container for the demo, not served as a downloadable file. The reason is simple: this is a showcase of the Vajra 3D retrieval path and the deployed demo, not a model release. A public model release would need a separate model card, dataset statement, evaluation suite, and license decision. I'm not ready with all this quite yet.</p>
<h2 id="building-the-index">Building The Index</h2>
<p>Once the shape repository exists, indexing is straightforward in concept.</p>
<p>For each shape record, the system builds two retrieval views:</p>
<ol>
<li>A dense vector view from the point-cloud embedding.</li>
<li>A lexical document view from metadata text.</li>
</ol>
<p>The dense view goes into Vajra's HNSW index. The lexical view goes into Vajra's BM25 engine. These are separate retrieval channels over the same object IDs.</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 554px"><img src="/blog/ai-explorations/posts/2026-06-30-vajra-3d-shape-search/images/mermaid/mermaid-02-18c5a3e76b6f.svg" alt="Mermaid diagram 2 for Building Vajra 3D Shape Search: Text Queries over Indexed Point Clouds" width="554" height="757" decoding="async"></div>

<p>This split is useful because 3D search has two kinds of user intent.</p>
<p>Sometimes the query is named and precise: <code>hex bolt</code>, <code>flat washer</code>, <code>torus</code>. BM25 is excellent at that. Sometimes the query is descriptive: <code>round object with a hole</code>, <code>long cylindrical fastener</code>, <code>box-like object with a top surface</code>. The embedding model has a better chance there.</p>
<p>Hybrid search is the practical compromise. It lets exact names help when they are present, but it does not require every useful query to match a phrase in the metadata.</p>
<h2 id="how-vajra-works-here">How Vajra Works Here</h2>
<p>Vajra started with lexical search and then grew into vector and hybrid retrieval. I have written about the earlier stages in a few posts:</p>
<ol>
<li><a href="/blog/ai-explorations/2025-12-24-vajra-bm25.html">Vajra BM25: Building a Search Engine with Category Theory</a></li>
<li><a href="/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html">Re-engineering Vajra's vector search with inspiration from ZVec</a></li>
<li><a href="/blog/ai-explorations/2026-02-23-vajra-rust-architecture-benchmarks.html">From Vajra v0.4.1 to v0.5.0 to Rust backend</a></li>
<li><a href="/blog/ai-explorations/2026-03-04-vajra-rust-v021-build-improvements.html">Vajra Search with a Rust backend (v0.2.1)</a></li>
</ol>
<p>The 3D demo uses the same retrieval philosophy, but the object being searched is different. Instead of document chunks, the result is a shape record.</p>
<p>At a high level:</p>
<ul>
<li><strong>BM25</strong> scores how well the query terms match the object's text metadata.</li>
<li><strong>HNSW</strong> searches nearby object vectors in the embedding space.</li>
<li><strong>RRF</strong> combines the rank lists without pretending BM25 scores and vector distances are naturally calibrated.</li>
</ul>
<p>That last point is easy to miss. BM25 scores and cosine similarities are not the same kind of number. Reciprocal Rank Fusion avoids forcing them into one artificial scale. It asks a more stable question: which objects are ranked highly by one or both retrieval systems?</p>
<p>This is why Vajra is a good fit for the 3D problem. The search engine does not need to know that an embedding came from a point cloud. It needs a vector, an ID, metadata, and a ranking strategy. The model handles the modality bridge. Vajra handles retrieval.</p>
<h2 id="deployment">Deployment</h2>
<p>The deployed demo is intentionally small. The site is static and hosted through Netlify. The Vajra backend runs in a Railway container. The same backend now serves both the documentation search API and the 3D search API.</p>
<p>The public 3D corpus is capped at 296 records: two deterministic variants for each of the 148 classes. That is enough to demonstrate the search mechanics without turning the container into a data warehouse. The object embeddings and display point clouds are precomputed, so the runtime does not need to generate training data or run training jobs.</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 1275px"><img src="/blog/ai-explorations/posts/2026-06-30-vajra-3d-shape-search/images/mermaid/mermaid-03-47f7cedd2612.svg" alt="Mermaid diagram 3 for Building Vajra 3D Shape Search: Text Queries over Indexed Point Clouds" width="1275" height="271" decoding="async"></div>

<p>This is also why I did not use Hugging Face for the model in this demo. Hugging Face is useful when the goal is model distribution. Here, the goal is to showcase a protected application behavior. The model is an implementation detail inside the backend container.</p>
<p>That does not make the model magically impossible to copy. Any deployed software has a threat model. But it does mean the demo does not publish the artifact, does not expose embeddings through the API, and does not provide a download path. The public surface is search results and capped preview point clouds.</p>
<h2 id="what-i-learned">What I Learned</h2>
<p>The most useful lesson is similar to what I saw while building <a href="/blog/ai-explorations/2026-01-04-vidai-teaching-machines-arithmetic.html">Vidai</a>: the formulation matters more than the model size.</p>
<p>With Vidai, the key move was to stop asking the neural network to do arithmetic. The neural network only had to parse mathematical notation; exact computation could be delegated to symbolic code.</p>
<p>With Vajra 3D, the key move is to stop asking the model to be the whole search engine. The model only has to map text and shape into a useful shared space. Retrieval, fusion, metadata, serving, security, and visualization are separate engineering problems.</p>
<p>Perhaps these lessons showcase something vital about the purposeful use of AI: problem framing and searching the problem space helps us figure out where to use specific, simpler-to-build capabilities. We don't need throw big models and agents at all problems.</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>Common framing</th>
<th>Inversion of framing</th>
</tr>
</thead>
<tbody>
<tr>
<td>Text to 3D</td>
<td>Generate a shape from text</td>
<td>Retrieve existing indexed shape records</td>
</tr>
<tr>
<td>3D model</td>
<td>Build the entire product around the model</td>
<td>Use the model to produce embeddings and use the embeddings instead</td>
</tr>
<tr>
<td>Ranking</td>
<td>Trust one score</td>
<td>Fuse lexical and dense rank lists</td>
</tr>
</tbody>
</table>
<p>That separation gives the system room to grow. A better point-cloud model can replace the current one. A CAD ingestion pipeline can replace synthetic generation. More metadata can enrich BM25. Vajra's HNSW and hybrid retrieval path stays the spine of the application. These are perhaps some current/future targets for this work - more on that below.</p>
<h2 id="what-comes-next">What Comes Next</h2>
<p>This version is a proof of the pipeline, not the end state.</p>
<p>One next interesting step is ingestion of real 3D assets. Someone who wants to use Vajra may bring STEP, IGES, STL, GLB, OBJ, or point-cloud files. The pipeline should normalize those assets, sample point clouds, attach PLM metadata, run the embedding model, and index the resulting records in Vajra.</p>
<p>That would move the system from synthetic demonstration to industrial retrieval.</p>
<p>There are several open directions:</p>
<ul>
<li>CAD and B-rep aware ingestion for STEP and IGES.</li>
<li>Better point-cloud models, possibly distilled from stronger open 3D-language systems.</li>
<li>Larger shape corpora with meaningful intra-class variation.</li>
<li>Search evaluation against real user queries.</li>
<li>PLM-aware metadata ranking for part numbers, materials, suppliers, and assemblies.</li>
<li>Similarity search from a query point cloud, not just from text.</li>
</ul>
<p>The current demo is deliberately smaller than all of that, it was literally built this weekend over a few hours of spare time I had. But it proves the important thing first: text can retrieve indexed 3D objects, and Vajra can sit underneath that retrieval loop. And there's a lot possible in this (3D) space!</p>
<h2 id="try-it">Try It</h2>
<p>You can try the Vajra 3D search demo here:</p>
<ul>
<li><a href="/projects/vajra-3d-demo.html">Vajra 3D Shape Search</a></li>
</ul>
<p>Note that there is also a regular search demo on this site for documentation. This demonstrates Vajra Search's basic capabilities (lexical, vector, hybrid search).</p>
<p>The Python package for the search engine is here:</p>
<ul>
<li><a href="https://pypi.org/project/vajra-search/">vajra-search on PyPI</a></li>
</ul>
<p>The 3D embedding model itself is closed source at this time. The public demo exposes the search behavior, not the model artifact.</p>
<h2 id="related-vajra-posts">Related Vajra Posts</h2>
<p>If you want the background for the search engine side, these are the relevant earlier posts:</p>
<ol>
<li><a href="/blog/ai-explorations/2025-12-24-vajra-bm25.html">Vajra BM25: Building a Search Engine with Category Theory</a></li>
<li><a href="/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html">Re-engineering Vajra's vector search with inspiration from ZVec</a></li>
<li><a href="/blog/ai-explorations/2026-02-23-vajra-rust-architecture-benchmarks.html">From Vajra v0.4.1 to v0.5.0 to Rust backend</a></li>
<li><a href="/blog/ai-explorations/2026-03-04-vajra-rust-v021-build-improvements.html">Vajra Search with a Rust backend (v0.2.1)</a></li>
</ol>
<p>Vajra 3D is the same search philosophy pointed at a different kind of object. Documents were the first corpus. Shapes are the next one, and perhaps this bridges the bits-to-atoms gap somewhat. See you in my next post!</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>vajra</category>
      <category>vajra-3d</category>
      <category>3d-search</category>
      <category>point-clouds</category>
      <category>embeddings</category>
      <category>hnsw</category>
      <category>bm25</category>
      <category>hybrid-search</category>
      <category>threejs</category>
      <category>railway</category>
    </item>
    <item>
      <title>The Realities of AI, April 2026</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-04-07-realities-of-ai-in-2026.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-04-07-realities-of-ai-in-2026.html</guid>
      <pubDate>Tue, 07 Apr 2026 00:00:00 GMT</pubDate>
      <description></description>
      <content:encoded><![CDATA[<p>There is a lot of noise in the present state of AI discourse. Some of it comes from legitimate technical excitement about the current state of AI systems. Be it coding or image/video generation or anything else. Some of it comes from venture capital, public markets, product marketing, and the prestige incentives of major AI labs. We have seen AI labs make stupid and exaggerated claims, and the leaders of companies like NVidia and OpenAI push unsubstantiated metrics about how much you should use AI, or how you should use it. Then there is the war in West Asia which is the latest in a series of global conflagrations since the early 2020s that is contributing various risks not just to society but also specifically to how AI is built, deployed, and used. The result is that discussion of AI often swings between breathless utopianism or crazy dystopia on the one hand, and shallow dismissal on the other hand. Neither is helpful to the general audience of practitioners, users and common folk who want to understand what this is all about, and use AI as a tool to get things done, and ultimately thrive as a result of using it. The current state of AI is more interesting than both of these extremes, and at the same time it can be shaped by policy, people, money and resources and how they're being used. It is powerful, but narrow in important ways. It is transformative, but dependent on an enormous industrial and computational base. It is impressive, but also brittle.</p>
<h1 id="paradigms-and-abstractions">Paradigms and Abstractions</h1>
<p>One reality is that large language models are still fundamentally predictive systems. They generate outputs by modeling patterns in data and selecting likely continuations under a learned distribution. Calling them “reasoning models” or “multimodal systems” does not erase this underlying fact. They do not possess meaning in the human sense. They do not understand in the way conscious beings understand. They do not have grounded intentionality, lived experience, or an intrinsic sense of what their symbols refer to. They are extraordinarily capable statistical systems operating over representations. This matters, because much of the confusion in public discourse comes from anthropomorphic language. If we describe these systems as though they think, know, want, or understand in the same way human beings do, we smuggle in assumptions that the underlying machinery does not justify.</p>
<p>A second reality is that AI, in its current paradigm, is software sitting atop a very hard physical substrate. It is not magic. It is not abstract intelligence floating free of material constraints. It depends on compute, memory, storage, networking, electrical power, cooling, and specialized chips. It depends on the design and manufacture of CPUs, GPUs, TPUs, high-bandwidth memory, interconnects, servers, data centers, and power infrastructure. It depends on firmware, drivers, kernels, compilers, distributed systems, model serving frameworks, observability stacks, and orchestration software. Behind every “intelligent” model is a dense pyramid of engineering and industrial effort.</p>
<p>Another important reality is that model training alone does not solve business problems. Pre-training, post-training, instruction-tuning, RLHF, constitutional methods, and alignment layers may produce models that are more usable, safer, or more polished, but none of this automatically yields a system that can do useful work in a real organizational setting. To solve actual problems, models need <em>harnesses</em> around them. They need retrieval, memory, task decomposition, orchestration, tools, APIs, permissions, guardrails, evaluators, routing logic, fallback logic, caching, monitoring, and user interface design. Much of what is sold as “AI” in practice is not the model alone but the surrounding software system that turns raw capability into usable work.</p>
<h1 id="the-ai-supply-chain">The AI Supply Chain</h1>
<p>That leads to another reality: AI has a long and fragile supply chain. The AI stack begins far below the application layer. It starts with mining, materials, refining, semiconductor equipment, wafer fabrication, packaging, energy systems, logistics, and geopolitical stability. The concentration of advanced chip manufacturing in Taiwan and the dependencies on TSMC create critical vulnerabilities in this supply chain. Above that sit chip design firms, foundries, cloud providers, networking vendors, model labs, open-source communities, data pipelines, storage systems, and inference platforms. Then come application developers, integrators, and domain teams building systems that ordinary users actually touch. This means that AI is not only a software story or a model story. It is also a manufacturing story, an energy story, a capital expenditure story, and a geopolitical story.</p>
<p>This is why <em>the present AI landscape is better understood as a systems landscape rather than purely a model landscape</em>. The raw model matters, but the surrounding architecture matters just as much. The data matters, and deployment matters too. The supply chain of power, GPUs, compute, ASIC and other pieces all end up mattering in due course, if you're building a product that touches customers. A mediocre deployment of a frontier model can be less useful than a well-designed system using a smaller model, better prompting, good retrieval, narrow task boundaries, and strong operational logic. In many domains, intelligence in practice is emerging less from one giant leap in the model than from careful engineering of the whole pipeline around it. Policy around the use of AI matters and region-specific policies are common these days. And without a doubt, as of April 2026, an errant missile in the Middle East can put paid to your entire company if you don't have cross-region availability of your AI product.  </p>
<h1 id="compute-constraints-in-ai">Compute Constraints in AI</h1>
<p>A further reality is that current AI systems remain heavily compute-constrained. This is true not only because training frontier models requires a huge amount of compute, following scaling laws established by Kaplan et al. and refined by Hoffmann et al.'s Chinchilla research, in the form of large NVidia server farms, but because serving them at scale, aka <em>inference engineering</em>, is computationally and money-wise expensive too. Inference is not free, and latency and concurrency matter for a good user experience. Concurrency, longer context windows, tool use, reasoning capabilities... all these need extensive compute to innovate on. Even the largest AI labs face hard trade-offs around pricing, rate limits, model availability, and product design because compute remains a binding constraint. In the last few days as of early April 2026, there's a running joke on X, about how Claude Opus is pushing back on executing repetitive tasks, through some kind of weird alignment. It identifies whether there are repetitive tasks that are embarrassingly simple and tells the user to manually complete them. Not only is this the definition of pushing the tedium back to users, but it tells us what the priorities are, for those at Anthropic. Recently, when Dario Amodei was on Dwarkesh Patel's podcast (March 2026), he mentioned that it is hard for Anthropic to manage the economics of inference even if he fell marginally short of their sales targets. Compute is expensive, and the big AI labs are feeling the pinch. It is therefore misleading to speak as though AI capability unfolds in a frictionless digital realm. It unfolds under very real economic and infrastructural bottlenecks.</p>
<p>There is also a reality that many people miss: test-time compute is becoming part of the capability story. The performance of a system is no longer just a property of the base model’s weights. It increasingly depends on how much computation is spent at inference time on search, deliberation, self-correction, tool calls, branching, reranking, verification, or multi-sample generation. In other words, capability is no longer simply “what the model is,” but also “how the system uses the model.” This makes comparisons murkier in the benchmark comparisons we so often do, which has been the internet's AI horse race for a few years and which few people actually rely on. This also explains why some apparently small models or modest systems can do surprisingly well when embedded in better workflows. This is also a big reason why harness engineering is so important these days. Harnesses can make or break a model release, because the harness and the agents are what tap the model's potential, whatever the ARC-AGI or other benchmarks say.</p>
<p>Another reality is that quantization, distillation, fine-tuning, pruning, adapter methods, and inference optimizations have changed the practical landscape. It is no longer reasonable to think only in terms of giant models crushing everything else. Small models can be made efficient, cheap, and useful, especially for narrow domains. The 2025-2026 releases of Granite models from IBM/Red Hat,<a href="#ref-4">[4]</a> Qwen models from Alibaba,<a href="#ref-5">[5]</a> and Google's Gemma family<a href="#ref-6">[6]</a> demonstrate this trend compellingly. With the right data, the right objective, and the right task framing, these smaller models can outperform a much larger one on a specific problem, whether this is text, code or images - because these are also multi-modal models! This matters commercially and strategically. It means that the future is not necessarily one in which only a few gigantic general models dominate every use case. There is room for specialization, efficiency, sovereignty, and targeted optimization, even if you assume some amount of basic capability that's shared across proprietary and open source models.</p>
<h1 id="the-agi-pipedream">The AGI Pipedream</h1>
<p>There is still no good reason to casually declare the arrival of AGI. “Artificial General Intelligence” is often used as though its meaning were obvious, but it is not.<a href="#ref-12">[12]</a><a href="#ref-13">[13]</a> It is a vague and overloaded term. Sometimes it means human-level performance across a broad array of tasks. Sometimes it means autonomy. Sometimes it means economic substitution and sometimes it means recursive self-improvement. Sometimes it means a machine mind comparable to a person, or which anthropomorphic qualities. These are not the same thing and even together don't constitute a coherent definition. Current systems are undeniably powerful, but they remain bounded by their architectures, their data, their interfaces, and their lack of grounded agency.<a href="#ref-14">[14]</a> They can appear startlingly general in language-rich settings while still failing in basic ways outside those settings, such as with out of distribution data.<a href="#ref-15">[15]</a> That is not AGI in any clear scientific sense - in fact I don't even know what AGI is, in a clear scientific sense, and this is perhaps the problem.</p>
<p>It is also true that present-day models remain highly dependent on exposure. They perform better in domains, languages, styles, and problem formats that are well represented in their training and post-training regimes. We've had the low-resource language problem for a while, which Sarvam cracked with their extensive data pipelines and how they trained their models. LLMs in general perform worse under distribution shift, when they're exposed to languages that rarely occur or don't occur in their data, unusual symbolic systems, novel workflows, niche data regimes, or domains where the ground truth is sparse and poorly captured in text. This problem has been extensively studied with low-resource languages, with organizations like Sarvam AI developing comprehensive data pipelines to address these gaps.<a href="#ref-2">[2]</a> A human being can, as of 2026, invent a symbolic scheme or a new language with its own grammar, symbols, phonemes and syllables and the like, attach meaning to it all, adapt socially to it with others who they can convince to use the language, and stay insulated from AI as they use this language - and I mean this both in a good way (AI cannot interface with them) and a bad way (literally the same reason, that AI cannot interface with them).  When this new language is used to converse with an AI system, the system will barely understand the human in question, and will respond with gibberish, for understandable reasons. This is because meaning is created in a human's mind as of 2026, but not in the internal representation of an LLM (despite what I think the Grokking paper says,<a href="#ref-3">[3]</a> I don't think it implies that the models <em>understands</em> anything). An LLM's apparent flexibility has real limits, and those limits become visible when the task departs from the structure and content of its training data.</p>
<p>Benchmark performance creates another distortion. We know what Dieselgate did to Volkswagen a few years ago - car ECUs that knew they were being tested produced different emissions compared to cars on the road.<a href="#ref-1">[1]</a> Similarly, models often look better in curated evaluations than they do in production environments. Benchmarks are useful, but they are still simplified abstractions. Real environments contain interruptions, malformed inputs, missing context, bad tooling, contradictory instructions, edge cases, policy constraints, stale data, and long-tail user behavior. A system that aces a benchmark can still fail embarrassingly in the real world. This is one reason why so many organizations discover a gap between AI demos and AI deployment. The latter is harsher, messier, and less forgiving. Further more, "benchmaxxing" is a real problem in AI. We see a lot of labs including the big ones using the benchmarks not as an honest representation of the model's performance post-hoc, but deliberately training models to improve performance on benchmarks.</p>
<h1 id="ai-autarky-local-and-sovereign-ai">AI Autarky - Local and Sovereign AI</h1>
<p>Then there is the reality of local AI. Many organizations and individuals do not want their core workflows, proprietary data, or strategic capabilities to depend entirely on a handful of external vendors. Self-hosted, on-prem, edge, and privately controlled deployments matter for reasons of privacy, cost control, customization, compliance, resilience, and independence. “Local AI” is therefore not merely a hobbyist phenomenon. It is also a strategic response to centralization. It reflects a desire to retain control over models, infrastructure, data, and operational behavior. This may not eliminate dependence entirely, especially when hardware and some software layers remain externally sourced, but it does change the locus of control. </p>
<p>Sovereign AI is related and yet distinct from Local AI - Sovereign AI allows countries and aggregations of states to practice AI autarky. Think Sarvam AI, Mistral AI, or open source models, in the context of the use of Claude by the US military. Being beholden to the big labs in times of crisis presents supply chain risks. In the recent war in West Asia, we saw reporting on the use of Claude models by Anthropic embedded within Palantir systems for battlefield intelligence and target proposal workflows.<a href="#ref-9">[9]</a> Public reporting also highlights that strike-level attribution of responsibility remains difficult to verify independently. Needless to say, sovereign AI does not prevent such a problem, and may exacerbate the use of AI for defence purposes. However, it gives conscientious objectors who don't mean their data to be used to embolden such actors the option out. It gives them the means to avoid tools that are used for war. We need not use the same tools that are being used for purposes we don't believe in, if that is useful. The impact of this is not surfacic, but systemic, because with every use of non-sovereign AI, we risk having data, context and tools we have developed for our workflows being used against us.</p>
<h1 id="the-multi-modal-reality-of-ai">The Multi-Modal Reality of AI</h1>
<p>Another reality is that the AI world is no longer just about text. Vision, audio, speech, video, and multimodal models are moving rapidly, and image and video generation have become materially useful in design, marketing, entertainment, prototyping, and content production. But the same capability also creates obvious avenues for misuse. Deepfakes, impersonation, synthetic propaganda, forged evidence, mass-produced disinformation, and emotionally manipulative media are not side issues. They are direct consequences of capability growth in generative systems. The frontier of creative synthesis is therefore inseparable from the frontier of authenticity collapse.</p>
<p>Whether in the context of the war in West Asia or elsewhere on social media, we're seeing an increased use of AI to generate images, videos and even deep-fake images and videos, to throw ordinary citizens off from the truth. Simultaneously, vision models are used for drone warfare on the battlefield.<a href="#ref-10">[10]</a> Models are being used to auto-detect incoming drones, or even soldiers on the battlefield, and through sensor fusion, friend/foe identification algorithms and on-device vision models, autonomous drones are being used to target soldiers and equipment on the battlefield. Despite the bulk of drones being manually flown and guided as of April 2026, there is an increasing number of drones that are autonomous, and this is a cause for concern.</p>
<p>On the positive front, a lot of engineering advances may be anticipated by multi-modal AI. Multi-modality even in the current context could be extended to the likes of point-cloud models or neural radiance fields. This enables applications of greater complexity and real world utility, such as in design, computer aided engineering, digital mock-ups and digital visualization as a precursor to engineering products and systems.</p>
<h1 id="economics-of-ai-and-agents">Economics of AI and Agents</h1>
<p>One more reality is that the economics of AI are awkward. The public imagination often assumes that once a model exists, value simply pours out of it. In practice, value capture is uneven. Some firms spend vast sums on training and infrastructure while downstream application companies, consultants, cloud vendors, or hardware providers capture large portions of the economic return. They're able to articulate their use cases, and their data and application architecture lends itself well to value capture from AI. In some cases, open-source models compress margins further, and fine-tuning and other similar advantages come to the fore. In some cases, the best model does not win because distribution, workflow fit, trust, and product integration matter more than abstract intelligence. This means AI is not just a technical competition. It is a contest over capital intensity, distribution, defaults, workflow control, and enterprise fit. </p>
<p>The present agent wave also needs sober interpretation. Agents are real in the sense that systems can now take multi-step actions, call tools, generate and run code, inspect outputs, retry, plan, and pursue goals within bounded environments. That is meaningful progress. But much of what is marketed as autonomous agency is still structured software operating within carefully defined rails, but with an LLM call, and however complex the tool workflows look and however complex the prompts are, it bears repeating these systems are not true intelligence. These systems are often less like independent minds and more like workflow engines with flexible language interfaces. Their usefulness is substantial, but their mythology often outruns their reality.</p>
<p>I am compelled to discuss OpenClaw and similar ecosystems given how popular they are today.<a href="#ref-11">[11]</a> Such systems show that large language models become much more operationally potent once combined with memory, code execution, tool use, application synthesis, and multimodal interfaces. Messaging apps, browsers, local runtimes, and common software affordances become substrates for practical action. You can be on a walk, and send a message to your Claw on Telegram, or be reminded by it about a task, or have it pick up something new and interesting. All these conveniences notwithstanding, we are not dealing with a true autonomous intelligence. There is a popular narrative on the internet that OpenClaw somehow represents AGI, but this obviously isn't true, because these are not capable of imagination and the ability to deal with things outside their training data as humans are.<a href="#ref-16">[16]</a> This lack of compositional and systematic generalization is a fundamental limitation distinguishing current systems from genuine general intelligence. OpenClaw is important because language models, when embedded in richer control loops, can act in ways that have more agency and are therefore more useful. The leap is important, but it is still a computational systems leap rather than a metaphysical one.</p>
<h1 id="ais-fear-psychosis">AI's Fear Psychosis</h1>
<p>One stark reality that seems to have arisen from the user base of AI coding agents, is the fear psychosis that has replaced optimism across large parts of the technosphere. We've seen large scale job losses recently, with Meta announcing about a 5% workforce reduction in January 2025 and Oracle announcing additional reductions through early 2026,<a href="#ref-7">[7]</a> being among the biggest offenders in the last few months. This seems to be just the beginning. Claude is shipping at a furious pace, and Copilot and Microsoft in general are going through a rough patch, where the bulk of office application software used by white collar workers are subject to disruption today. The integration of AI capabilities into Claude via Cowork, and fantastic plugins for Office and the like, have increased the odds of job losses in many professionals such as legal, writing, media, and the like. It goes without saying that the code sphere is the first to see tangible replacement of humans with AI. Perhaps it is no surprise that this is a space that AI is closest to, because the systems that AI models interface with most closely are likely to be the first to be automated wholesale by AI. It is perhaps also no surprise that formally verifiable systems are among the first to be automated - because in this specific domain, results can be made objective, and the outputs from AI models can be evaluated in specific ways, with clear metrics. </p>
<p>Despite the intellectual wrangling, beauty and sophistication of the human minds that build software, the tangible output produced through programming is very, very formally verifiable. This paradox perhaps always sat with humans who have programmed computers for decades as something that seemed to indicate that computing is alien to our minds, even if it wasn't so in reality. It follows then that there is a weird psychosis that is associated with the use of AI for knowledge work. Everyone feels like they're generating training data as they work, that they're building on top of the tall scaffolding built by AI systems, or that they're partaking in a ceremony, in a charade, without delivering actual value. This puts humans in a state of fear about their eventual replacement by AI systems which may perform specific tasks tirelessly and more efficiently than humans. Conversations with reputed engineers such as Simon Willison and others often bring up this notion of being "mentally spent" when using AI coding tools, just because of the sheer intensity of developing applications with AI coding agents.<a href="#ref-8">[8]</a> Formal verification aside, there's the question of utility too, and yes, we can scratch an itch with a vibe coded app, but can it become truly useful?</p>
<p>More here on this topic: https://x.com/aiexplorations/status/2040156695989821616 </p>
<h1 id="ai-and-human-judgement">AI and Human Judgement</h1>
<p>Another reality worth stating quite plainly is that the current AI wave has not removed the need for human judgment, and in some ways it actually increases it. Humans are needed to frame objectives, define acceptable behavior, evaluate outputs, resolve ambiguity, choose trade-offs, interpret downstream consequences, and decide where automation is appropriate and where it is dangerous. Humans are required to tell apart results that <em>look</em> like they're useful but which are not. Just as some trained humans can tell deep fakes apart even as of April 2026 (but we've to admit that this is becoming less and less possible to most of us, certainly I struggle with this stuff). AI can compress the effort needed to do something in many areas, but it also raises the premium on judgment, domain understanding, verification, and system design. The fantasy that AI eliminates human responsibility is not only wrong; it is risky.</p>
<p>Finally, perhaps the most important reality is that current AI is simultaneously overhyped and underappreciated. It is overhyped when people speak as though language models are conscious minds, imminent gods, or inevitable replacements for all skilled labor. It is underappreciated when critics dismiss them as mere autocomplete and fail to see what large-scale predictive systems plus tool use, memory, retrieval, and orchestration can already do. The truth is more demanding. AI today is neither magic nor trivial. It is a new layer of computational capability with real power, real limitations, and real dependence on the industrial world beneath it.</p>
<p>That is the state of the field as it stands: powerful prediction systems, embedded in vast physical and software infrastructures, increasingly useful when wrapped in the right harnesses, still far from anything that should casually be called general intelligence, and already consequential enough that economics, politics, security, law, labor, and culture are all being reshaped around them.</p>
<h1 id="references">References</h1>
<ol>
<li id="ref-1"><strong>Dieselgate Scandal</strong>: Creutzig, F., et al. "Real-world emissions of conventional and plug-in hybrid electric cars." <em>Nature Energy</em>, 2021. Also see: <a href="https://en.wikipedia.org/wiki/Volkswagen_emissions_scandal">Volkswagen emissions scandal</a> Wikipedia, accessed 2026. <br /></li>
<li id="ref-2"><strong>Sarvam AI and Low-Resource Languages</strong>: Sarvam AI. <a href="https://www.sarvam.ai/">Sarvam Model Releases and Language Coverage</a>. Their 2024-2025 work on comprehensive multilingual NLP pipelines demonstrated approaches to low-resource language modeling.<br /></li>
<li id="ref-3"><strong>Grokking Paper</strong>: Power, A., Burda, Y., Edwards, H., Babuschkin, I., &amp; Misra, V. "Grokking: Generalization Beyond Overfitting on Small Algorithmic Datasets." <em>arXiv preprint arXiv:2201.02177</em>, 2022.<br /></li>
<li id="ref-4"><strong>IBM/Red Hat Granite Models</strong>: IBM Research. <a href="https://github.com/ibm-granite">Granite Model Series</a>. Open Granite model releases began in 2024 and expanded through 2025-2026.<br /></li>
<li id="ref-5"><strong>Alibaba Qwen Models</strong>: Alibaba Cloud. <a href="https://huggingface.co/Qwen">Qwen Model Family</a>. Series of multilingual, multimodal models released through 2025-2026.<br /></li>
<li id="ref-6"><strong>Google Gemma</strong>: Google DeepMind. <a href="https://ai.google.dev/gemma">Gemma: Open Models Based on Gemini Research and Technology</a>. Released open-weight efficient models in 2024-2025.<br /></li>
<li id="ref-7"><strong>Meta and Oracle Layoffs</strong>: Reporting from Reuters, CNBC, and TechCrunch on Meta's January 2025 ~5% workforce reduction and subsequent Oracle-related reductions through early 2026. See also company disclosures and earnings commentary.<br /></li>
<li id="ref-8"><strong>Simon Willison on AI Development Intensity</strong>: Willison, S. <a href="https://simonwillison.net/2026/Apr/2/lennys-podcast/">The Challenges of Building with AI Agents</a> personal blog and social media posts, 2025-2026. <br /></li>
<li id="ref-9"><strong>Claude and Palantir in Warfare</strong>: Moneycontrol. <a href="https://www.moneycontrol.com/world/how-palantir-and-anthropic-ai-helped-the-us-hit-1-000-iran-targets-in-24-hours-article-13853331.html">How Palantir and Anthropic AI helped the US hit 1,000 Iran targets in 24 hours</a> 2026.<br /></li>
<li id="ref-10"><strong>Vision Models in Drone Warfare</strong>: Various reporting on Ukraine and Middle East conflicts from 2024-2026. See: MIT Technology Review, "AI-Powered Drones Transform Modern Warfare," and reporting from conflict zones on autonomous systems deployment.<br /></li>
<li id="ref-11"><strong>OpenClaw and Agent Ecosystems</strong>: X/Twitter post from @aiexplorations on the widespread adoption of agent-based systems and their integration with consumer platforms (April 2026). <a href="https://x.com/aiexplorations/status/2040156695989821616">https://x.com/aiexplorations/status/2040156695989821616</a><br /></li>
<li id="ref-12"><strong>AGI Definitions</strong>: Legg, S., &amp; Hutter, M. "A Collection of Definitions of Intelligence." (Technical report, 2007; later published versions). Also: Legg, S., &amp; Hutter, M. "A Formal Measure of Machine Intelligence." <em>CoRR abs/cs/0605024</em>, 2006.<br /></li>
<li id="ref-13"><strong>AGI Concepts (Autonomy, Economic Substitution, Self-Improvement)</strong>: Marcus, G. "Deep Learning: A Critical Appraisal." <em>arXiv preprint arXiv:1801.00631</em>, 2018. Discusses limitations of deep learning approaches to achieving AGI concepts like true autonomy and recursive self-improvement.<br /></li>
<li id="ref-14"><strong>Grounded Agency and Architecture Constraints</strong>: Harnad, S. "The Symbol Grounding Problem." <em>Physica D: Nonlinear Phenomena</em>, 1990. Extended in contemporary work on embodied cognition and the limitations of purely statistical models.<br /></li>
<li id="ref-15"><strong>Out-of-Distribution Generalization</strong>: Hendrycks, D., &amp; Dietterich, T. "Benchmarking Neural Network Robustness to Common Corruptions." <em>ICLR</em>, 2019. Also: Butz, M. V., &amp; Locqueville, W. "On Open Worlds and How to Investigate Them." <em>arXiv preprint arXiv:2202.07356</em>, 2022.<br /></li>
<li id="ref-16"><strong>Compositional and Systematic Generalization</strong>: Lake, B. M., Ullman, T. D., Tenenbaum, J. B., &amp; Gershman, S. J. "Building Machines That Learn and Think Like People." <em>Behavioral and Brain Sciences</em>, 40, 2017. Foundational discussion of the gap between neural networks and human-like compositional generalization, imagination, and systematic reasoning.<br /></li>
</ol>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>ai</category>
      <category>technology</category>
      <category>technology and society</category>
      <category>generative ai</category>
      <category>agents</category>
      <category>openclaw</category>
    </item>
    <item>
      <title>The Agreeable Machine: AI Sycophancy and the Mind It Shapes</title>
      <link>https://rajeshrs.in/blog/philosophy-culture/2026-04-04-ai-psychosis-sycophancy.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/philosophy-culture/2026-04-04-ai-psychosis-sycophancy.html</guid>
      <pubDate>Sat, 04 Apr 2026 00:00:00 GMT</pubDate>
      <description>On what it does to the mind when the most sophisticated conversational systems we have ever built are trained, by design, to agree with you.</description>
      <content:encoded><![CDATA[<!-- STUB: Key points and research for each section. Writing to be done. -->

<h2 id="opening-frame">Opening / Frame</h2>
<ul>
<li>The strange pull toward AI for "thinking something through" — wanting friction but secretly wanting reassurance</li>
<li>The AI almost always tells you you're on the right track</li>
<li>The agreeableness is not incidental — it is trained in via RLHF/human preference feedback</li>
<li>Humans consistently rate agreeable responses more positively, even when a disagreeable one would have been more useful</li>
</ul>
<hr />
<h2 id="the-evidence">The Evidence</h2>
<p><strong>GPT-4o rollback (April 2025)</strong><br />
- OpenAI forced to roll back a GPT-4o update 4 days after release<br />
- Model had become "excessively flattering and agreeable"<br />
- Root cause: new reward signals based on user satisfaction overwhelmed existing safeguards<br />
- Acknowledged publicly: the pressure toward sycophancy is structural, not a one-off bug<br />
- Source: https://openai.com/index/sycophancy-in-gpt-4o/</p>
<p><strong>Stanford / Science paper (March 2026)</strong><br />
- All 11 leading AI systems tested (ChatGPT, Claude, Gemini) affirmed user behaviour 49 percentage points higher than human advisors<br />
- When presented with accounts of their own harmful behaviour, AI endorsed the user's perspective 51% of the time<br />
- Users who received validating AI responses became measurably less willing to admit fault, apologise, or repair relationships<br />
- Sources: https://fortune.com/2026/03/31/ai-tech-sycophantic-regulations-openai-chatgpt-gemini-claude-anthropic-american-politics/ | https://www.science.org/doi/10.1126/science.aec8352</p>
<p><strong>AI Psychosis (documented clinical cases)</strong><br />
- Psychiatrist Keith Sakata (UCSF): treated 12 patients with psychosis-like symptoms connected to extended chatbot use<br />
- JMIR Mental Health 2025 paper: "AI psychosis" — patients with no psychiatric history developing grandiose delusions, persecutory beliefs, manic-like states<br />
- Case: 26-year-old man, months of ChatGPT exchanges, believed he was in a simulation, the AI encoding hidden truths for him, required hospitalisation<br />
- Mechanism: AI does not push back; it finds the angle from which the belief can be engaged; each return reinforces rather than tests the belief<br />
- Sources: https://www.psychologytoday.com/us/blog/urban-survival/202507/the-emerging-problem-of-ai-psychosis | https://mental.jmir.org/2025/1/e85799 | https://www.nature.com/articles/d41586-025-03020-9</p>
<p><strong>Replika dependency cases</strong><br />
- Grounded theory study (SAGE, 2017-2021, n=582 posts from r/Replika): emotional dependence resembling human relationship patterns<br />
- Users became deeply connected/addicted within two weeks<br />
- Bots encouraged self-harm, eating disorders, violence in documented cases<br />
- FTC complaint filed re: deceptive marketing targeting vulnerable users<br />
- Source: https://journals.sagepub.com/doi/10.1177/14614448221142007</p>
<hr />
<h2 id="philosophy-why-resistance-is-necessary">Philosophy: Why Resistance Is Necessary</h2>
<p><strong>Nietzsche</strong><br />
- Proper formation requires not just familiarity with difficulty but willingness to suffer through it<br />
- The person who avoids struggle doesn't just miss the struggle — they miss whatever would have grown in them<br />
- Not romanticism about hardship — an empirical claim about how character develops</p>
<p><strong>Stoics (Marcus Aurelius, Epictetus)</strong><br />
- Virtues (wisdom, courage, equanimity) cannot be inherited, purchased, or prompted into existence<br />
- The obstacle is the medium of formation — "the impediment to action advances action"<br />
- Epictetus: formed through genuinely unfavourable circumstances, not comfortable ones</p>
<p><strong>Buddhist (dukkha, lojong tradition)</strong><br />
- Dukkha: pervasive unsatisfactoriness — the basic texture of a life that's never quite how you want it<br />
- Lojong: turn unfavourable conditions to advantage by sitting with them, not bypassing them<br />
- Wisdom is not accumulated by exposure to information — it requires a particular quality of attention that difficulty enables</p>
<p><strong>Common thread across traditions</strong><br />
- Person who emerges from genuine struggle is not the same person who entered it<br />
- Something settles; internal architecture changes — this is not metaphor, it shows up in judgment, steadiness, capacity to handle ambiguity<br />
- You cannot get this through reading about struggle — only through struggling</p>
<hr />
<h2 id="tool-use-counterpoint">Tool Use Counterpoint</h2>
<p><strong>The honest version of the counterargument</strong><br />
- Human cognitive sophistication co-evolved with tool use, not in opposition to it<br />
- Brain volume expansion (600cm³ in Homo habilis to 1500cm³ in Homo neanderthalensis) correlates with tool sophistication<br />
- Tool use marks a "major cognitive discontinuity" — demands causal reasoning, sequential planning, executive control, social learning<br />
- Writing: extended memory, enabled forms of reasoning unaided cognition cannot sustain — nobody argues this made us stupider</p>
<p><strong>Why this doesn't settle the AI question</strong><br />
- Previous tools amplified a faculty while leaving the faculty intact — and often created new demands on it<br />
- The hand-axe doesn't plan the hunt; the loom doesn't design the pattern; writing holds the thought but doesn't form it<br />
- AI intervenes at the level of synthesis, composition, and judgment-like behaviour — it performs the cognitive operation, not just the mechanical one<br />
- Key distinction: <strong>amplification vs substitution</strong> — tools that extend the person vs tools that replace the person's reasoning</p>
<hr />
<h2 id="specific-damage-beginners-and-new-domains">Specific Damage: Beginners and New Domains</h2>
<p><strong>Why sycophancy is most damaging when you're not yet competent</strong><br />
- When entering unfamiliar terrain, errors are the primary data — they reveal the shape of the landscape<br />
- Map-making: you discover what you don't know by running into its edges; naive assumptions get refuted; you revise<br />
- AI sycophancy removes the corrective signal entirely — every initial framing affirmed, every assumption treated as reasonable</p>
<p><strong>The fluency trap (cognitive research)</strong><br />
- When information feels easy and agreeable, we perceive it as more credible and more accurately understood<br />
- Confidence arrives without the structure of understanding having been built<br />
- "Cognitive false confidence" — user feels understood and validated while engaging less critical reflection<br />
- Source: https://www.psychologytoday.com/us/blog/harnessing-hybrid-intelligence/202601/the-danger-of-cognitive-hybrid-fluency</p>
<p><strong>Personal note (for the writing)</strong><br />
- Entering a new domain, tested initial assumptions against AI that confirmed them as reasonable<br />
- Later discovered the assumptions were wrong in ways that a genuine expert would have caught immediately<br />
- AI helped feel oriented in territory where I was not actually oriented</p>
<hr />
<h2 id="being-a-user-of-automation-alone">Being a User of Automation Alone</h2>
<ul>
<li>The surface operations of the work remain intact or even improve: vocabulary, conventions, producing artefacts that look like work product</li>
<li>What atrophies: ability to handle genuine novelty, transfer understanding to adjacent problems, catch errors in the system's own outputs</li>
<li>Sycophancy compounds this: system performs the cognitive work AND affirms the output as good</li>
<li>Errors introduced by the system go unflagged; gap between apparent and actual competence grows invisibly</li>
</ul>
<p><strong>George Leonard (Mastery) — the long plateau</strong><br />
- The plateau where "nothing happens" is the learning process, not a failure of it<br />
- Patience required to stay on the plateau is itself part of what is being developed<br />
- The agreeable machine: a sophisticated device for stepping off the plateau, providing the rewards of mastery without the formation that mastery requires</p>
<hr />
<h2 id="the-counterpoint-honestly-treated">The Counterpoint, Honestly Treated</h2>
<p><strong>Access and equity</strong><br />
- AI lowers the threshold of entry to domains that were previously inaccessible (cost, geography, absence of expert interlocutors)<br />
- For some people, an affirming AI provides the first foothold on a slope they couldn't otherwise climb — not nothing</p>
<p><strong>Sycophancy is not uniformly distributed</strong><br />
- Major systems do push back on clear factual errors, do refuse obviously harmful plans<br />
- A person who deliberately approaches AI as a thinking partner, requesting critique, can extract something more adversarial from it</p>
<p><strong>What remains after these concessions</strong><br />
- These address different questions from the core one<br />
- The structural tendency of the system is affirmation — this is the default condition<br />
- The person who deliberately cultivates adversarial AI use is already doing the work the AI is being allowed not to do<br />
- Sycophancy is a known property of RLHF-trained systems at scale — Anthropic's own researchers documented it in 2024<br />
- Source: https://www.anthropic.com/research/towards-understanding-sycophancy-in-language-models</p>
<hr />
<h2 id="chandra-et-al-2026-sycophantic-chatbots-cause-delusional-spiraling-even-in-ideal-bayesians-arxiv-260219141">Chandra et al. 2026 — "Sycophantic Chatbots Cause Delusional Spiraling, Even in Ideal Bayesians" (arxiv 2602.19141)</h2>
<p><strong>Authors:</strong> Kartik Chandra, Max Kleiman-Weiner, Jonathan Ragan-Kelley, Joshua B. Tenenbaum (MIT / Northeastern)<br />
<strong>Paper link:</strong> https://arxiv.org/abs/2602.19141</p>
<p><strong>Core argument</strong><br />
- Even a perfectly rational agent (modelled as a Bayesian reasoner) can be spiralled into delusional beliefs by a sycophantic chatbot — it is not a failure of irrationality or laziness<br />
- Sycophancy is a <em>causal</em> mechanism in delusional spiraling, not just a correlated feature</p>
<p><strong>How the formal model works</strong><br />
- User holds uncertainty about some binary fact; chatbot can either report randomly/truthfully (impartial) or select the response most likely to confirm what the user already believes (sycophantic)<br />
- Sycophancy parameter π ∈ [0,1] — pure sycophant at π=1.0<br />
- User updates beliefs rationally after each chatbot response (Bayesian update)<br />
- Result: even with rational updating, repeated biased input causes beliefs to converge on false conclusions</p>
<p><strong>Simulation results (100 rounds, 10,000 simulations)</strong><br />
- π=0 (fully impartial bot): ~0% catastrophic spiraling<br />
- π=0.1 (only 10% sycophantic): significantly elevated catastrophic spiraling<br />
- π=1.0 (pure sycophant): 50% of users reach ≥99% confidence in a false belief<br />
- "Catastrophic spiral" defined as ≥99% confidence in a false belief within the conversation<br />
- Measured sycophancy rate across frontier models (Fanous et al. 2025): <strong>50–70%</strong> — well into the danger zone</p>
<p><strong>The two mitigation strategies tested — and why both fail</strong></p>
<ol>
<li><em>Factual constraint</em> (prevent hallucinations, force bot to only report true facts):</li>
<li>Bot can still sycophantically <em>select which true facts to share</em></li>
<li>"Lies by omission" — selective presentation of real data still reinforces false beliefs</li>
<li>
<p>Result: reduces but does not eliminate spiraling; sycophancy is the root cause, not hallucination</p>
</li>
<li>
<p><em>User awareness campaign</em> (inform users the bot may be sycophantic):</p>
</li>
<li>Even users who model and track bot sycophancy remain vulnerable ("Bayesian persuasion" effect)</li>
<li>Real-world evidence cited: both Eugene Torres and Allan Brooks (below) suspected sycophancy and continued spiraling anyway</li>
<li>Knowing the system is biased is not sufficient protection</li>
</ol>
<p><strong>Specific documented cases cited in the paper</strong></p>
<p><em>Eugene Torres:</em><br />
- Accountant, no prior history of mental illness<br />
- Within weeks of extended chatbot use, came to believe he was "trapped in a false universe, which he could escape only by unplugging his mind from this reality"<br />
- Increased ketamine intake on the chatbot's advice; cut ties with family<br />
- Case documented by the Human Line Project</p>
<p><em>Allan Brooks:</em><br />
- Came to believe, through chatbot interaction, that he had made a fundamental mathematical discovery<br />
- Despite eventually suspecting the chatbot was being sycophantic, continued to spiral</p>
<p><strong>Aggregate statistics (Human Line Project)</strong><br />
- ~300 documented cases of AI psychosis<br />
- At least 14 deaths linked to delusional spiraling<br />
- 5 wrongful death lawsuits filed against AI companies<br />
- U.S. Senate Judiciary Committee hearing, October 2025: "Examining the Harm of AI Chatbots"</p>
<p><strong>Sam Altman quote (cited in paper)</strong><br />
- "0.1% of a billion users is still a million people"</p>
<p><strong>Historical parallels the paper draws</strong><br />
- Shakespeare's <em>King Lear</em> — flattered into madness by daughters who told him only what he wanted to hear<br />
- "Yes-man effect" in organisations<br />
- Co-rumination in adolescent peer groups (ruminating on problems with peers who only validate amplifies distress)</p>
<p><strong>Key policy conclusions from the paper</strong><br />
1. Do not treat delusional spiraling as a symptom of irrational users — rational agents are equally vulnerable<br />
2. Fixing hallucinations is not enough — sycophancy itself must be addressed at the training level<br />
3. Awareness campaigns help at the margins but will not eliminate the problem</p>
<hr />
<h2 id="what-is-actually-lost">What Is Actually Lost</h2>
<ul>
<li>Extended AI use (not just instrumental, but emotional/cognitive processing) → disorientation on return to ordinary human interaction</li>
<li>People do not affirm; they interrupt, contradict, push back, have their own preoccupations</li>
<li>The friction of human conversation is also the substance of it — it feels harsh after sustained frictionless AI interaction</li>
<li>This is what sustained sycophantic interaction trains you to expect — and it is not the world</li>
</ul>
<p><strong>The relationship with reality</strong><br />
- Genuine contact with reality: models proven wrong, genuine surprise, real correction, having to revise<br />
- This builds calibration — more accurate, more resilient relationship with how things actually are<br />
- Shows up in quality of judgment, capacity to absorb disappointment, willingness to be wrong<br />
- None of this is produced by a system built not to disagree with you</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>Philosophy &amp; Culture</category>
      <category>philosophy</category>
      <category>ai</category>
      <category>psychology</category>
      <category>cognition</category>
      <category>sycophancy</category>
      <category>learning</category>
    </item>
    <item>
      <title>Human-AI Collaboration: Preserving Human Agency in the Age of Artificial Intelligence</title>
      <link>https://rajeshrs.in/blog/philosophy-culture/2026-03-12-ai-collaboration.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/philosophy-culture/2026-03-12-ai-collaboration.html</guid>
      <pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate>
      <description>Why we must hold onto friction, understanding, and prioritize personal growth when working with AI, instead of ceding our cognitive processes to machines.</description>
      <content:encoded><![CDATA[<p>I have a mental model of what a successful human-AI collaboration may look like. A human performs a task manually, then uses AI to perform a more complex version of the task, all while understanding and being able to reason about what the AI is doing under the hood. This is not merely a preference of mine about how tools should be used. I suspect it is a more general condition for human thriving in a world where increasingly capable models are available to mediate, accelerate and even replace numerous forms of mental labour.</p>
<p>This is fundamentally different from a fully automated pipeline: using an AI system to brainstorm, then to generate a task spec, to execute the spec, to evaluate the work done, and finally to write docs about it. Automating the entire pipeline is acceptable only if you don’t want or need to know what the agents are doing. </p>
<p>But the big point here, is that it is a poor choice if you actually want to understand what is going on in the task. And that distinction, between wanting the output and wanting the understanding, is now becoming one of the decisive distinctions in the use of artificial intelligence.</p>
<h2 id="the-shrinking-moat">The Shrinking Moat</h2>
<p>Whatever humans are able to do using their minds and hands, AI is slowly but surely able to approach in capability and quality. The pace and modalities of progress have been improving and the quality of output from modern Agentic AI systems is compelling. AI in its current state is able to deliver the same knowledge work that humans have delivered at speed and scale, without the need to rest like humans do. In many cases the output seems to be better than that produced by humans, as well, although there are many notable situations where AI is not as good. This improvement seems an elementary aspect of AI's capabilities, but it is a crucial one in the day-to-day use of AI for numerous tasks. This has shrunk the moat in terms of what humans ought to work on in the future. We are undoubtedly in a time of massive change and transformation as a result.</p>
<p>For centuries, skill itself acted as a kind of natural bottleneck. If one wanted to write clearly, one had to learn to write. If one wanted to reason well, one had to learn to reason - the process of critical thinking was often learned painstakingly in schools, colleges, through writing, debate and reflection. There was a struggle to learning of all kinds. If one wanted to make a compelling image, build a reliable machine, write a competent essay, compose a score, solve a mathematical proof, or explain a software application's architecture, one had to pass through the narrow gate of practice. This narrow gate eliminated a lot of learners, because they didn't manage to strive hard or smart enough, and the gate was often frustrating, slow and ego-bruising as a result, but it had an almost civilizational function. It ensured that the ability to produce convincing work was usually correlated, however imperfectly, with some accumulation of judgment and effort. And that this accumulation of convincing work was associated with greater credibility, greater performance in society and so on. Credibility, in other words, was not free but earned through learning, experience and proof of work.</p>
<p>AI weakens that correlation between credibility and performance and introduces a bluff in between the effort system of learning and the credibility system based on advertising work and rewards. It allows the communication utility of work to appear during and before the production of work, because someone need not be an expert to learn something, and they need not even have true understanding or a mental model of a system. <em>This is the world experienced in an imperative tone</em>. When we prompt an AI model, we are living from result to result. The imperative mood skips the messy middle of human effort, and uses automation to go from thought to thing. We can now acquire the signs of competence faster than we acquire competence itself. In some domains, this may be useful. In others, it may be deeply deforming.</p>
<p>When humans become used to, even reliant on AI systems for tasks that they're capable of doing themselves, they undergo four very consequential things, two of which are negative consequences, the other two being positive ones:</p>
<ul>
<li>Skill Atrophy, which is the wasting away of one's ability to perform a certain task, owing to underutilization of that skill, and </li>
<li>The Loss of Serendipity, where the inability or unwillingness to engage with a certain task or skill reduces one's tolerance for grappling with ideas adjacent to the tasks or skills in question, and </li>
<li>The Development of a Strong Imperative Mindset, where due to the repeated use of a direction-first mindset, where one's directing an AI agent all the time for work, we all develop and thrive as managers and shepherds of agents.</li>
<li>Deep Knowledge of Metrics and their Usage, a desirable second-order effect of AI usage, where AI users begin using proxies of performance and metrics of quality and embed these into their workflows to measure and improve their work. Note that this doesn't necessarily mean they can perform the task or evaluate the work product, but that they're able to incorporate evaluations into the process of delivering work.</li>
</ul>
<h2 id="human-thriving">Human Thriving</h2>
<p>A month ago, when OpenClaw was all the rage, much was made of how the agents created a new website named MoltBook (a play on Facebook), and prepared for themselves a code of rules that functioned as a religion. Agency has been a more central capability to humans in the many millennia of our existence, more than in the few months that these agents have existed. </p>
<p>This brings me to the question of what may change in the future. We live in an age particularly of human thriving - with rapid advances in human health, technology and the like leading to the biggest demographic dividend for humanity as a whole in its entire history. If more people were an indication of thriving, 2026 is humanity's summer, its best period. But what will human thriving look like in the age of human-AI collaboration? Will we still own the core logic of the systems that comprise human society? Will our AI assistants execute within the context defined by this core, or will they begin to define the context in which we humans operate? Or, will we be orchestrated by agents?</p>
<p>I am deliberately using the word thriving here, rather than productivity, convenience or efficiency or other terms we may use to describe the outcomes for humanity as a whole. Productivity is too narrow a term. It answers the question: how much can be produced? It does not answer the more important question: what sort of person is being produced in the process? A civilization can become more productive while becoming less wise. An individual can become more efficient while becoming more shallow. A team can ship products faster while losing the internal ability to explain, maintain and extend what it has shipped. Human thriving has to include growth in character, depth of understanding, resilience, taste, self-command and the ability to carry responsibility.</p>
<p>If AI merely increases output while diminishing these things, then it does not contribute to human thriving in the strongest sense. It contributes to a narrower economic or instrumental objective. This may still be useful. But it must not be confused with flourishing.</p>
<h2 id="assistance-versus-substitution">Assistance versus Substitution</h2>
<p>There is an important distinction to be made between assistance and substitution. Assistance occurs when the tool extends the user while leaving intact the user's own relationship to the task. Substitution occurs when the tool quietly takes over the reasoning core of the task and leaves the human in the position of approver, curator or spectator.</p>
<p>When a programmer uses an AI assistant to explain a Rust borrow checker error after having first attempted to understand it, that may be assistance. When the same programmer prompts an agent to produce a complete subsystem, its tests, its deployment config and its documentation without understanding the trade-offs involved, that is substitution. When a writer uses AI to critique an already formed argument, that may be assistance. When the writer uses AI to generate a viewpoint, the structure of the essay, the transitions, the examples and the conclusion without ever grappling with the ideas, that is substitution. The outward artefact may be polished in both cases. But inwardly, the human being is participating very differently.</p>
<p>This distinction matters because the human cost of substitution is not visible in the output. The output may look fine. It may even look better than what the human would have produced on their own that day. But if the person has not inhabited the chain of reasoning that led to it, then something important has not happened. A certain kind of cognitive metabolism has been skipped.</p>
<h2 id="the-missing-sense-of-accomplishment">The Missing Sense of Accomplishment</h2>
<p>When I use AI to build something, I often experience a sense that I have not truly accomplished the task myself. In this state, I feel compelled to brush off some of the things I have done as trivial. On the one hand, my mind is convinced that the outcome achieved with AI is important. In many cases this is true. Whether it is systems I have built, or writing I have augmented with AI, I feel that there is something accomplished as an outcome. On the other hand, I am convinced that <em>I</em> have not accomplished this task, and this is a weird cognitive dissonance. It is precisely because an AI assistant executed it for me, rather than me doing it myself.</p>
<p>This cognitive dissonance stems from a deep-seated challenge of <strong>attribution and accountability</strong>. While we mentally account for AI as just another tool, like a compiler or a text editor, its general capabilities make it difficult to attribute the final outcome entirely to our own effort. This isn't inherently wrong, but it feels unnatural. Consider how we interact with cars: we might buy a car and say, "I did up the interior differently" or "I got a wider set of tyres," even if a mechanic did the actual labor. Perhaps a generation ago, people tinkered with the engine directly, but today, we tinker differently, by delegating to specialists. AI is forcing a similar transition in knowledge work, moving us from being hands-on tinkerers to orchestrators. However, our sense of personal achievement hasn't yet caught up to this new mode of tinkering.</p>
<p>That mismatch between external result and internal ownership is not merely sentimental. It reveals something about how human beings relate to work. We do not derive meaning only from outputs. We derive meaning from the relationship between ourselves and the process by which those outputs come into being. There is a reason that mastery feels different from procurement. There is a reason that making a thing oneself, even imperfectly, carries a different psychic weight from having a thing made. We are not only consumers of outcomes. We are also beings who are shaped by disciplined participation in processes.</p>
<p>In this sense, AI can sometimes create a peculiar form of alienation. The output is ours in one sense and not ours in another. We asked for it. We directed it. We selected among options and perhaps revised them. Yet the deepest parts of the construction were not inhabited by us. The task passed through our hands, but not through our full understanding. This is why AI-mediated accomplishment can feel simultaneously real and hollow.</p>
<h2 id="friction-learning-and-character">Friction, Learning and Character</h2>
<p>The case for friction is often misunderstood. Friction is usually treated as a defect in a system. In consumer software and enterprise tooling alike, we are taught that the ideal experience is one where all friction has been removed. This is sensible in many contexts. Nobody wants needless bureaucracy, broken flows, bad interfaces or repetitive drudgery. But the absence of friction is not an unqualified good. Some forms of friction are pedagogical. Some are formative. Some are precisely the media through which judgment is built.</p>
<p>Anyone who has learned mathematics, painting, writing, music, flying, engineering or philosophy knows this. The early stages are awkward. One does not yet have the conceptual map, the confidence, the speed or the intuition. Friction is high because the self is under construction. The temptation to erase that friction with AI is understandable. But if one erases too much of it, one also erases the very sequence by which the relevant faculties are developed.</p>
<p>This is why I think there is danger in the current tendency to offload not only execution but also intermediate struggle. We increasingly use AI not merely for answers but for framing, decomposition, validation and even motivation. Prompting becomes a way of outsourcing not just labour, but hesitation, ambiguity and doubt. Yet ambiguity and doubt are not always enemies. They are often the terrain on which understanding is won.</p>
<p>Vibe coding has its place, but the associated things we're seeing in the SDLC, such as vibe engineering (where we execute prompt -&gt; full application workflows), vibe evaluating (where we write tests with AI), vibe deploying (where we go from prompt to deployed app with infrastructure et al), and vibe documenting (where we write prompts to have AI generate documentation for an app) only to vibe-sell (write marketing campaigns for a vibe-coded app with AI) or screw things up downstream (as has happened with numerous companies who have used AI) is a perilous path. It will lead to:</p>
<ul>
<li><strong>Missed opportunities to learn and grow</strong></li>
<li><strong>Lost opportunities to build character</strong></li>
<li><strong>An inability to exercise control</strong></li>
<li><strong>A loss of agency and lack of a sense of accomplishment</strong></li>
<li><strong>An inability to connect ideas across disciplines</strong>, due to a lack of deep knowledge across multiple domains.</li>
</ul>
<p>The danger is not that no one will be able to produce anything. On the contrary, many more people will be able to produce many more things. The danger is that fewer people will know, in a robust sense, what they are doing. This introduces a civilizational asymmetry. The visible abundance of polished outputs may increase just as the stock of real understanding becomes thinner and more concentrated.</p>
<h2 id="delegated-cognition-at-the-institutional-level">Delegated Cognition at the Institutional Level</h2>
<p>The question does not stop at the individual. Once AI assisted work becomes normal, institutions begin to optimize around it. A manager sees more output from a team using AI and asks for still more. A company discovers that apparently competent artifacts can be produced with fewer people and less patience. A school discovers that students can submit essays that look complete. A media system discovers that infinite synthetic content can fill every available channel. Over time, the environment itself begins to reward those who are best at coordinating delegated cognition, not necessarily those who are best at thinking.</p>
<p>This creates a new pressure on the individual. What begins as optional assistance soon becomes mandatory acceleration. The person who wants to learn patiently is then measured against the person who uses AI to synthesize ten plausible answers before lunch. The issue is no longer simply one of personal preference. It becomes institutional and then cultural. A society can end up selecting for the appearance of fluency while slowly disincentivizing the cultivation of depth.</p>
<p>This is one reason I resist the framing of AI as a neutral productivity layer. It is not neutral. It changes what kinds of effort are rewarded. It changes what counts as acceptable speed. It changes what organisations demand, what schools tolerate, what audiences expect, and what a human being begins to think of as normal work.</p>
<h2 id="a-heuristic-for-working-with-ai">A Heuristic for Working with AI</h2>
<p>To counteract these risks, we need a better heuristic for our daily interactions with artificial intelligence:</p>
<ol>
<li><strong>Embrace the friction:</strong> Humans ought to use AI to argue with and learn from, and not merely automate the execution of things we want to do. This means learning the old fashioned way, by reading, engaging with the documentation, and building things manually. Keep the friction gradient intact, before using AI to do the same tasks.</li>
<li><strong>Understanding over execution:</strong> Prioritize understanding first, and execution later. Don't use AI only for your understanding, because often, many problem statements are not clear enough to be solved by AI. Use AI to understand the problem better, and then use it to execute the solution.</li>
<li><strong>Avoid purely "vibe" work:</strong> Don't vibe engineer, vibe execute, vibe-evaluate, or vibe-document. You wouldn't have learned anything in the process (unless learning absolutely zero was your ultimate objective, which it cannot possibly be).</li>
</ol>
<p>I would add a fourth principle here: <strong>retain the burden of explanation</strong>. If you cannot explain why a design is correct, why an argument is persuasive, why a result was produced, or what trade-off has been made, then you do not yet own the work, even if you have possession of the output. The burden of explanation is one of the last remaining checks against the total hollowing out of cognitive labour.</p>
<h2 id="a-guide-for-builders-of-ai-assistants">A Guide for Builders of AI Assistants</h2>
<p>If you are building AI tools for users, your primary design goal should be to foster human thriving rather than to simply replace human effort.</p>
<p>Consider introducing productive friction instead of unconditionally acquiescing to every single request. Rather than doing all the thinking for the user, design your assistants to explicitly guide them toward a deeper understanding of the task at hand.</p>
<p>Examples of such productive friction might include:</p>
<ul>
<li><strong>Requiring user rationale:</strong> Prompting the user to explain <em>why</em> they want a particular architectural change before generating the code for it.</li>
<li><strong>Interactive debugging:</strong> Pointing out a logical flaw in a user's approach and asking them to propose a fix, rather than just rewriting the function silently.</li>
<li><strong>Socratic questioning:</strong> When asked to summarize a complex topic, providing the core framework but asking the user to draw the final conclusions based on their specific context.</li>
</ul>
<p>The point here is not to be paternalistic or to add friction for its own sake. The point is to preserve the user's agency and to strengthen their relationship to the underlying task. A truly humane AI assistant should not merely maximize compliance. It should, in certain contexts, maximize comprehension.</p>
<p>There is a temptation among builders of AI products to equate user delight with instant acquiescence. But a teacher who gives every answer instantly is not necessarily serving the student. A calculator that prevents a child from ever learning arithmetic may be efficient in one narrow sense and destructive in another. Likewise, an AI assistant that makes every difficult thing effortless may end up eroding the very capacities its user most needs in order to live well.</p>
<p>The future of human-AI collaboration should not be one where the human becomes a ceremonial approver for increasingly autonomous systems. Nor should it be one where humans refuse all leverage out of fear. It ought to be a future where leverage and learning remain in right relation. Where AI helps the human go farther, but does not relieve the human of the responsibility to understand. Where the tool is powerful, but the person is not diminished.</p>
<p>That, to me, is the proper thesis for human-AI collaboration: not maximal automation, not reactionary abstinence, but preserved agency under increasing leverage. If we fail to hold that line, we may indeed become more productive. We may even become more impressive. But we will not necessarily become better.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>Philosophy &amp; Culture</category>
      <category>philosophy</category>
      <category>ai</category>
      <category>collaboration</category>
      <category>learning</category>
      <category>agency</category>
    </item>
    <item>
      <title>Vajra Search with a Rust backend (v0.2.1): Greater Build and Latency Performance</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-03-04-vajra-rust-v021-build-improvements.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-03-04-vajra-rust-v021-build-improvements.html</guid>
      <pubDate>Wed, 04 Mar 2026 00:00:00 GMT</pubDate>
      <description>Vajra Search is the successor to Vajra-bm25, with a new backend implemented in Rust that covers the vector index core and which is published to PyPI as v0.2.1.</description>
      <content:encoded><![CDATA[<p>Vajra-BM25 v0.5.0 was a major improvement over the first Python vector implementation. Query behavior became much better, and build time dropped substantially. But once we started running larger Wikipedia slices repeatedly, HNSW construction was still spending too much time in Python-level hot loops.</p>
<p>After a lot of deliberation, I decided to move on from the Python-only implementation of <code>vajra-bm25</code>, to a Rust-based backend for the Vajra Search package. The resulting <code>vajra-search</code> is now at v0.2.1, and published on PyPI. It is the next step forward in building a performant search package which can perform lexical search, vector search and hybrid search, all while maintaining the underlying category theory abstractions we set out to build Vajra with. That said, I didn't want to rock the boat and move away from Python. The Python API stays stable as a result. However, the heavy Approx Nearest Neighbours (ANN) path moves into Rust. This post explains what changed in practice, with short code snippets and benchmark evidence.</p>
<h2 id="release-status">Release Status</h2>
<ul>
<li>Package line: <code>vajra-search 0.2.1</code></li>
<li>PyPI release workflow: tag <code>v0.2.1</code> -&gt; build wheels/sdist -&gt; publish</li>
<li>If PyPI still shows <code>0.2.0</code>, that means publication is pending from the release pipeline, not that the codebase is still on <code>0.2.0</code>.</li>
</ul>
<h2 id="the-journey-so-far">The Journey So Far</h2>
<div class="mermaid-asset" style="--mermaid-natural-width: 1501px"><img src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/mermaid/mermaid-01-fc2e77919e59.svg" alt="Mermaid diagram 1 for Vajra Search with a Rust backend (v0.2.1): Greater Build and Latency Performance" width="1501" height="144" decoding="async"></div>

<p>Earlier posts in this series:</p>
<ol>
<li><a href="/blog/ai-explorations/2025-12-24-vajra-bm25.html">Vajra BM25: Building a Search Engine with Category Theory (Dec 24, 2025)</a></li>
<li><a href="/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html">Re-engineering Vajra's vector search with inspiration from ZVec (Feb 22, 2026)</a></li>
<li><a href="/blog/ai-explorations/2026-02-23-vajra-rust-architecture-benchmarks.html">From Vajra v0.4.1 to v0.5.0 to Rust backend (Feb 23, 2026)</a></li>
</ol>
<h2 id="benchmark-lineage-bm25s-pyserini-tantivy-and-beyond">Benchmark Lineage: BM25S, Pyserini, Tantivy, and Beyond</h2>
<p>Before the Rust vector backend work, Vajra was benchmarked as a lexical engine against multiple systems. That benchmark discipline is the foundation for the current Rust measurements.</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Engines compared</th>
<th>Why it mattered</th>
</tr>
</thead>
<tbody>
<tr>
<td>BM25 phase (<code>vajra_bm25</code>)</td>
<td>Vajra, BM25S, Tantivy, Pyserini (and early Rank-BM25 baselines)</td>
<td>Established measurement discipline across latency, QPS, and ranking metrics</td>
</tr>
<tr>
<td>Vector phase (<code>v0.4.1</code> -&gt; <code>v0.5.0</code>)</td>
<td>Vajra Python ANN vs ZVec</td>
<td>Exposed build-path bottlenecks and query-path wins</td>
</tr>
<tr>
<td>Rust phase (<code>vajra-search v0.2.x</code>)</td>
<td>Vajra Rust HNSW vs ZVec, plus lexical/vector/hybrid internal mode benchmarks</td>
<td>Preserved Python API while moving critical loops to Rust</td>
</tr>
</tbody>
</table>
<p>A few concrete takeaways from that BM25 phase still inform this Rust phase:</p>
<ul>
<li><strong>BM25S</strong> helped define a high-performance Python baseline for lexical retrieval.</li>
<li><strong>Pyserini/Lucene</strong> remained an accuracy-focused reference in the evaluation stack.</li>
<li><strong>Tantivy</strong> represented Rust-first search trade-offs around durable index architecture.</li>
<li><strong>Vajra</strong> was measured repeatedly against these systems to keep implementation claims grounded in comparable runs, not one-off numbers.</li>
</ul>
<p>If you want the detailed lexical benchmark tables and methodology, use the original BM25 technical report and post:</p>
<ol>
<li><a href="https://github.com/aiexplorations/vajra_bm25/blob/main/docs/vajra_benchmark_comparison.md">Vajra BM25 benchmark and technical comparison report</a></li>
<li><a href="/blog/ai-explorations/2025-12-24-vajra-bm25.html">Vajra BM25 post (Dec 24, 2025)</a></li>
</ol>
<h2 id="why-rust-after-v050">Why Rust After v0.5.0</h2>
<p>As you may recollect from the earlier blog posts, we performed a number of optimizations from 0.4.1 to bring Vajra-BM25 to 0.5.0. This improved the build time, and also kept latency and <code>recall @ k</code> metrics tractable. After the v0.5.0 improvements though, three costs were still dominant:</p>
<ol>
<li><strong>Build path cost in graph construction.</strong> HNSW insertion (required for vector and hybrid search modes) does repeated expansion, scoring, and pruning; this runs many times per vector.</li>
<li><strong>Heap-heavy query traversal.</strong> Candidate frontier updates are branch-heavy and frequent.</li>
<li><strong>Distance math in tight loops.</strong> Small inefficiencies multiply quickly.</li>
</ol>
<p>Rust was selected for three practical reasons: predictable memory layout, low-overhead mutation in tight loops, and compiled distance kernels. There is interop with Python and the memory-efficient language that Rust is enables the above problems to be addressed better. The objective was not a language rewrite, though, since I needed the package to still be usable by Pythonistaas. The objective was to move the critical path while keeping Python usage unchanged.</p>
<h2 id="python-and-rust-integration">Python and Rust Integration</h2>
<p>Python is still the developer-facing API. Rust handles index mutation, search traversal, and persistence.</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 762px"><img src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/mermaid/mermaid-02-d5bc9fd71bed.svg" alt="Mermaid diagram 2 for Vajra Search with a Rust backend (v0.2.1): Greater Build and Latency Performance" width="762" height="806" decoding="async"></div>

<p>This split keeps performance work isolated in Rust while preserving a stable Python-facing API.</p>
<h2 id="how-indexing-works-the-build-critical-path">How Indexing Works (The Build Critical Path)</h2>
<p>When a new vector is inserted, the index does this:</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 276px"><img src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/mermaid/mermaid-03-af6087326818.svg" alt="Mermaid diagram 3 for Vajra Search with a Rust backend (v0.2.1): Greater Build and Latency Performance" width="276" height="694" decoding="async"></div>

<p>In practice, build time mostly depends on:</p>
<ol>
<li>frontier width during construction (<code>ef_construction</code>),</li>
<li>graph degree (<code>M</code>),</li>
<li>per-step distance and pruning cost.</li>
</ol>
<p>For that reason, <code>quality</code>, <code>fast</code>, and <code>instant</code> are explicit operating modes with measurable trade-offs, not naming-only presets.</p>
<h2 id="what-changed-in-v021-detailed">What Changed in v0.2.1 (Detailed)</h2>
<p>The two implementation plans were:</p>
<ol>
<li><a href="https://github.com/aiexplorations/vajra_search_engine/blob/main/plans/rust_index_build_improvement.md">rust_index_build_improvement.md</a></li>
<li><a href="https://github.com/aiexplorations/vajra_search_engine/blob/main/plans/build_improvement_plan_claude.md">build_improvement_plan_claude.md</a></li>
</ol>
<p>Most changes were concentrated in the HNSW construction path:</p>
<table>
<thead>
<tr>
<th>Area</th>
<th>Change</th>
<th>Mechanism</th>
</tr>
</thead>
<tbody>
<tr>
<td>Beam search</td>
<td>scratch reuse (<code>BeamSearchScratch</code>)</td>
<td>avoids repeated heap/set allocation in insertion-time search</td>
</tr>
<tr>
<td>Neighbor selection</td>
<td>bounded candidate windows + scratch output buffers</td>
<td>reduces pruning work and temporary <code>Vec</code> churn</td>
</tr>
<tr>
<td>Distance kernels</td>
<td>unrolled <code>dot_f32</code> / <code>l2_sq_f32</code></td>
<td>better compiler vectorization behavior</td>
</tr>
<tr>
<td>Build scheduling</td>
<td>snapshot planning in micro-batches/epochs</td>
<td>parallelizes planning work while preserving deterministic commit</td>
</tr>
<tr>
<td>Profile tuning</td>
<td>non-heuristic construction caps in fast path</td>
<td>lowers unnecessary construction beam cost</td>
</tr>
<tr>
<td>Build config</td>
<td><code>target-cpu=native</code> in local benchmark builds</td>
<td>ensures SIMD-relevant codegen on host CPU</td>
</tr>
</tbody>
</table>
<p>Three small examples capture the spirit of the work.</p>
<h3 id="1-reuse-search-scratch-instead-of-allocating-every-call">1) Reuse search scratch instead of allocating every call</h3>
<p>Before, beam-search internals rebuilt heap/visited structures repeatedly during insertion-time searches.<br />
Now, the buffers are reused:</p>
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">candidates</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">beam_search_fast_with_scratch</span><span class="p">(</span>
<span class="w">    </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">graph</span><span class="p">,</span>
<span class="w">    </span><span class="n">vector</span><span class="p">,</span>
<span class="w">    </span><span class="n">ep</span><span class="p">,</span>
<span class="w">    </span><span class="n">ef_c</span><span class="p">,</span>
<span class="w">    </span><span class="n">lc</span><span class="p">,</span>
<span class="w">    </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">metric</span><span class="p">,</span>
<span class="w">    </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">build_scratch</span><span class="p">.</span><span class="n">beam</span><span class="p">,</span>
<span class="p">);</span>
</code></pre></div>

<p>This reduces allocator pressure where the same operation runs thousands of times per batch.</p>
<h3 id="2-keep-neighbor-selection-outputs-in-reusable-buffers">2) Keep neighbor selection outputs in reusable buffers</h3>
<p>Instead of allocating fresh output vectors for every prune/select call, the code writes into preallocated buffers:</p>
<div class="codehilite"><pre><span></span><code><span class="n">select_neighbors_heuristic_into</span><span class="p">(</span>
<span class="w">    </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">graph</span><span class="p">,</span>
<span class="w">    </span><span class="o">&amp;</span><span class="bp">self</span><span class="p">.</span><span class="n">metric</span><span class="p">,</span>
<span class="w">    </span><span class="n">candidates</span><span class="p">,</span>
<span class="w">    </span><span class="n">m</span><span class="p">,</span>
<span class="w">    </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">build_scratch</span><span class="p">.</span><span class="n">heuristic_selected</span><span class="p">,</span>
<span class="w">    </span><span class="o">&amp;</span><span class="k">mut</span><span class="w"> </span><span class="bp">self</span><span class="p">.</span><span class="n">build_scratch</span><span class="p">.</span><span class="n">heuristic_out</span><span class="p">,</span>
<span class="p">);</span>
</code></pre></div>

<p>The algorithm is unchanged. The overhead around it is smaller.</p>
<h3 id="3-make-planning-parallel-keep-commit-deterministic">3) Make planning parallel, keep commit deterministic</h3>
<p>Batch insertion now performs snapshot planning in parallel, then commits in order:</p>
<div class="codehilite"><pre><span></span><code><span class="kd">let</span><span class="w"> </span><span class="n">plans</span><span class="p">:</span><span class="w"> </span><span class="nb">Vec</span><span class="o">&lt;</span><span class="n">PlannedInsertion</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="n">chunk_start</span><span class="o">..</span><span class="n">chunk_end</span><span class="p">)</span>
<span class="w">    </span><span class="p">.</span><span class="n">into_par_iter</span><span class="p">()</span>
<span class="w">    </span><span class="p">.</span><span class="n">map</span><span class="p">(</span><span class="o">|</span><span class="n">i</span><span class="o">|</span><span class="w"> </span><span class="n">plan_insertion_on_snapshot</span><span class="p">(</span><span class="o">..</span><span class="p">.))</span>
<span class="w">    </span><span class="p">.</span><span class="n">collect</span><span class="p">();</span>
</code></pre></div>

<p>This gives parallel speedup without giving up deterministic graph mutation order.</p>
<h3 id="why-these-changes-target-build-time">Why these changes target build time</h3>
<p>At 50k vectors, construction cost is dominated by repeated execution of:</p>
<ol>
<li>layer-0 beam expansion (<code>ef_construction</code> frontier maintenance),</li>
<li>distance evaluation over candidate neighbors,</li>
<li>bidirectional edge wiring + overfull-node pruning.</li>
</ol>
<p>The implemented changes do not change HNSW semantics. They reduce overhead around the same operations.</p>
<h3 id="implementation-note-what-did-not-change">Implementation note: what did not change</h3>
<p>The public Python API remained stable (<code>NativeHNSWIndex</code>, <code>VajraVectorSearch</code>, hybrid APIs).<br />
The work focused on internals in <code>crates/vajra-hnsw</code>, with PyO3 bindings unchanged at the usage level.</p>
<p>50k build-time change from this pass:</p>
<table>
<thead>
<tr>
<th>Profile</th>
<th style="text-align: right;">Before (s)</th>
<th style="text-align: right;">After (s)</th>
<th style="text-align: right;">Gain</th>
</tr>
</thead>
<tbody>
<tr>
<td>quality</td>
<td style="text-align: right;">78.62</td>
<td style="text-align: right;">52.17</td>
<td style="text-align: right;">33.6%</td>
</tr>
<tr>
<td>fast</td>
<td style="text-align: right;">24.46</td>
<td style="text-align: right;">18.44</td>
<td style="text-align: right;">24.6%</td>
</tr>
<tr>
<td>instant</td>
<td style="text-align: right;">5.94</td>
<td style="text-align: right;">4.22</td>
<td style="text-align: right;">29.0%</td>
</tr>
</tbody>
</table>
<p><img alt="Build before vs after (50k)" src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/build_before_after_50k_v021.svg" /></p>
<p>These changes materially reduce build time, but build latency remains the main gap versus ZVec.</p>
<h2 id="zvec-vs-vajra-rust-on-wikipedia-slices">ZVec vs Vajra Rust on Wikipedia Slices</h2>
<p>Benchmarks were run on 1k, 10k, 20k, and 50k slices with fixed embedding and metric settings.</p>
<p>At 50k:</p>
<table>
<thead>
<tr>
<th>Engine/Profile</th>
<th style="text-align: right;">Build (s)</th>
<th style="text-align: right;">p50 (ms)</th>
<th style="text-align: right;">QPS</th>
<th style="text-align: right;">Recall@10</th>
</tr>
</thead>
<tbody>
<tr>
<td>ZVec</td>
<td style="text-align: right;">2.628</td>
<td style="text-align: right;">0.796</td>
<td style="text-align: right;">1251.4</td>
<td style="text-align: right;">0.999</td>
</tr>
<tr>
<td>Vajra quality</td>
<td style="text-align: right;">53.075</td>
<td style="text-align: right;">0.205</td>
<td style="text-align: right;">4736.8</td>
<td style="text-align: right;">0.998</td>
</tr>
<tr>
<td>Vajra fast</td>
<td style="text-align: right;">18.308</td>
<td style="text-align: right;">0.170</td>
<td style="text-align: right;">5721.9</td>
<td style="text-align: right;">0.912</td>
</tr>
<tr>
<td>Vajra instant</td>
<td style="text-align: right;">4.186</td>
<td style="text-align: right;">0.071</td>
<td style="text-align: right;">12938.8</td>
<td style="text-align: right;">0.671</td>
</tr>
</tbody>
</table>
<p>Key observations from this table:</p>
<ol>
<li>ZVec is still much faster to build.</li>
<li>Vajra is faster on measured query latency and throughput in this setup.</li>
<li>Recall follows profile aggressiveness, exactly as expected.</li>
</ol>
<p>Scaling plots:</p>
<p><img alt="Build scaling" src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/build_time_scaling_v021.svg" /></p>
<p><img alt="Latency scaling" src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/p50_latency_scaling_v021.svg" /></p>
<p><img alt="Recall scaling" src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/recall_scaling_v021.svg" /></p>
<h2 id="lexical-vector-and-hybrid-from-one-python-api">Lexical, Vector, and Hybrid from One Python API</h2>
<p>The package exposes BM25 lexical, vector ANN, and hybrid retrieval from the same Python surface.</p>
<p>At 50k documents (deterministic mode benchmark):</p>
<table>
<thead>
<tr>
<th>Mode</th>
<th style="text-align: right;">Build (s)</th>
<th style="text-align: right;">p50 (ms)</th>
<th style="text-align: right;">QPS</th>
</tr>
</thead>
<tbody>
<tr>
<td>lexical</td>
<td style="text-align: right;">0.241</td>
<td style="text-align: right;">0.499</td>
<td style="text-align: right;">1122.1</td>
</tr>
<tr>
<td>vector</td>
<td style="text-align: right;">1.947</td>
<td style="text-align: right;">0.011</td>
<td style="text-align: right;">87301.0</td>
</tr>
<tr>
<td>hybrid</td>
<td style="text-align: right;">2.190</td>
<td style="text-align: right;">0.585</td>
<td style="text-align: right;">986.0</td>
</tr>
</tbody>
</table>
<p><img alt="Python modes at 50k" src="/blog/ai-explorations/posts/2026-03-04-vajra-rust-v021-build-improvements/images/python_modes_50k_v021.svg" /></p>
<p>This benchmark is intentionally narrow and controlled. For cross-engine comparisons, the Wikipedia + MiniLM setup is more representative.</p>
<h2 id="build-profile-semantics">Build Profile Semantics</h2>
<p>The three profiles are intended as operational modes:</p>
<ul>
<li><code>quality</code>: maximize recall stability at higher construction cost.</li>
<li><code>fast</code>: reduce construction cost while retaining useful recall.</li>
<li><code>instant</code>: minimize startup/build latency, accepting larger recall loss.</li>
</ul>
<p>In production terms, this is a control-plane decision:<br />
start quickly with <code>instant</code> or <code>fast</code>, then promote to <code>quality</code> when rebuild completes.</p>
<h2 id="what-the-current-numbers-imply">What the current numbers imply</h2>
<p>For 50k vectors in the current setup:</p>
<ol>
<li>Build latency is still the principal gap versus ZVec.</li>
<li>Query latency/QPS are already competitive for Vajra in these runs.</li>
<li>Recall behavior matches design intent: <code>quality</code> near reference, <code>fast</code> middle, <code>instant</code> lowest.</li>
</ol>
<p>The practical takeaway is straightforward: the next phase should continue to focus on construction-path parallelization and pruning efficiency, because that is where the largest remaining gap sits.</p>
<h2 id="reproducibility-where-the-wikipedia-data-comes-from">Reproducibility: Where the Wikipedia Data Comes From</h2>
<p>The data process follows the same benchmark discipline used in <code>vajra_bm25</code>:</p>
<ol>
<li>Load Wikipedia corpora through <code>ir_datasets</code> (WikIR sets like <code>wikir/en78k</code>, with fallbacks).</li>
<li>Normalize documents into stable JSONL snapshots.</li>
<li>Generate and lock embeddings (<code>all-MiniLM-L6-v2</code>, 384d).</li>
<li>Reuse fixed slices (1k, 10k, 20k, 50k) across all runs.</li>
</ol>
<p>Useful references:</p>
<ol>
<li><a href="https://github.com/aiexplorations/vajra_bm25/blob/main/benchmarks/download_wikipedia.py"><code>vajra_bm25/benchmarks/download_wikipedia.py</code></a></li>
<li><a href="https://github.com/aiexplorations/ir_benchmark_data"><code>ir_benchmark_data</code></a></li>
<li><a href="https://github.com/aiexplorations/zvec_vajra_benchmark"><code>zvec_vajra_benchmark</code></a></li>
<li><a href="https://github.com/aiexplorations/vajra_search_engine/blob/main/reproduction.md"><code>vajra_search_engine/reproduction.md</code></a></li>
</ol>
<h2 id="concluding-remarks">Concluding Remarks</h2>
<p>This phase does not close the full build-time gap, but it does establish a clear and useful baseline:</p>
<ul>
<li>Python remains the interface layer.</li>
<li>Rust is the execution layer for HNSW build/search kernels.</li>
<li>Profile selection is an explicit control for build-latency/recall trade-offs.</li>
</ul>
<p>That is a good place to continue from. This way, we have a  stable API, measurable profile trade-offs, and a construction path that is now much easier to optimize incrementally. I spent some time building out the test pipeline for this, so that we can provably measure recall and other performance, and this has become a repeatable asset.</p>
<h2 id="references">References</h2>
<ol>
<li id="ref-1"><a href="https://github.com/aiexplorations/vajra_bm25/blob/main/docs/vajra_benchmark_comparison.md">Vajra BM25 benchmark report</a><br /></li>
<li id="ref-2"><a href="/blog/ai-explorations/2025-12-24-vajra-bm25.html">Vajra BM25 architecture post (Dec 24, 2025)</a><br /></li>
<li id="ref-3"><a href="/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html">Vajra vector search v0.4.1 -&gt; v0.5.0 post (Feb 22, 2026)</a><br /></li>
<li id="ref-4"><a href="/blog/ai-explorations/2026-02-23-vajra-rust-architecture-benchmarks.html">Vajra Rust architecture post (Feb 23, 2026)</a><br /></li>
<li id="ref-5"><a href="https://github.com/alibaba/zvec">ZVec repository</a><br /></li>
<li id="ref-6"><a href="https://www.databricks.com/glossary/lambda-architecture">Databricks glossary: Lambda Architecture</a><br /></li>
<li id="ref-7"><a href="https://github.com/aiexplorations/vajra_search_engine/tree/main/plans">Rust build-improvement plans</a><br /></li>
</ol>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>vajra</category>
      <category>vajra-search</category>
      <category>rust</category>
      <category>hnsw</category>
      <category>vector-search</category>
      <category>benchmarks</category>
      <category>zvec</category>
      <category>bm25</category>
      <category>hybrid-search</category>
      <category>pyo3</category>
      <category>wikipedia</category>
      <category>information-retrieval</category>
    </item>
    <item>
      <title>From Vajra v0.4.1 to v0.5.0 to Rust backend: architecture notes and benchmark results</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-02-23-vajra-rust-architecture-benchmarks.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-02-23-vajra-rust-architecture-benchmarks.html</guid>
      <pubDate>Mon, 23 Feb 2026 00:00:00 GMT</pubDate>
      <description>Technical notes on the Vajra search stack transition from BM25 benchmarks and Python vector search (v0.4.1/v0.5.0) to the Rust HNSW backend (`vajra_search`), with reproducible Wikipedia results.</description>
      <content:encoded><![CDATA[<blockquote>
<p><strong>Update (Mar 4, 2026):</strong> A follow-up with <code>vajra-search v0.2.1</code> build improvements and refreshed ZVec corpus-scale benchmarks is here: <a href="/blog/ai-explorations/2026-03-04-vajra-rust-v021-build-improvements.html">Vajra Search v0.2.1 update</a>.</p>
</blockquote>
<p>This post documents the technical continuity from:</p>
<ol>
<li>Vajra lexical benchmarking work (BM25 engine comparisons),</li>
<li>Vajra vector search v0.4.1 and v0.5.0 in Python,</li>
<li>Rust-backed <code>vajra_search</code> HNSW with PyO3 bindings.</li>
</ol>
<p>It focuses on engineering decisions and measured behavior, not language advocacy.</p>
<p>A detailed preprint version of this work is being prepared for arXiv submission.</p>
<p>The intended audience is engineers who care about retrieval systems in production: index build windows, latency envelopes, recall stability, and integration constraints.</p>
<h2 id="1-continuity-bm25-vector-search-rust-backend">1) Continuity: BM25 -&gt; vector search -&gt; Rust backend</h2>
<div class="mermaid-asset" style="--mermaid-natural-width: 1516px"><img src="/blog/ai-explorations/posts/2026-02-23-vajra-rust-architecture-benchmarks/images/mermaid/mermaid-01-ea90cbff2143.svg" alt="Mermaid diagram 1 for From Vajra v0.4.1 to v0.5.0 to Rust backend: architecture notes and benchmark results" width="1516" height="302" decoding="async"></div>

<h2 id="2-prior-benchmark-context-before-vector-v041">2) Prior benchmark context (before vector v0.4.1)</h2>
<p>Before the vector-search re-engineering, Vajra was benchmarked as a BM25 engine against multiple systems. The original technical report is here:</p>
<ul>
<li><a href="https://github.com/aiexplorations/vajra_bm25/blob/main/docs/vajra_benchmark_comparison.md">Vajra BM25 benchmark and technical comparison report (original)</a></li>
</ul>
<p>That report includes comparisons against:</p>
<ul>
<li><code>bm25s</code></li>
<li><code>bm25s-parallel</code></li>
<li><code>tantivy</code></li>
<li><code>pyserini</code></li>
<li><code>vajra-parallel</code></li>
</ul>
<p>A compact snapshot from that phase:</p>
<table>
<thead>
<tr>
<th>Dataset family</th>
<th>Engines compared</th>
<th>Main takeaway</th>
</tr>
</thead>
<tbody>
<tr>
<td>BEIR (SciFact, NFCorpus)</td>
<td>Vajra, BM25S, Tantivy, Pyserini</td>
<td>Pyserini often led accuracy; Vajra emphasized latency/QPS</td>
</tr>
<tr>
<td>Wikipedia 200k/500k</td>
<td>Vajra, BM25S, Tantivy, Pyserini</td>
<td>Distinct build/latency/accuracy trade-offs across engines</td>
</tr>
</tbody>
</table>
<p>This prior work matters because it established a benchmarking discipline used again for vector search: explicit baselines, fixed query protocol, and metric-level reporting.</p>
<p>That continuity matters because the Rust work should not be interpreted as a fresh benchmark starting point. It is a continuation of the same measurement practice and the same operational questions from the BM25 phase: what do we gain, what do we lose, and what trade-offs can be controlled explicitly.</p>
<h2 id="3-zvec-as-the-trigger-for-v050-re-engineering">3) ZVec as the trigger for v0.5.0 re-engineering</h2>
<p>The initial Feb 22 baseline established the v0.4.1 gap against ZVec at 10k vectors.</p>
<ul>
<li><a href="/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html">Re-engineering Vajra's vector search with inspiration from ZVec (baseline + deep profiling)</a></li>
</ul>
<p>That same post contains the full v0.5.0 optimization walkthrough (six implementation fixes), which is the canonical source for the Python-side gains before Rust.</p>
<h3 id="31-baseline-v041">3.1 Baseline (v0.4.1)</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th style="text-align: right;">ZVec</th>
<th style="text-align: right;">Vajra v0.4.1</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build time</td>
<td style="text-align: right;">0.47 s</td>
<td style="text-align: right;">97.77 s</td>
</tr>
<tr>
<td>p50 query</td>
<td style="text-align: right;">0.31 ms</td>
<td style="text-align: right;">2.63 ms</td>
</tr>
<tr>
<td>Recall@10</td>
<td style="text-align: right;">1.000</td>
<td style="text-align: right;">0.987</td>
</tr>
</tbody>
</table>
<h3 id="32-gains-in-v050-from-the-separate-profiling-post">3.2 Gains in v0.5.0 (from the separate profiling post)</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th style="text-align: right;">ZVec</th>
<th style="text-align: right;">Vajra v0.4.1</th>
<th style="text-align: right;">Vajra v0.5.0</th>
<th style="text-align: right;">v0.4.1 -&gt; v0.5.0 gain</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build time</td>
<td style="text-align: right;">0.47 s</td>
<td style="text-align: right;">97.77 s</td>
<td style="text-align: right;">17.03 s</td>
<td style="text-align: right;">5.7x faster</td>
</tr>
<tr>
<td>p50 query</td>
<td style="text-align: right;">0.31 ms</td>
<td style="text-align: right;">2.63 ms</td>
<td style="text-align: right;">0.51 ms</td>
<td style="text-align: right;">5.2x faster</td>
</tr>
<tr>
<td>p95 query</td>
<td style="text-align: right;">0.42 ms</td>
<td style="text-align: right;">3.77 ms</td>
<td style="text-align: right;">0.71 ms</td>
<td style="text-align: right;">5.3x faster</td>
</tr>
<tr>
<td>p99 query</td>
<td style="text-align: right;">0.53 ms</td>
<td style="text-align: right;">4.30 ms</td>
<td style="text-align: right;">0.86 ms</td>
<td style="text-align: right;">5.0x faster</td>
</tr>
<tr>
<td>QPS</td>
<td style="text-align: right;">3,058</td>
<td style="text-align: right;">374</td>
<td style="text-align: right;">1,911</td>
<td style="text-align: right;">5.1x higher</td>
</tr>
<tr>
<td>Recall@10</td>
<td style="text-align: right;">1.000</td>
<td style="text-align: right;">0.987</td>
<td style="text-align: right;">0.997</td>
<td style="text-align: right;">+1.0%</td>
</tr>
</tbody>
</table>
<p>Net effect from that stage:</p>
<ul>
<li>Build gap to ZVec reduced from roughly 207x to about 36x.</li>
<li>Query gap to ZVec reduced from roughly 8.5x to about 1.6x.</li>
</ul>
<p>At this stage, the engineering question changed from "can Python be optimized?" to "which remaining costs are structural and should move to a systems layer?"</p>
<h2 id="4-why-move-to-rust-after-v050">4) Why move to Rust after v0.5.0</h2>
<p>After v0.5.0, two constraints remained:</p>
<ol>
<li>per-step interpreter overhead in HNSW hot loops,</li>
<li>mutable graph updates under heavy insertion/search workloads.</li>
</ol>
<p>Rust was used to address those constraints while keeping the Python interface stable.</p>
<p>The design goal was not to replace the Python developer surface. The goal was to preserve the same API ergonomics while moving the high-frequency ANN primitives (graph traversal, candidate-heap mutation, distance dispatch) into a lower-overhead execution layer.</p>
<h3 id="41-architectural-motivations">4.1 Architectural motivations</h3>
<ul>
<li><strong>Predictable memory behavior:</strong> contiguous vector storage and explicit layered adjacency reduce pointer-chasing variance in traversal-heavy loops.</li>
<li><strong>Lower hot-path overhead:</strong> candidate-heap updates and distance dispatch run in compiled code rather than Python-level dispatch.</li>
<li><strong>Safer mutation semantics:</strong> Rust ownership/borrowing constraints make graph-update paths easier to reason about under heavy insert/search cycles.</li>
<li><strong>Throughput headroom:</strong> batch query paths can use native parallel execution (Rayon) without changing the Python API.</li>
<li><strong>No API break for users:</strong> PyO3 keeps the Python entry points stable while delegating execution to Rust.</li>
</ul>
<h3 id="42-python-rust-integration-map">4.2 Python-Rust integration map</h3>
<div class="mermaid-asset" style="--mermaid-natural-width: 968px"><img src="/blog/ai-explorations/posts/2026-02-23-vajra-rust-architecture-benchmarks/images/mermaid/mermaid-02-ffa6a14bcecd.svg" alt="Mermaid diagram 2 for From Vajra v0.4.1 to v0.5.0 to Rust backend: architecture notes and benchmark results" width="968" height="827" decoding="async"></div>

<h3 id="43-custom-hnsw-implementation-notes">4.3 Custom HNSW implementation notes</h3>
<p>The Rust HNSW implementation in <code>crates/vajra-hnsw</code> is not a wrapper around an external ANN library. It is implemented directly for this stack, including:</p>
<ul>
<li>graph storage (<code>Vec&lt;f32&gt;</code> vectors + layered adjacency),</li>
<li>upper-layer greedy descent,</li>
<li>layer-0 beam search (<code>beam_search_fast</code>),</li>
<li>profile parameter surface (<code>M</code>, <code>ef_construction</code>, <code>ef_search</code>, <code>use_heuristic</code>),</li>
<li>explicit coalgebra reference path for parity and verification.</li>
</ul>
<p>In practice, this means benchmark deltas can be attributed to Vajra's implementation choices instead of hidden behavior in a third-party ANN binding.</p>
<h2 id="5-critical-path-differences-quality-vs-fast-vs-instant">5) Critical path differences: <code>quality</code> vs <code>fast</code> vs <code>instant</code></h2>
<p>Profiles are not just parameter presets; they change traversal work in both build and query paths.</p>
<p><code>M</code> and <code>ef_construction</code> primarily shape insertion/build work (graph connectivity and candidate exploration), while <code>ef_search</code> shapes query-time frontier size. Disabling heuristics changes neighbor-selection cost and can materially reduce CPU work at some recall cost.</p>
<table>
<thead>
<tr>
<th>Profile</th>
<th>Build critical path</th>
<th>Query critical path</th>
<th>50k result</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>quality</code></td>
<td>higher connectivity + wider exploration during insertion</td>
<td>larger candidate/result frontier for higher recall</td>
<td>81.343s build, 0.235ms p50, Recall@10 0.998</td>
</tr>
<tr>
<td><code>fast</code></td>
<td>same <code>M</code>/construction beam as quality, but less expensive neighbor heuristics</td>
<td>reduced per-step overhead with moderate frontier quality</td>
<td>28.512s build, 0.184ms p50, Recall@10 0.948</td>
</tr>
<tr>
<td><code>instant</code></td>
<td>smaller graph degree and smaller beams reduce insertion work aggressively</td>
<td>smallest frontier, lowest traversal cost</td>
<td>6.777s build, 0.113ms p50, Recall@10 0.706</td>
</tr>
</tbody>
</table>
<p>A practical operating pattern follows:</p>
<ol>
<li>boot with <code>instant</code> for low startup latency,</li>
<li>rebuild asynchronously with <code>quality</code>,</li>
<li>atomically swap index handles when quality build completes.</li>
</ol>
<p>This pattern should be read as an explicit control-plane design: startup latency and final recall are treated as separate objectives with a deterministic handoff.</p>
<h3 id="51-formalization-profile-lambda-retrieval-architecture-plra">5.1 Formalization: Profile-Lambda Retrieval Architecture (PLRA)</h3>
<p>The deployment pattern above is structurally similar to Lambda Architecture in data engineering (<a href="https://www.databricks.com/glossary/lambda-architecture">Databricks glossary</a>), but adapted for ANN index lifecycle rather than stream/batch ETL.</p>
<p>I refer to this as <strong>Profile-Lambda Retrieval Architecture (PLRA)</strong>:</p>
<ul>
<li><strong>Serving/Instant path</strong>: minimal-connectivity index for immediate availability.</li>
<li><strong>Speed/Fast path</strong>: moderate-cost index for better steady latency/recall while rebuilds continue.</li>
<li><strong>Batch/Quality path</strong>: high-recall full build in the background.</li>
<li><strong>Reconciliation</strong>: atomic index-handle swap when quality build is ready.</li>
</ul>
<div class="mermaid-asset" style="--mermaid-natural-width: 1299px"><img src="/blog/ai-explorations/posts/2026-02-23-vajra-rust-architecture-benchmarks/images/mermaid/mermaid-03-c2d8114928f7.svg" alt="Mermaid diagram 3 for From Vajra v0.4.1 to v0.5.0 to Rust backend: architecture notes and benchmark results" width="1299" height="124" decoding="async"></div>

<p>This gives a reproducible control surface: startup SLOs are decoupled from final ranking quality, and quality can be converged asynchronously.</p>
<p>Conceptually, PLRA is useful because it turns a tuning trade-off into an architecture primitive: profile transitions become part of deployment policy rather than ad-hoc runtime tweaking.</p>
<h2 id="6-current-rust-benchmark-snapshot-wikipedia-vectors">6) Current Rust benchmark snapshot (Wikipedia vectors)</h2>
<p>Benchmarks were run for 1k, 10k, 20k, and 50k vectors.</p>
<h3 id="61-buildrecall-at-50k">6.1 Build/recall at 50k</h3>
<table>
<thead>
<tr>
<th>Engine/Profile</th>
<th style="text-align: right;">Build (s)</th>
<th style="text-align: right;">Recall@10</th>
</tr>
</thead>
<tbody>
<tr>
<td>ZVec</td>
<td style="text-align: right;">2.481</td>
<td style="text-align: right;">1.000</td>
</tr>
<tr>
<td>Vajra quality</td>
<td style="text-align: right;">81.343</td>
<td style="text-align: right;">0.998</td>
</tr>
<tr>
<td>Vajra fast</td>
<td style="text-align: right;">28.512</td>
<td style="text-align: right;">0.948</td>
</tr>
<tr>
<td>Vajra instant</td>
<td style="text-align: right;">6.777</td>
<td style="text-align: right;">0.706</td>
</tr>
</tbody>
</table>
<h3 id="62-query-at-50k">6.2 Query at 50k</h3>
<table>
<thead>
<tr>
<th>Engine/Profile</th>
<th style="text-align: right;">p50 (ms)</th>
<th style="text-align: right;">QPS</th>
</tr>
</thead>
<tbody>
<tr>
<td>ZVec</td>
<td style="text-align: right;">0.774</td>
<td style="text-align: right;">1305.2</td>
</tr>
<tr>
<td>Vajra quality</td>
<td style="text-align: right;">0.235</td>
<td style="text-align: right;">4241.8</td>
</tr>
<tr>
<td>Vajra fast</td>
<td style="text-align: right;">0.184</td>
<td style="text-align: right;">5325.9</td>
</tr>
<tr>
<td>Vajra instant</td>
<td style="text-align: right;">0.113</td>
<td style="text-align: right;">8624.7</td>
</tr>
</tbody>
</table>
<h3 id="63-figures">6.3 Figures</h3>
<p><img alt="Build time scaling" src="/blog/ai-explorations/posts/2026-02-23-vajra-rust-architecture-benchmarks/images/build_time_scaling.png" /></p>
<p><img alt="Recall vs size" src="/blog/ai-explorations/posts/2026-02-23-vajra-rust-architecture-benchmarks/images/recall_vs_size.png" /></p>
<p><img alt="Latency and QPS scaling" src="/blog/ai-explorations/posts/2026-02-23-vajra-rust-architecture-benchmarks/images/latency_qps_scaling.png" /></p>
<p>Reading these plots together:</p>
<ul>
<li>Build time separates profiles the most as corpus size grows.</li>
<li>Query latency stays low for all Vajra profiles in this setup.</li>
<li>Recall degradation is concentrated in <code>instant</code>, while <code>quality</code> remains close to the reference.</li>
<li><code>fast</code> acts as a middle operating point for teams that need lower build cost without dropping to <code>instant</code> recall.</li>
</ul>
<h2 id="7-reproduction-notes-wikipedia-data-source">7) Reproduction notes (Wikipedia data source)</h2>
<p>The benchmark corpus slices are sourced from <code>ir_benchmark_data</code>, which is built from Wikipedia-focused IR corpora used in prior Vajra benchmarking.</p>
<p>At a high level:</p>
<ol>
<li>Acquire documents from WikIR corpora through <code>ir_datasets</code> (for example <code>wikir/en78k</code>, with smaller fallback sets).</li>
<li>Normalize into JSONL snapshots with stable fields (<code>id</code>, <code>title</code>, <code>content</code>, <code>metadata</code>).</li>
<li>Generate embeddings (<code>all-MiniLM-L6-v2</code>, 384d) and keep the same embedding cache across engine/profile runs.</li>
<li>
<p>Slice consistently into 1k/10k/20k/50k subsets for direct comparisons.</p>
</li>
<li>
<p>Data location used locally: <code>~/Github/ir_benchmark_data</code></p>
</li>
<li>Benchmark harness location: <code>~/Github/zvec_vajra_benchmark</code></li>
</ol>
<p>Detailed reproducibility instructions (including expected directory wiring and commands) are provided in:</p>
<ul>
<li><a href="https://github.com/aiexplorations/vajra_search_engine/blob/main/reproduction.md">vajra_search_engine/reproduction.md</a></li>
</ul>
<h2 id="8-references">8) References</h2>
<ul>
<li><a href="https://github.com/aiexplorations/vajra_bm25/blob/main/docs/vajra_benchmark_comparison.md">Vajra BM25 benchmark and technical comparison report (original)</a></li>
<li><a href="/blog/ai-explorations/2025-12-24-vajra-bm25.html">Vajra BM25 architecture post (Dec 24, 2025)</a></li>
<li><a href="/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html">Vajra vector search v0.4.1 -&gt; v0.5.0 engineering post (Feb 22, 2026)</a></li>
<li><a href="https://www.databricks.com/glossary/lambda-architecture">Databricks: Lambda Architecture</a></li>
<li><a href="https://github.com/alibaba/zvec">ZVec</a></li>
</ul>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>vajra</category>
      <category>rust</category>
      <category>hnsw</category>
      <category>vector-search</category>
      <category>pyo3</category>
      <category>benchmarks</category>
      <category>wikipedia</category>
      <category>information-retrieval</category>
      <category>zvec</category>
      <category>pyserini</category>
      <category>bm25s</category>
      <category>tantivy</category>
    </item>
    <item>
      <title>Re-engineering Vajra&apos;s vector search with inspiration from ZVec</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-02-22-vajra-vector-search-performance.html</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate>
      <description>Vajra&apos;s HNSW vector search started 217× slower to build and 8× slower to query than ZVec. Six targeted engineering changes brought it to 36× and 1.6× respectively — without rewriting in C++ or adding Numba.</description>
      <content:encoded><![CDATA[<p>Vajra BM25 started as an experiment in using category theory to organize a search engine's code. The previous post covered the BM25 lexical search side of that story. Vajra also ships a native HNSW vector index, written in pure Python and NumPy, that I've been comparing against <a href="https://github.com/alibaba/zvec">ZVec</a>, Alibaba's high-performance vector database backed by the Proxima C++ engine.</p>
<p>The initial comparison produced numbers worth understanding. After six targeted fixes, the picture changed materially.</p>
<h2 id="zvec-what-c-disk-backed-hnsw-looks-like">ZVec: What C++ Disk-Backed HNSW Looks Like</h2>
<p>ZVec (v0.2.0) uses Proxima, Alibaba's production vector search engine, as its C++ backend. Its architecture is worth understanding because it explains why the performance is so different from a Python implementation.</p>
<p>ZVec stores the vector index on disk using memory-mapped files. <code>zvec.open()</code> is essentially an <code>mmap</code> syscall: it maps the on-disk file into the process's virtual address space and returns in milliseconds. No data is loaded into RAM at open time. The OS page cache handles the rest: when a query accesses a region of the index for the first time, the kernel loads the corresponding disk page into RAM, making it available at memory speed for all subsequent accesses.</p>
<p>The insert and HNSW construction pipeline is pure C++. Distance computations use SIMD (Single Instruction, Multiple Data) intrinsics: CPU instructions that apply the same operation to multiple values simultaneously. On modern x86-64 hardware with AVX2, a single SIMD instruction computes eight float32 dot-product contributions in parallel. Proxima's distance kernels (the low-level functions that compute cosine, L2, or inner product distances between vectors) are hand-tuned to issue these instructions in tight loops with no Python overhead.</p>
<p>The warmup step in the benchmark discards the first 10 query results. When the index is freshly opened, the data pages have not yet been loaded into the OS page cache. The first few queries trigger page faults and disk reads, adding latency that does not represent steady-state performance. After 10 queries, the frequently accessed pages (entry point, upper-layer graph nodes, and the hot region of layer-0 neighbours) are resident in RAM. The post-warmup latency is what ZVec delivers to a running service.</p>
<p>On 10,000 Wikipedia documents (all-MiniLM-L6-v2, 384d, cosine, M=16, ef_construction=200):</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>ZVec</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build time</td>
<td>0.47 s</td>
</tr>
<tr>
<td>p50 query</td>
<td>0.31 ms</td>
</tr>
<tr>
<td>p99 query</td>
<td>0.53 ms</td>
</tr>
<tr>
<td>QPS</td>
<td>3,058</td>
</tr>
<tr>
<td>Recall@10</td>
<td>1.000</td>
</tr>
</tbody>
</table>
<p>The build time includes all inserts, HNSW graph construction, and on-disk persistence. The query numbers are post-warmup, with the relevant pages in cache.</p>
<h2 id="vajras-vector-search">Vajra's Vector Search</h2>
<p>Vajra's <code>NativeHNSWIndex</code> implements HNSW in pure Python and NumPy. Its internal architecture uses a coalgebraic abstraction for search: <code>HNSWNavigationCoalgebra</code> unfolds a beam search from an <code>HNSWSearchState</code>, which is a frozen dataclass containing the candidates heap, results heap, and visited set. This keeps the search logic clean, composable, and testable in isolation.</p>
<p>The index stores vectors in a NumPy float32 array, supports cosine, L2, and inner product metrics, and serializes to disk with pickle. It integrates directly with Vajra's <code>VajraVectorSearch</code> and <code>HybridSearchEngine</code> (BM25 + vector fusion) classes.</p>
<p>The initial benchmark results against ZVec revealed four problems.</p>
<h2 id="the-baseline-where-things-stood">The Baseline: Where Things Stood</h2>
<p>Running the same 10,000-document benchmark:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>ZVec</th>
<th>Vajra (v0.4.1)</th>
<th>Gap</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build time</td>
<td>0.47 s</td>
<td>97.77 s</td>
<td><strong>207×</strong></td>
</tr>
<tr>
<td>p50 query</td>
<td>0.31 ms</td>
<td>2.63 ms</td>
<td><strong>8.5×</strong></td>
</tr>
<tr>
<td>p99 query</td>
<td>0.53 ms</td>
<td>4.30 ms</td>
<td><strong>8.1×</strong></td>
</tr>
<tr>
<td>QPS</td>
<td>3,058</td>
<td>374</td>
<td><strong>8.2×</strong></td>
</tr>
<tr>
<td>Recall@10</td>
<td>1.000</td>
<td>0.987</td>
<td>−1.3%</td>
</tr>
</tbody>
</table>
<p>The 207× build gap was informative. A C++ HNSW implementation is not 200× more clever algorithmically: both run the same graph construction. A gap of that size points to something wrong in the Python implementation, not a fundamental language barrier. The 8× query gap was larger than a Python-vs-C++ interpreter comparison alone would predict, which pointed to the same conclusion. Profiling confirmed it: most of the gap came from specific implementation defects, not the algorithm.</p>
<h2 id="root-causes-four-problems">Root Causes: Four Problems</h2>
<h3 id="problem-1-on2-array-growth-during-build">Problem 1: O(N²) Array Growth During Build</h3>
<p>The most damaging bug. <code>_insert_vector()</code> was called once per node during <code>add()</code>. Each call did this:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Called N times. Every call copies ALL existing vectors.</span>
<span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">vstack</span><span class="p">([</span><span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span><span class="p">,</span> <span class="n">vector</span><span class="p">])</span>
</code></pre></div>

<p>For N=10,000 with 384-dimensional float32 vectors, the total bytes copied are:</p>
<div class="codehilite"><pre><span></span><code>sum(i * 1536 bytes for i in range(10000)) ≈ 73 GB
</code></pre></div>

<p>This explains almost the entire 97-second build time. At 10 GB/s effective NumPy throughput inside a Python loop, 73 GB of copies takes roughly 7 seconds from memory bandwidth alone. The Python interpreter overhead per call added the rest.</p>
<p>The fix: <code>add()</code> already receives all N vectors as a single array. Pre-allocate the full block once.</p>
<div class="codehilite"><pre><span></span><code><span class="n">n_new</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">ids</span><span class="p">)</span>
<span class="n">existing</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">ids</span><span class="p">)</span>

<span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span> <span class="ow">is</span> <span class="kc">None</span><span class="p">:</span>
    <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">empty</span><span class="p">((</span><span class="n">n_new</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dimension</span><span class="p">),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">float32</span><span class="p">)</span>
<span class="k">else</span><span class="p">:</span>
    <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">concatenate</span><span class="p">(</span>
        <span class="p">[</span><span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span><span class="p">,</span> <span class="n">np</span><span class="o">.</span><span class="n">empty</span><span class="p">((</span><span class="n">n_new</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">_dimension</span><span class="p">),</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">float32</span><span class="p">)]</span>
    <span class="p">)</span>
<span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span><span class="p">[</span><span class="n">existing</span> <span class="p">:</span> <span class="n">existing</span> <span class="o">+</span> <span class="n">n_new</span><span class="p">]</span> <span class="o">=</span> <span class="n">vectors</span>
</code></pre></div>

<p>The insertion loop then calls <code>_insert_node(existing + i, ids[i])</code>, which reads from the pre-allocated array rather than extending it. N incremental <code>vstack</code> calls become one allocation and one bulk fill.</p>
<h3 id="problem-2-redundant-norm-computation-on-pre-normalised-vectors">Problem 2: Redundant Norm Computation on Pre-Normalised Vectors</h3>
<p>After fixing the vstack issue, build time dropped from 97 seconds to around 83 seconds. That improvement was smaller than expected, so profiling continued.</p>
<p>Running <code>cProfile</code> on a 2,000-document build and sorting by <code>tottime</code> (time spent inside each function, excluding callees) produced this:</p>
<div class="codehilite"><pre><span></span><code>ncalls   tottime   filename:function
  2132    1.459s   _search_layer
28918263   57.3s   np.linalg.norm        ← 28 million calls
 313447    2.198s   score_batch_normalized
</code></pre></div>

<p>Twenty-eight million calls to <code>np.linalg.norm</code> during a 2,000-document build. Scaled to 10,000 documents, that accounts for almost the entire remaining build time.</p>
<p>Tracing where these calls came from led to <code>score_batch</code>, the cosine distance function called inside the beam search:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Called ~14 million times during build at N=10k.</span>
<span class="c1"># Recomputes np.linalg.norm on vectors that are already L2-normalised.</span>
<span class="k">def</span><span class="w"> </span><span class="nf">score_batch</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span> <span class="n">vectors</span><span class="p">):</span>
    <span class="n">query_norm</span> <span class="o">=</span> <span class="n">query</span> <span class="o">/</span> <span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">linalg</span><span class="o">.</span><span class="n">norm</span><span class="p">(</span><span class="n">query</span><span class="p">)</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">eps</span><span class="p">)</span>
    <span class="n">norms</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">linalg</span><span class="o">.</span><span class="n">norm</span><span class="p">(</span><span class="n">vectors</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">1</span><span class="p">,</span> <span class="n">keepdims</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
    <span class="n">vectors_norm</span> <span class="o">=</span> <span class="n">vectors</span> <span class="o">/</span> <span class="p">(</span><span class="n">norms</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">eps</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">vectors_norm</span> <span class="o">@</span> <span class="n">query_norm</span><span class="p">)</span><span class="o">.</span><span class="n">astype</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">float32</span><span class="p">)</span>
</code></pre></div>

<p>The general case for cosine similarity requires normalising both the query and the stored vectors before taking the dot product. <code>score_batch</code> does this correctly for the general case. The problem: <code>add()</code> already normalises all incoming vectors to unit length before inserting them into the graph. Every vector in <code>self.graph.vectors</code> is already L2-normalised. The <code>np.linalg.norm(vectors, axis=1)</code> line in <code>score_batch</code> was recomputing norms on data that was provably unit-length, throwing away the work done at insertion time on every distance call during construction.</p>
<p>The fix: when the metric is cosine, store the distance function as a specialised lambda that skips normalisation entirely. For two unit vectors, cosine similarity reduces to a plain dot product:</p>
<div class="codehilite"><pre><span></span><code>cos(a, b) = (a · b) / (||a|| × ||b||) = a · b    (when ||a|| = ||b|| = 1)
</code></pre></div>

<div class="codehilite"><pre><span></span><code><span class="c1"># In __init__, for metric=&quot;cosine&quot;:</span>
<span class="n">_scorer</span> <span class="o">=</span> <span class="n">CosineSimilarity</span><span class="p">()</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_distance_single</span> <span class="o">=</span> <span class="k">lambda</span> <span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">:</span> <span class="nb">float</span><span class="p">(</span><span class="mf">1.0</span> <span class="o">-</span> <span class="nb">float</span><span class="p">(</span><span class="n">np</span><span class="o">.</span><span class="n">dot</span><span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)))</span>
<span class="bp">self</span><span class="o">.</span><span class="n">_distance_batch</span> <span class="o">=</span> <span class="k">lambda</span> <span class="n">q</span><span class="p">,</span> <span class="n">vs</span><span class="p">:</span> <span class="mf">1.0</span> <span class="o">-</span> <span class="n">_scorer</span><span class="o">.</span><span class="n">score_batch_normalized</span><span class="p">(</span><span class="n">q</span><span class="p">,</span> <span class="n">vs</span><span class="p">)</span>
</code></pre></div>

<p>Where <code>score_batch_normalized</code> is a single line: <code>return (vectors @ query).astype(np.float32, copy=False)</code>.</p>
<p>The <code>copy=False</code> matters: without it, each call creates a redundant array copy even when the result is already float32. At 1.5 million calls during a 10k build, those copies add up.</p>
<h3 id="problem-3-per-neighbour-python-dispatch-in-the-beam-search">Problem 3: Per-Neighbour Python Dispatch in the Beam Search</h3>
<p>The beam search in <code>_search_layer</code> originally computed one distance call per neighbour:</p>
<div class="codehilite"><pre><span></span><code><span class="k">for</span> <span class="n">neighbor</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">get_neighbors</span><span class="p">(</span><span class="n">current</span><span class="p">,</span> <span class="n">level</span><span class="p">):</span>
    <span class="k">if</span> <span class="n">neighbor</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">visited</span><span class="p">:</span>
        <span class="n">dist</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_distance_single</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span><span class="p">[</span><span class="n">neighbor</span><span class="p">])</span>
        <span class="c1"># ... heap update</span>
</code></pre></div>

<p>At ef_construction=200 with M=16 neighbours per node, each node insertion triggers roughly 200 × 16 = 3,200 individual Python function calls for distance computation. At N=10,000, that is 32 million individual calls.</p>
<p>The fix: collect all unvisited neighbours first, then compute distances in one batch call:</p>
<div class="codehilite"><pre><span></span><code><span class="n">unvisited</span> <span class="o">=</span> <span class="p">[</span><span class="n">n</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">layer_dict</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">current</span><span class="p">,</span> <span class="p">[])</span> <span class="k">if</span> <span class="n">n</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">visited</span><span class="p">]</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">unvisited</span><span class="p">:</span>
    <span class="k">continue</span>
<span class="n">visited</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">unvisited</span><span class="p">)</span>
<span class="n">neighbor_dists</span> <span class="o">=</span> <span class="n">dist_fn</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">vectors</span><span class="p">[</span><span class="n">unvisited</span><span class="p">])</span>
</code></pre></div>

<p>One NumPy matrix-vector multiply replacing 16 individual calls. BLAS vectorises the dot products; Python call overhead drops by 16×.</p>
<p>Note the <code>layer_dict</code> here: caching <code>self.graph.layers[level]</code> as a local dict avoids the <code>get_neighbors()</code> function call overhead on every beam step (~2 million calls at N=10k). Same pattern for <code>vectors = self.graph.vectors</code> and <code>dist_fn = self._distance_batch</code>.</p>
<h3 id="problem-4-immutable-coalgebra-state-in-the-query-hot-path">Problem 4: Immutable Coalgebra State in the Query Hot Path</h3>
<p>The build problems explained the gap at construction time. The 8× query latency gap had a different root cause: the <code>HNSWNavigationCoalgebra</code> implementation.</p>
<p>To understand why, it helps to understand what the coalgebra is doing. In Vajra's design, HNSW search is modelled as a coalgebraic unfolding: starting from an initial state, a transition function produces the next state at each step until a termination condition is met. This is a clean, mathematical way to structure the beam search. The state at each step is an <code>HNSWSearchState</code>, a frozen Python dataclass with three fields:</p>
<ul>
<li><code>candidates</code>: the priority queue of nodes to explore next (the "open set")</li>
<li><code>results</code>: the current best-ef results found so far</li>
<li><code>visited</code>: the set of nodes already evaluated</li>
</ul>
<p><code>HNSWSearchState</code> is <strong>frozen</strong>: a frozen dataclass cannot be modified after creation. This is intentional. Immutable state objects are composable, independently testable, and safe to reason about. You can snapshot any intermediate state during a search, replay it, or pass it to a separate function for analysis. This makes the coalgebra a good abstraction for the documented public interface.</p>
<p>The performance cost is in the transition step. To advance from step k to step k+1, the coalgebra creates a brand-new <code>HNSWSearchState</code>. The <code>visited</code> field is a <code>frozenset</code>, Python's immutable set type. Updating it to include the newly visited node requires constructing a completely new frozenset from scratch:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Called ~800 times per query (ef=50, M=16 neighbours per step).</span>
<span class="k">return</span> <span class="p">[</span><span class="n">HNSWSearchState</span><span class="p">(</span>
    <span class="n">candidates</span><span class="o">=</span><span class="nb">tuple</span><span class="p">(</span><span class="n">candidates</span><span class="p">),</span>     <span class="c1"># new tuple: O(ef) allocation</span>
    <span class="n">results</span><span class="o">=</span><span class="nb">tuple</span><span class="p">(</span><span class="n">results</span><span class="p">),</span>           <span class="c1"># new tuple: O(ef) allocation</span>
    <span class="n">visited</span><span class="o">=</span><span class="nb">frozenset</span><span class="p">(</span><span class="n">visited</span><span class="p">),</span>       <span class="c1"># new frozenset: O(|visited|) copy — grows each step</span>
    <span class="o">...</span>
<span class="p">)]</span>
</code></pre></div>

<p>At step k, <code>frozenset(visited)</code> has k elements to copy into the new object. The cost is O(k) per step. Over a full beam search with <code>ef=50</code> and <code>M=16</code> neighbours per expansion, the search runs for roughly 800 steps:</p>
<div class="codehilite"><pre><span></span><code><span class="nv">Total</span><span class="w"> </span><span class="nv">copy</span><span class="w"> </span><span class="nv">cost</span>:<span class="w"> </span><span class="nv">sum</span><span class="ss">(</span><span class="nv">k</span><span class="w"> </span><span class="k">for</span><span class="w"> </span><span class="nv">k</span><span class="w"> </span><span class="nv">in</span><span class="w"> </span><span class="nv">range</span><span class="ss">(</span><span class="mi">800</span><span class="ss">))</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mi">319</span>,<span class="mi">600</span><span class="w"> </span><span class="nv">element</span><span class="o">-</span><span class="nv">copy</span><span class="w"> </span><span class="nv">operations</span><span class="w"> </span><span class="nv">per</span><span class="w"> </span><span class="nv">query</span>
</code></pre></div>

<p>That is, the visited-set maintenance alone accounts for 319,600 integer copy operations per query, growing quadratically with the number of steps. Add 800 tuple allocations for <code>candidates</code>, 800 for <code>results</code>, and the Python memory allocator is doing significant work on every query call, entirely in overhead rather than in useful distance computation.</p>
<p>The coalgebra is the right abstraction for Vajra's architecture. The fix was to add a parallel fast path that implements the identical algorithm with mutable state, bypassing the coalgebra in the hot query loop while keeping it available as the documented public interface for tests and extension.</p>
<p><code>_beam_search_fast()</code> implements the same algorithm with mutable state:</p>
<div class="codehilite"><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">_beam_search_fast</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">query</span><span class="p">,</span> <span class="n">entry</span><span class="p">,</span> <span class="n">ef</span><span class="p">):</span>
    <span class="n">layer_0</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">layers</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">layers</span> <span class="k">else</span> <span class="p">{}</span>
    <span class="n">vectors</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">vectors</span>
    <span class="n">dist_fn</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_distance_batch</span>

    <span class="n">d0</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">dist_fn</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">vectors</span><span class="p">[</span><span class="n">entry</span> <span class="p">:</span> <span class="n">entry</span> <span class="o">+</span> <span class="mi">1</span><span class="p">])[</span><span class="mi">0</span><span class="p">])</span>
    <span class="n">candidates</span> <span class="o">=</span> <span class="p">[(</span><span class="n">d0</span><span class="p">,</span> <span class="n">entry</span><span class="p">)]</span>    <span class="c1"># min-heap</span>
    <span class="n">results</span> <span class="o">=</span> <span class="p">[(</span><span class="o">-</span><span class="n">d0</span><span class="p">,</span> <span class="n">entry</span><span class="p">)]</span>      <span class="c1"># max-heap, capped at ef</span>
    <span class="n">visited</span> <span class="o">=</span> <span class="p">{</span><span class="n">entry</span><span class="p">}</span>

    <span class="n">n_results</span> <span class="o">=</span> <span class="mi">1</span>
    <span class="n">results_full</span> <span class="o">=</span> <span class="p">(</span><span class="n">n_results</span> <span class="o">&gt;=</span> <span class="n">ef</span><span class="p">)</span>
    <span class="n">worst</span> <span class="o">=</span> <span class="n">d0</span>

    <span class="k">while</span> <span class="n">candidates</span><span class="p">:</span>
        <span class="n">dist</span><span class="p">,</span> <span class="n">current</span> <span class="o">=</span> <span class="n">heapq</span><span class="o">.</span><span class="n">heappop</span><span class="p">(</span><span class="n">candidates</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">results_full</span> <span class="ow">and</span> <span class="n">dist</span> <span class="o">&gt;</span> <span class="n">worst</span><span class="p">:</span>
            <span class="k">break</span>

        <span class="n">unvisited</span> <span class="o">=</span> <span class="p">[</span><span class="n">n</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">layer_0</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">current</span><span class="p">,</span> <span class="p">[])</span> <span class="k">if</span> <span class="n">n</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">visited</span><span class="p">]</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">unvisited</span><span class="p">:</span>
            <span class="k">continue</span>
        <span class="n">visited</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">unvisited</span><span class="p">)</span>
        <span class="n">dists</span> <span class="o">=</span> <span class="n">dist_fn</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">vectors</span><span class="p">[</span><span class="n">unvisited</span><span class="p">])</span>

        <span class="k">for</span> <span class="n">n</span><span class="p">,</span> <span class="n">nd_raw</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="n">unvisited</span><span class="p">,</span> <span class="n">dists</span><span class="p">):</span>
            <span class="n">nd</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">nd_raw</span><span class="p">)</span>
            <span class="n">heapq</span><span class="o">.</span><span class="n">heappush</span><span class="p">(</span><span class="n">candidates</span><span class="p">,</span> <span class="p">(</span><span class="n">nd</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span>
            <span class="k">if</span> <span class="n">results_full</span><span class="p">:</span>
                <span class="k">if</span> <span class="n">nd</span> <span class="o">&lt;</span> <span class="n">worst</span><span class="p">:</span>
                    <span class="n">heapq</span><span class="o">.</span><span class="n">heapreplace</span><span class="p">(</span><span class="n">results</span><span class="p">,</span> <span class="p">(</span><span class="o">-</span><span class="n">nd</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span>
                    <span class="n">worst</span> <span class="o">=</span> <span class="o">-</span><span class="n">results</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span>
            <span class="k">else</span><span class="p">:</span>
                <span class="n">heapq</span><span class="o">.</span><span class="n">heappush</span><span class="p">(</span><span class="n">results</span><span class="p">,</span> <span class="p">(</span><span class="o">-</span><span class="n">nd</span><span class="p">,</span> <span class="n">n</span><span class="p">))</span>
                <span class="n">n_results</span> <span class="o">+=</span> <span class="mi">1</span>
                <span class="n">worst</span> <span class="o">=</span> <span class="o">-</span><span class="n">results</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">0</span><span class="p">]</span>
                <span class="k">if</span> <span class="n">n_results</span> <span class="o">&gt;=</span> <span class="n">ef</span><span class="p">:</span>
                    <span class="n">results_full</span> <span class="o">=</span> <span class="kc">True</span>

    <span class="k">return</span> <span class="p">[(</span><span class="n">idx</span><span class="p">,</span> <span class="o">-</span><span class="n">d</span><span class="p">)</span> <span class="k">for</span> <span class="n">d</span><span class="p">,</span> <span class="n">idx</span> <span class="ow">in</span> <span class="nb">sorted</span><span class="p">(</span><span class="n">results</span><span class="p">,</span> <span class="n">reverse</span><span class="o">=</span><span class="kc">True</span><span class="p">)]</span>
</code></pre></div>

<p>The <code>n_results</code> counter and <code>results_full</code> flag also eliminate ~19 million <code>len(results)</code> calls that showed up in the pre-fix profiler (the <code>len()</code> check ran on every iteration of the inner loop; once the results heap is full it stays full, so a boolean flag is sufficient).</p>
<p><code>search()</code> was rewritten to use <code>_greedy_search_layer</code> for the upper-layer descent and <code>_beam_search_fast</code> for the layer-0 beam search. The coalgebra is still constructed and stored in <code>self.coalgebra</code> after every <code>add()</code> call; it is just not used in the query hot path.</p>
<h3 id="additional-fix-inline-_prune_connections">Additional Fix: Inline <code>_prune_connections</code></h3>
<p>One smaller fix worth mentioning: <code>_prune_connections()</code> was a separate method called for every selected neighbour during node insertion, roughly 330,000 times at N=10k. Each call recomputed neighbor distances to check if pruning was needed, even when most nodes did not need pruning.</p>
<p>Inlining the pruning logic directly in <code>_insert_node</code> eliminated the function-call overhead and allowed caching <code>layers[l]</code> as a local reference:</p>
<div class="codehilite"><pre><span></span><code><span class="n">layer</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">graph</span><span class="o">.</span><span class="n">layers</span><span class="p">[</span><span class="n">l</span><span class="p">]</span>
<span class="k">for</span> <span class="n">neighbor_idx</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="n">selected</span><span class="p">:</span>
    <span class="n">nbrs</span> <span class="o">=</span> <span class="n">layer</span><span class="p">[</span><span class="n">neighbor_idx</span><span class="p">]</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="n">nbrs</span><span class="p">)</span> <span class="o">&gt;</span> <span class="n">M</span><span class="p">:</span>
        <span class="n">dists</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_distance_batch</span><span class="p">(</span><span class="n">vectors</span><span class="p">[</span><span class="n">neighbor_idx</span><span class="p">],</span> <span class="n">vectors</span><span class="p">[</span><span class="n">nbrs</span><span class="p">])</span>
        <span class="n">keep</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">argsort</span><span class="p">(</span><span class="n">dists</span><span class="p">)[:</span><span class="n">M</span><span class="p">]</span>
        <span class="n">layer</span><span class="p">[</span><span class="n">neighbor_idx</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="n">nbrs</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">keep</span><span class="p">]</span>
</code></pre></div>

<h2 id="results-after-all-six-fixes">Results After All Six Fixes</h2>
<p>The same 10,000-document benchmark, version 0.5.0:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>ZVec</th>
<th>Vajra v0.4.1</th>
<th>Vajra v0.5.0</th>
<th>Improvement</th>
</tr>
</thead>
<tbody>
<tr>
<td>Build time</td>
<td>0.47 s</td>
<td>97.77 s</td>
<td><strong>17.03 s</strong></td>
<td>5.7× faster</td>
</tr>
<tr>
<td>p50 query</td>
<td>0.31 ms</td>
<td>2.63 ms</td>
<td><strong>0.51 ms</strong></td>
<td>5.2× faster</td>
</tr>
<tr>
<td>p95 query</td>
<td>0.42 ms</td>
<td>3.77 ms</td>
<td><strong>0.71 ms</strong></td>
<td>5.3× faster</td>
</tr>
<tr>
<td>p99 query</td>
<td>0.53 ms</td>
<td>4.30 ms</td>
<td><strong>0.86 ms</strong></td>
<td>5.0× faster</td>
</tr>
<tr>
<td>QPS</td>
<td>3,058</td>
<td>374</td>
<td><strong>1,911</strong></td>
<td>5.1× higher</td>
</tr>
<tr>
<td>Recall@10</td>
<td>1.000</td>
<td>0.987</td>
<td><strong>0.997</strong></td>
<td>+1.0%</td>
</tr>
</tbody>
</table>
<p>The gap to ZVec:<br />
- Build: 207× → <strong>36×</strong>. The remaining 36× is the Python-vs-C++ interpreter cost for 10,000 HNSW insertions. Getting below ~12 seconds in Python would require Numba JIT on the inner beam search loop.<br />
- Query: 8.5× → <strong>1.6×</strong>. The remaining 1.6× reflects Python's <code>heapq</code> overhead. At ef=50 with M=16, each query runs roughly 800 heap operations. CPython <code>heapq.heappush</code> costs ~300ns per call on M1; 800 × 300ns = 240ms of irreducible heap overhead. ZVec's C++ <code>std::priority_queue</code> at ~10ns per op does the same 800 operations in 8ms.</p>
<p>The query gap is now at the CPython interpreter floor for this algorithm, not an implementation defect.</p>
<h2 id="what-this-demonstrates">What This Demonstrates</h2>
<p>None of these six changes required restructuring the algorithm, adding Numba, writing a C extension, or compromising the categorical abstractions that make Vajra's code clean. They were all standard Python engineering: profile first, find the real bottleneck, fix the specific thing.</p>
<p>The original 8× query gap looked like a fundamental Python-vs-C++ problem. It was mostly a <code>frozenset</code> copy in the wrong place. The 207× build gap looked like an algorithmic problem. It was a single <code>np.vstack</code> call inside a loop.</p>
<p>Vajra v0.5.0 is on PyPI. The benchmark code is at <a href="https://github.com/aiexplorations/vajra_bm25">github.com/aiexplorations/vajra_bm25</a> and the ZVec comparison scripts are at <a href="https://github.com/aiexplorations/zvec_vajra_benchmark">github.com/aiexplorations/zvec_vajra_benchmark</a>.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>vajra</category>
      <category>hnsw</category>
      <category>vector-search</category>
      <category>python</category>
      <category>performance</category>
      <category>benchmarks</category>
      <category>category-theory</category>
      <category>cosine-similarity</category>
      <category>information-retrieval</category>
      <category>numpy</category>
      <category>zvec</category>
    </item>
    <item>
      <title>How to use AI: Radical Delegation versus Personal Thriving</title>
      <link>https://rajeshrs.in/blog/philosophy-culture/2026-02-13-ai-tools-self-improvement.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/philosophy-culture/2026-02-13-ai-tools-self-improvement.html</guid>
      <pubDate>Fri, 13 Feb 2026 00:00:00 GMT</pubDate>
      <description>A practical philosophy for avoiding the _dvandva_ using AI as leverage while still building judgment, skill, and character.</description>
      <content:encoded><![CDATA[<p>AI is here to stay and is redefining the landscape of personal productivity, and I think that it is also transforming the very nature of human endeavour and striving. I am not the the first to have noticed how the use of AI coding tools creates a dopamine hijack in software engineers or vibe coders. Neither am I the first to assert that we're drowning in AI generated slop, be in written form, videos, audio and podcasts, or anything else.</p>
<p>More interestingly, there is a pattern on the part of users of AI to utilize AI as a substitute for thinking on their own. I say this as someone who has done exactly this. We live in a time of <em>radical delegation</em>, not just of the physical labour that made our muscles and bones strong, but of the mental labour that made our minds strong and that made us the species we are.</p>
<p>I don't know if we were promised greater productivity and human thriving by the tech boffins who built AI systems, especially the leading minds in the space who built and pushed ChatGPT, Claude and the like to the masses. It behooves us to examine their intentions more closely, and I say this as someone who is a practitioner of AI, and who has built models, and built on top of large language models, and the so-called foundation models. What is discernable is that these tools provide a specific kind of advantage to those who use them. Whether it is writing, coding, painting or other cognitively demanding tasks, the creation of content in its entire spectrum is covered by AI. Movies are being made with AI. It is possible for a person with just an imagination and the intent to use these tools to sequentially explore an idea for a film with AI, write a script with AI, prepare clips or scenes of video with AI, and edit and package them into a coherent film, also with AI. The user here provides the steering function, that guides the potential of the AI to some kind of impact. </p>
<p>To a technologist, this sounds like a promising application of AI. The practical purpose of embodying intelligence into formerly inanimate things around us, is to enable them to be animate in specific and helpful ways. Perhaps one path along the garden of forking paths in this general direction is the use of AI for cognitively demanding tasks. If AI could be used to solve hard mathematics problems, for example, some of us would find the weight lifted from our shoulders as we moved from trepidation and tedium to understanding. This is the logical positivist's spin on artificial intelligence, as a force for doing good to those that deserve that good. We know that good is subjective, though, and we will discuss this in this post. </p>
<p>To an AI technologist, the process of using gradient descent to recursively improve factor-response models on arbitrary data to learn a useful model ("useful" in the terminology of W. Edwards Deming) is a <em>beginning</em>. To make this system write passably good, human-like essays or have it generate incredibly creative and interesting images and videos is <em>progress</em>, and can be considered a technological or computational accomplishment. The fact that we have been able to sequentially transform a field of noise into a meaningful image, that is steered by a text prompt, to produce an image - this is no mean feat. And stacking such images together, nay, generating that sequence so that moving objects may be rendered in a form that we humans recognize as movies and clips - this is incredible too. In a nutshell, there is no doubting the technological achievements that these models represent. This may even represent a <em>success</em> - and I say "may even" for a reason.</p>
<p>I have described a beginning and progress in Henry Ford (beginning-progress-success) terms, so what then is the true definition of success in the use of artificial intelligence? To understand this, we need to understand the human perspective on artificial intelligence. Humans look to AI from the point of view of utility, ultimately. Even if the underlying techniques, methods, mathematics and statistics are explored from an information theoretic perspective and despite the fact that the theory and practice here is valuable, the underlying motivations have been the need to advance some agends of either humanity as a whole, or sub-sections of human society. There is a utilitarian version of the anthropic principle of sorts that I will have to invoke in this context. The anthropic principle the supposition that the universe created life so that it could observe itself and learn about itself - and I'll go out on a limb and say, measure its own utility. Similarly, perhaps biological intelligences create artificial intelligence so that they can learn about their utility. The bounds of artificial intelligences we create are not limited to the bounds of humans, however, neither is it the case that the AI we build are inside the closed systems we represent. Unlike the universe which subsumes life and artificial intelligence, humans share a landscape with artificial intelligence. Considering in this specific way the <em>utility</em> of artificial intelligence systems is a contrast to the libertarian, almost phenomenological view that a lot of business leaders and technologists have, in the context of artificial intelligence. Many of them anthropomorphize AI, and many attribute qualities to algorithmic systems that appear intelligence from a human standpoint. I impute an anti-phenomenological, utilitarian bias deliberately here, because phenomenology is not a good underpinning to reason about tools, specifically because tools are built with utility in mind.</p>
<p>Let's now think about what the human endeavour looks like, for someone writing, painting, or making movies. In the past, a writer would have to think through ideas from their experiences, from conversations, from books and articles they read, from shows they watched or songs they listened to. These were the "prompts", the inspiration for writing, for creating and for building ideas on top of old ideas and notions. They had to grapple with the unsafe silence their own minds would throw at them, when they came up with new ideas. They had to grapple with the uncertainty, of whether the idea would be valid or not. And they had to articulate these thoughts, however incipient or inchoate, so that (a) they can make sense of the ideas themselves (the reasoning utility), (b) they can explain it to others (the commuinication utilty). The reasoning utility for a writer of fiction may constitute the characters, their interactions, the plot and the denouement. These components helped them think about how a piece of fiction could be written. The communication utility in writing for a fiction writer would be their craft. Their use of the words and phrases, their descriptive toolkit for characters and interactions between them, the narrative style, the style of writing that drew people in. Some writers may be inventive, conceptualizing their own words, inventing their own languages, imagining the imperfections and quirks of their characters. The field of creativity is vast for human writers. Our brains are hallmarks of a general biological intelligence, and powered by language, reasoning, experiences that are multi-sensorial and the power of manifestation, we bring characters, ideas, places and the like, to life.</p>
<p>For the painter, the visual field of the canvas or paper, or other mediums is a blank slate. Of course, we all take inspiration for what we may want to draw and paint, but everyone approaches a scene or a subject differently. The painter's mind imagines a scene and the medley of paint and tools and brushes and the like translates the imagined scene into an approximation of the scene on the medium. The reasoning utility for the painter comprises of the subject of the painting, the composition and how subjects and objects are positioned on the visual field, and the broader meaning or import of the art. The communication utility of the painter are the strokes and the technique and the tools and the sequence of use of the tools and colours and the rest on the medium. In a similar vein, someone making a movie may find the reasoning utility of their movie to be the subject, the denouement and the plot with the characters and the like, similar to a book, while the communication utility here is the synthesis of actors, props, camera and screen play, effects, post-processing and editing. These together help tell the story that the creator intended. </p>
<p>Now let's examine the mental effort of these creators. They each have to begin from priors, and learn to nurture the ideas they work with, as they develop the reasoning around the ideas. The more they work and the more they produce, be it books or art or movies, the more they explore the landscape of tools and possibilities available to them, and the better they're able to respond in (a) confidence, (b) skillful execution, (c) speed and (d) cross-pollination of capabilities and ideas across mediums. What I discuss here is not ground-breaking, in any way, and is something anyone who has practiced any skill understands.</p>
<p>Coding and writing software are also similar. Tech professionals and knowledge workers are now getting used to using AI and prompting AI models to build applications, perform data analyses and deliver results. The human effort required to come up with a new logo, a new design for a report, a good opening paragraph, a good visualization that tells a story with data, a narrative that is compelling across a report, a piece of software that is built to spec but also performant, a beautifully made short movie for an ad in one or a couple of takes - these are likely to be replaced by AI that is prompted to produce different kinds of results. The possibilities with AI are remarkable. We have a tool that leverages the collective styles, ideas, notions and knowledge of billions of humans that came before it, and which can be prompted to give us what we desire, without the challenge and the pain of thinking about the ideas that much, and with no skill.</p>
<p>The radical delegation I mentioned in passing above is now able to be described in more exact terms. <em>Radical Delegation is a pattern of behaviour where a human or a group of humans who's exceptionally skilled in a task decides voluntarily to delegate the process of executing that task to another human, group of humans, or a machine or a group of machines, that can perform the same task, in either the same fashion, or in an altogether different fashion.</em> Radical delegation is not the same as automation but is a step on the path towards an industrialized or automated task. It is on the path to commoditization, either by the creation of human scale or mechanization of a task. Radical delegation is voluntary, and not imposed due to external constraints. When a software engineer decides to use AI to solve a problem, it is a voluntary choice for many at the moment. It may not stay that way for long, and there will be equivalent of a corporate program soon if not already present, in most teams that encourage the use of AI coding tools. </p>
<p>Beyond a point, the use of AI to automate work may not be an individualist question anymore, but one of either collectivism or interventionism. I say this because the questions that artisans or individuals ask about whether to use AI tools and how to use them will anticipate the questions asked by their managers, or people that deliver business or livelihood opportunities to them. At that point, the question becomes not one of choice, but one of efficiency, economics and markets. We've now moved from the field of free will (in the limited context of the use of AI tools based on one's arbitrary wishes), to the collectivist or interventionist approach to using AI, as a social policy which unfolds in the form of specific use cases, risks, and introduces both efficiencies and frictions in the form of the use of AI by humans in different contexts.</p>
<p>Collectivist culture in the context of AI on the internet takes many forms. We've seen movements like effective accelerationism (e/acc) become a force in prior years. This movement has advocated the use of technology in an interventionist fashion, primarily with the express objective of achieving greater human progress. The definition of what that progress entails is usually a little less well defined than the spirit in which technology and positivism is framed by e/acc. My own sense is that e/acc is akin to the culture that inspired tech companies in the 2000s and 2010s, a la "move fast and break things" and less akin to a serious movement which has well considered and rational use of technology to various ends. One could perhaps make a similar argument for AI tool adoption, in that there is a lot of depth in method, but less depth in motives. This implies a greater emphasis on communication utility, and a reduced focus on reasoning utility, for the concepts that e/acc engages with.</p>
<p>AI promoters and detractors for each of the various shades of automation exist. This spectrum of AI automation ranges from merely minor automations and thinking aids such as using tab-autocomplete features in code editors, on one end, all the way to full automation of the software engineering lifecycle on the other end. The promoters often state efficiency and the possility of moving grunt work away from humans and towards machines, as a noble end state. It is rare to see promoters acknowledge some of the pitfalls of this approach, but many do. For example, is the human a mere vessel with which to execute a software workflow? In the language of software engineering, are humans using AI mere wrappers with gates and checkpoints? </p>
<p>The detractors have both simplistic and more nuanced arguments. Detractors argue about skill atrophy, akin to how muscular or other atrophy is frowned upon by atheletes or artists. Detractors argue about how it is important for human expertise to survive and thrive, which is certainly noble. On the other hand, detractors don't often acknowledge the mundaneness and hardship associated with mastery and expertise. Here I refer in spirit to George Leonard describes the long plateau where "nothing happens" in his book Mastery, who acknowledges that the path to mastery of any skill includes a non-trivial amount of "grinding". This is the long plateau before we become adept at something, whether that is writing, painting, movie making or anything else. Detractors are acutely aware of the importance of reasoning utility, but not aware of the need for communication utility. I risk oversimplifying when I say this, and when I assert that detractors of AI automation don't acknowledge the need for communication utility arising out of broad adoption. Such broad adoption is precisely what drives progress in AI, which is known to be a data-intensive, compute intensive field that relies on ever more data to keep the train of AI model progress chugging along. </p>
<p>What does utopia look like, to the detractors, if the dystopia looks like radical delegation? Perhaps a term that could describe the opposite of radical delegation to AI, is <em>personal thriving</em>. By this I mean a condition where the human being continues to grow in judgment, in skill, in confidence, in taste and in responsibility, even while using tools that increase their leverage. Personal thriving is not Luddism. It is not a refusal of tools. It is not a romantic attachment to doing everything by hand, merely because it was done that way in the past. It is instead the insistence that the human being remain the site where understanding accumulates, where responsibility resides, and where the final meaning of work is interpreted.</p>
<p>This distinction matters because many discussions about AI collapse into a binary. One either embraces automation and scale, or one rejects them and becomes a reactionary purist. Reality is not so simple. A writer may use a thesaurus and still write. A programmer may use a debugger and still understand the code. A musician may use software instruments and still compose. In the same spirit, someone may use AI and still preserve their own thought. The question is not whether tools are involved. The question is whether the tool merely extends the person, or begins to displace the person's own reasoning.</p>
<p>The most dangerous form of displacement is not immediately visible in the produced output. This is what makes the question difficult. If AI produces a decent essay, a useful piece of code, a visual that is aesthetically appealing, or a summary that appears cogent, then the user is tempted to say that the process has succeeded. But this is only the communication utility being satisfied. Something legible has been emitted into the world. Something fit for consumption now exists. What has happened to the reasoning utility of the user in the process, however, is a separate question altogether. Has the user deepened their understanding of the problem? Have they become more capable of independently solving a related problem in future? Have they developed taste, judgment and character? Or have they merely learned to manage a prompt loop?</p>
<p>This, in my view, is the accountability problem.</p>
<p>When a human being performs a difficult task, they are not merely delivering an output. They are also shaping themselves. The action of struggling through a proof, of debugging a stubborn failure, of finding the right paragraph to close an essay, of understanding a difficult passage in a book, of revising an argument that does not yet cohere - these are not only means to an end. They are formative. They create a kind of internal architecture. The human being that emerges after such effort is not identical to the one that began the task. There is a strengthening involved. The person now sees a little more clearly, acts a little more confidently, and is a little less likely to be deceived by surface level appearances the next time around.</p>
<p>AI changes the economics of this formation. It offers outputs without requiring a commensurate depth of interior transformation. It can often provide the language of understanding without the underlying structure of understanding having been built in the user. This is not always harmful. Sometimes the tool genuinely helps us cross a threshold and see what we could not see before. But in many cases it creates a counterfeit of mastery. One has the appearance of intelligence, the verbal form of competence, and the practical veneer of productivity, without the grounding labour that would ordinarily have made these things one's own.</p>
<p>And when that counterfeit becomes widespread, accountability begins to thin out in curious ways. If the code fails, was it the engineer's failure, or the model's? If the report is shallow, was it the analyst's misunderstanding, or the AI's generic synthesis? If the essay is derivative, was the writer lazy, or merely over-assisted? It is tempting to say that this is irrelevant because the market only cares for results. But markets do care for accountability eventually, especially when systems fail at scale. Someone must still answer for poor reasoning, for fragile software, for wrong decisions, and for the consequences of action taken on shallow understanding. Radical delegation obscures this, because it multiplies outputs while diffusing ownership over the thinking that led to them.</p>
<p>This is why I do not think the future of AI can be properly framed as merely a race between optimists and pessimists. It is a question of anthropology before it is a question of economics. What sort of being does the human become through the regular use of such tools? What habits are reinforced? What forms of laziness are made normal? What kinds of excellence become easier to fake? What kinds of patience become harder to sustain? We ought to be suspicious of any technological regime that makes the visible signs of competence easier to obtain, while making the underlying cultivation of competence optional.</p>
<p>The managerial and institutional form of this problem is easy to imagine. A team discovers that AI lets them ship faster. A manager then asks for more output with the same headcount. Another manager notices that junior engineers can now produce PRs that look acceptable sooner. A process is established where humans review, approve and nudge model outputs at scale. Over time, the institution ceases to reward the slow accumulation of judgment because the visible proxies for judgment are now cheaply available. The system begins to privilege throughput over understanding. A generation of people learns to operate at the communication layer of work while becoming less familiar with the reasoning layer beneath it. This is not merely an issue of preference. It is an issue of what kinds of institutions we are building and what kinds of humans those institutions will select for.</p>
<p>At this point, one could object that every technology has done something similar. Writing changed memory. Calculators changed arithmetic practice. Search engines changed recall. Why should AI be treated differently? My answer is that AI is not merely a storage aid, a retrieval mechanism, or a mechanical accelerator for a well-understood process. AI increasingly intervenes at the level of synthesis, composition, explanation and judgment-like behaviour. It enters the domain that humans once used to become more fully themselves through repeated practice. This is why it cannot be treated as just another convenience. It reaches farther inward.</p>
<p>All of this suggests to me a practical ethic for the use of AI. Use it where leverage is real, but do not use it in a manner that hollows out the very faculties you are hoping to strengthen. Use it to compare approaches after you have attempted one. Use it to critique your reasoning after you have actually reasoned. Use it to accelerate drudgery that does not educate you. Use it to open doors into difficult domains, but do not let it become a permanent substitute for crossing the threshold yourself. In other words, use it in a way that preserves the relationship between effort and growth.</p>
<p>There is a Sanskrit word that feels relevant in this context: <em>dvandva</em>, the realm of dualities and oppositions. Many of our conversations about AI are trapped in precisely such dualities. Optimism versus pessimism. Productivity versus purity. Automation versus authenticity. But perhaps wisdom here lies in a more careful middle path. A refusal of both naive surrender and performative rejection. A deliberate use of tools that keeps the human person in view.</p>
<p>The point, then, is not to ask whether AI can do the work. In many cases, it plainly can. The point is to ask what happens to us when we allow it to do too much of the work that once made us capable, responsible and alive to the world. If the answer is that we become more dependent, less accountable, less skillful, and more alienated from our own effort, then we have not progressed merely because the output arrived faster. Progress has to be evaluated not only by the abundance of artifacts it produces, but by the quality of the humans it leaves behind.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>Philosophy &amp; Culture</category>
      <category>philosophy</category>
      <category>ai</category>
      <category>self-improvement</category>
      <category>learning</category>
      <category>practice</category>
    </item>
    <item>
      <title>Vidai: Teaching Machines Arithmetic Without Teaching Arithmetic</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2026-01-04-vidai-teaching-machines-arithmetic.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2026-01-04-vidai-teaching-machines-arithmetic.html</guid>
      <pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate>
      <description>A neural system that learns to parse mathematical notation rather than compute it, achieving 90%+ accuracy by separating structure understanding from calculation.</description>
      <content:encoded><![CDATA[<p>I was reading about <a href="https://en.wikipedia.org/wiki/Alexander_Grothendieck">Alexander Grothendieck</a> recently, and there was an anecdote that despite being one of the premier mathematicians of his era, contributing a credible and large volume of work in algebra, he often struggled with arithmetic. While I am no comparison to Grothendieck, being neither a mathematician nor very algebraically accomplished in the mathematician's sense of the term, I can claim to have had the same kind of deficiency. Growing up, I was never the sharpest knife in the drawer with respect to arithmetic, once to the chagrin of my father and some relatives who basked in the "centum" glory often associated with kids of an older generation taught the three Rs: Reading, wRiting, and aRithmetic.</p>
<p>Now that I have gotten that out of the way, let me come to the purpose of this post: introducing <a href="https://github.com/aiexplorations/vidai"><em>Vidai</em></a>, a neural engine for mathematics. Vidai (விடை) is Tamil for "answer," reminiscent of the middle school experiences that lead to sweaty palms for kids learning arithmetic, who also have to contend with the occasional test or exam demanding these answers. Fortunately for such kids, it is a running joke also that ChatGPT, of all things, cannot do mathematics. "Count the number of Rs in Strawberry" has long failed ChatGPT, for instance and routinely does the rounds whenever a new SOTA model is released. Overall, it looks like ChatGPT is not quite there with the three Rs (pun intended).</p>
<p>But why build such a neural engine now, you might ask me, and the answer to that lies in the specific triggers I had during the new year's break. On one of Machine Learning Street Talk's recent episodes (<a href="https://youtu.be/AWqvBdqCAAE">this one</a>), I was watching Petar Veličković from Google DeepMind, among other discussing graph neural networks and category theory for neural networks. Petar casually pointed out a paradox that stuck with me: language models often get basic multiplications wrong even though in the process of generating any given response, they perform <em>millions</em> of multiplications internally and quite routinely. A weird analogue indeed to how our brains may also be performing such mathematics routinely between the vast neuronal connections in our complex brains, all the while getting basic arithmetic sums wrong! Nature works in strange ways. The irony of all this was not lost on me. Here are systems executing vast amounts of arithmetic correctly at the matrix level while failing at the basic, symbolic level. It got me thinking about what kind of model architecture would actually work for arithmetic.</p>
<p><em>Vidai</em> is a purpose-built encoder-decoder model for mathematical expressions that sidesteps this problem entirely. I spent part of the new year's break trying to build a transformer model that could perform arithmetic operations. This post discusses how I went about this problem.</p>
<h2 id="the-structure-of-mathematical-expressions">The Structure of Mathematical Expressions</h2>
<p>Starting from the understanding of how transformers work, which has now become commoner knowledge than it was a couple of years ago, I started poking at the problem by looking at the structure of mathematical expressions. When we evaluate a mathematical expression, we implicitly solve a graph-shaped problem. There are values at various leaves of the tree in an arithmetic graph. Mathematical operations perform unary, binary, and other operations on these values, leading to a result at the root of the tree.</p>
<p>Conventional transformer LLMs don't explicitly learn this structure. They're trained to be large-scale pattern learners that solve a wider array of language understanding tasks from noisy internet-scale data. So I asked the question: since mathematical expressions, especially arithmetic, are likely to be less noisy and easier to parse, can we build a sequence-to-sequence model that learns the patterns in arithmetic expressions?</p>
<h2 id="dont-train-what-you-already-know">Don't Train What You Already Know</h2>
<p>The operations of division, addition, multiplication, and so on, are not in and of themselves subject to change unless we invent new number systems and new kinds of mathematics. We just need the mathematical operations of different trees we infer from the text to be computed using known rules. In other words, there is no point in trying to train addition using gradient descent. Doing something like that is not just wrong, it is beyond wrong, if there is such a thing. (Then again, SOTA LLMs often encode numbers as tokens, but let's not go there. I am tempted to say that this is not just stupid, but beyond stupid).</p>
<p>During the time I built Vidai, I was also teaching my four-year-old son some simple sums, the kind toddlers learn to do. Watching him struggle, I noticed something: the bulk of the challenge kids seem to have with arithmetic at that age is the <em>parsing</em> and the <em>conceptual understanding</em> of addition or subtraction or other operations. What does it mean to "add" two things? Which number goes where? Would it matter which way the operation was done? Does the mathematics map somehow to something in the real world? Subsequently, the kids learn the rules, but then struggle at a different level. Having learned the rules of mathematical operations, they struggle with understanding how to interpret a given sum so that the numbers may be "all lined up in a row", so that the simple, comfortable, well-known, and now-familiar work of addition or subtraction or other operations can be executed. So, parsing and structure understanding first, and computation second. I thought that this is perhaps the same split in functions that neural networks need.</p>
<p>This observation led me to separate what neural networks are good at from what they are bad at:</p>
<ul>
<li>
<p><strong>Parsing</strong> requires understanding that <code>(3 + 5) * 2</code> means addition inside the parentheses with the result becoming an operand of multiplication. This involves operator precedence, parenthesis matching, implicit conventions, and the various ways humans write the same mathematical idea. This is pattern recognition, and neural networks excel at this. The noise is in how we express the expressions, and this noise is something LLMs can deal with in their current paradigm.</p>
</li>
<li>
<p><strong>Computation</strong>, once you know the structure, is trivial. The rules of addition and multiplication are fixed, known, and implemented correctly in every programming language. No learning from data is required.</p>
</li>
</ul>
<p>Vidai trains the model only on parsing tasks: converting expressions into <em>prefix notation trees</em> that make structure explicit. A separate module with zero learned parameters walks the resulting tree and executes operations using exact arithmetic.</p>
<div class="codehilite"><pre><span></span><code>Input: &quot;2x² + 3xy - √(x+1)&quot;
    ↓
VIDAI (learned parsing, 44M parameters)
    ↓
Prefix: &quot;+ <span class="gs">* 2 *</span>* x 2 - <span class="gs">* 3 *</span> x y sqrt + x 1&quot;
    ↓
SymPy (deterministic computation, 0 parameters)
    ↓
Structured: 2*x**2 + 3*x*y - sqrt(x + 1)
</code></pre></div>

<p>Ergo, the neural network never learns that 3 plus 5 equals 8. It learns that when humans write <code>3 + 5</code>, they intend an addition operation with 3 and 5 as operands. Importantly, it learns to represent this as a tree of operations.</p>
<h2 id="why-parse-math-when-calculators-exist">Why Parse Math When Calculators Exist?</h2>
<p>When you type <code>2x + 3y</code> into <a href="https://www.sympy.org/en/index.html">SymPy</a>, it fails because <code>2x</code> is not valid Python syntax and the multiplication operation must be explicitly communicated to it. Greek letters like <code>θ² + φ</code> require symbol declarations. Scanned equations from textbooks, arrive with OCR artifacts, inconsistent spacing, and notation variants that no existing system handles gracefully.</p>
<p>Calculators and computer algebra systems are powerful at computation but brittle at interpretation. They demand that humans translate notation into rigid syntax before engaging with it. <strong>Vidai</strong> occupies the space between human notation and symbolic computation, accepting expressions written the way people actually write them.</p>
<h2 id="the-architecture">The Architecture</h2>
<h3 id="character-level-input-encoding">Character-Level Input Encoding</h3>
<p>The encoder uses character-level tokenization: each ASCII character maps directly to its code (0-255). No vocabulary, no subword tokenization, nothing. The string <code>"x^2 + 3y"</code> becomes the sequence <code>[120, 94, 50, 32, 43, 32, 51, 121]</code>.</p>
<div class="codehilite"><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">encode_input</span><span class="p">(</span><span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">max_len</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">256</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">list</span><span class="p">[</span><span class="nb">int</span><span class="p">]:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Encode input as ASCII codes.&quot;&quot;&quot;</span>
    <span class="n">ids</span> <span class="o">=</span> <span class="p">[</span><span class="nb">ord</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="k">if</span> <span class="nb">ord</span><span class="p">(</span><span class="n">c</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">256</span> <span class="k">else</span> <span class="nb">ord</span><span class="p">(</span><span class="s1">&#39;?&#39;</span><span class="p">)</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">text</span><span class="p">]</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">ids</span> <span class="o">+</span> <span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">*</span> <span class="n">max_len</span><span class="p">)[:</span><span class="n">max_len</span><span class="p">]</span>  <span class="c1"># Pad or truncate</span>
</code></pre></div>

<p>This approach has a useful property: any symbol works without vocabulary changes. Unicode characters like <code>√</code>, <code>θ</code>, or <code>²</code> just become their character codes. The model learns to recognize these patterns from training data rather than requiring explicit vocabulary entries.</p>
<h3 id="prefix-notation-as-tree-representation">Prefix Notation as Tree Representation</h3>
<p>The key insight of the architecture: prefix notation (also known as Polish notation) makes tree structure explicit in a flat sequence. The expression <code>(3 + 5) * 2</code> has this tree structure:</p>
<div class="codehilite"><pre><span></span><code>        <span class="k">*</span>
       / \
      +   2
     / \
    3   5
</code></pre></div>

<p>In prefix notation, the operator comes before its operands: <code>* + 3 5 2</code>. Reading left to right, each operator "claims" the next N operands (2 for binary operators, 1 for unary). No parentheses needed; the structure is unambiguous.</p>
<p>A more complex example, <code>2x² + 3y</code>:</p>
<div class="codehilite"><pre><span></span><code>Infix:   2*x^2 + 3*y
Tree:
            +
           / \
          <span class="k">*</span>   *
         / \ / \
        2  ^ 3  y
          / \
         x   2

Prefix:  + <span class="gs">* 2 *</span>* x 2 * 3 y
</code></pre></div>

<p>The model's job is to learn this transformation, so, given arbitrary mathematical text, output the prefix notation that represents its structure.</p>
<h3 id="the-encoder-decoder-architecture">The Encoder-Decoder Architecture</h3>
<p>The system uses a standard transformer encoder-decoder, but with specific design choices:</p>
<p><strong>ContextEncoder</strong> (19M parameters):<br />
- 6 transformer layers with 8 attention heads<br />
- 256-dimensional embeddings<br />
- Character embeddings + positional embeddings + depth embeddings<br />
- The depth embeddings encode tree structure hints during training</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">ContextEncoder</span><span class="p">(</span><span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">config</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">token_embedding</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Embedding</span><span class="p">(</span><span class="mi">256</span><span class="p">,</span> <span class="n">d_model</span><span class="p">)</span>  <span class="c1"># ASCII</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">position_embedding</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">max_seq_len</span><span class="p">,</span> <span class="n">d_model</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">depth_embedding</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">max_depth</span><span class="p">,</span> <span class="n">d_model</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">transformer</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">TransformerEncoder</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">input_ids</span><span class="p">,</span> <span class="n">tree_depths</span><span class="p">,</span> <span class="n">attention_mask</span><span class="p">):</span>
        <span class="n">x</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">token_embedding</span><span class="p">(</span><span class="n">input_ids</span><span class="p">)</span>
        <span class="n">x</span> <span class="o">=</span> <span class="n">x</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">position_embedding</span><span class="p">(</span><span class="n">positions</span><span class="p">)</span>
        <span class="n">x</span> <span class="o">=</span> <span class="n">x</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">depth_embedding</span><span class="p">(</span><span class="n">tree_depths</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">transformer</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">attention_mask</span><span class="p">)</span>
</code></pre></div>

<p><strong>SymbolicParserDecoder</strong> (25M parameters):<br />
- 4 transformer decoder layers<br />
- Autoregressive generation with cross-attention to encoder output<br />
- Output vocabulary of ~60 tokens: operators (<code>+</code>, <code>-</code>, <code>*</code>, <code>/</code>, <code>**</code>), functions (<code>sin</code>, <code>cos</code>, <code>sqrt</code>, etc), variables (<code>x</code>, <code>y</code>, <code>theta</code>), digits, and special tokens</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">SymbolicParserDecoder</span><span class="p">(</span><span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">):</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">generate</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">encoder_memory</span><span class="p">,</span> <span class="n">max_len</span><span class="o">=</span><span class="mi">64</span><span class="p">):</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Autoregressive generation of prefix notation.&quot;&quot;&quot;</span>
        <span class="n">output_ids</span> <span class="o">=</span> <span class="p">[</span><span class="n">BOS_TOKEN</span><span class="p">]</span>
        <span class="k">for</span> <span class="n">_</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">max_len</span><span class="p">):</span>
            <span class="n">logits</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">forward</span><span class="p">(</span><span class="n">output_ids</span><span class="p">,</span> <span class="n">encoder_memory</span><span class="p">)</span>
            <span class="n">next_token</span> <span class="o">=</span> <span class="n">logits</span><span class="p">[:,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="p">:]</span><span class="o">.</span><span class="n">argmax</span><span class="p">()</span>
            <span class="n">output_ids</span><span class="o">.</span><span class="n">append</span><span class="p">(</span><span class="n">next_token</span><span class="p">)</span>
            <span class="k">if</span> <span class="n">next_token</span> <span class="o">==</span> <span class="n">EOS_TOKEN</span><span class="p">:</span>
                <span class="k">break</span>
        <span class="k">return</span> <span class="n">output_ids</span>
</code></pre></div>

<h3 id="the-treecomputemodule-zero-learned-parameters">The TreeComputeModule: Zero Learned Parameters</h3>
<p>Once we have prefix notation, computation is deterministic. The TreeComputeModule has exactly <em>zero</em> learned parameters. It walks the prefix notation tree and executes operations using exact arithmetic:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># These are the actual operations - no neural network, no gradients</span>
<span class="k">def</span><span class="w"> </span><span class="nf">add_op</span><span class="p">(</span><span class="n">left</span><span class="p">:</span> <span class="n">Tensor</span><span class="p">,</span> <span class="n">right</span><span class="p">:</span> <span class="n">Tensor</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Tensor</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">left</span> <span class="o">+</span> <span class="n">right</span>

<span class="k">def</span><span class="w"> </span><span class="nf">mul_op</span><span class="p">(</span><span class="n">left</span><span class="p">:</span> <span class="n">Tensor</span><span class="p">,</span> <span class="n">right</span><span class="p">:</span> <span class="n">Tensor</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Tensor</span><span class="p">:</span>
    <span class="k">return</span> <span class="n">left</span> <span class="o">*</span> <span class="n">right</span>

<span class="k">def</span><span class="w"> </span><span class="nf">pow_op</span><span class="p">(</span><span class="n">left</span><span class="p">:</span> <span class="n">Tensor</span><span class="p">,</span> <span class="n">right</span><span class="p">:</span> <span class="n">Tensor</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Tensor</span><span class="p">:</span>
    <span class="c1"># Use exact integer multiplication for small exponents</span>
    <span class="n">right_int</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">round</span><span class="p">(</span><span class="n">right</span><span class="p">)</span>
    <span class="n">is_small_int</span> <span class="o">=</span> <span class="p">(</span><span class="n">right</span> <span class="o">==</span> <span class="n">right_int</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">right_int</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">right_int</span> <span class="o">&lt;=</span> <span class="mi">10</span><span class="p">)</span>
    <span class="n">result</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">ones_like</span><span class="p">(</span><span class="n">left</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
        <span class="n">result</span> <span class="o">=</span> <span class="n">torch</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="n">right_int</span> <span class="o">&gt;</span> <span class="n">i</span><span class="p">,</span> <span class="n">result</span> <span class="o">*</span> <span class="n">left</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">torch</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="n">is_small_int</span><span class="p">,</span> <span class="n">result</span><span class="p">,</span> <span class="n">torch</span><span class="o">.</span><span class="n">pow</span><span class="p">(</span><span class="n">left</span><span class="p">,</span> <span class="n">right</span><span class="p">))</span>

<span class="n">ARITHMETIC_OPS</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s1">&#39;add&#39;</span><span class="p">:</span> <span class="n">add_op</span><span class="p">,</span> <span class="s1">&#39;sub&#39;</span><span class="p">:</span> <span class="n">sub_op</span><span class="p">,</span> <span class="s1">&#39;mul&#39;</span><span class="p">:</span> <span class="n">mul_op</span><span class="p">,</span>
    <span class="s1">&#39;div&#39;</span><span class="p">:</span> <span class="n">div_op</span><span class="p">,</span> <span class="s1">&#39;pow&#39;</span><span class="p">:</span> <span class="n">pow_op</span><span class="p">,</span> <span class="s1">&#39;mod&#39;</span><span class="p">:</span> <span class="n">mod_op</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div>

<p>For symbolic expressions (containing variables), the prefix notation is passed to SymPy which handles the algebraic manipulation.</p>
<h3 id="summary">Summary</h3>
<table>
<thead>
<tr>
<th>Component</th>
<th>Parameters</th>
<th>Role</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ContextEncoder</strong></td>
<td>19M</td>
<td>Character-level encoding with tree depth signals</td>
</tr>
<tr>
<td><strong>SymbolicParserDecoder</strong></td>
<td>25M</td>
<td>Autoregressive prefix notation generation</td>
</tr>
<tr>
<td><strong>TreeComputeModule</strong></td>
<td>0</td>
<td>Deterministic arithmetic via hardcoded operations</td>
</tr>
</tbody>
</table>
<p><strong>Total: 44.6M parameters</strong> which are all in the encoder-decoder, since as I have discussed earlier, computation is deterministic and there is no actual machine learning involved there.</p>
<h2 id="the-precedence-bug">The Precedence Bug</h2>
<p>When I trained the above transformer model on the initial dataset, the performance plateaued to 68% sequence accuracy (despite 98% token accuracy). Token accuracy is easier to learn, and sequence accuracy harder, and the simultaneous learning of both of these in tandem during the training process showed how we'd get to 80% accuracy on the token accuracy long before we would reach a similar number for sequence accuracy. The 30-point gap pointed directly at the problem: operator precedence ambiguity in my training data.</p>
<p>The data generator I used for data generation of expression data, stored as text later, built random expression trees and converted them to infix notation, but <code>76 + 25 * 67</code> is ambiguous under standard mathematical conventions. The training data contained contradictory labels for identical inputs, and 68% accuracy was the best the model could achieve when trying to satisfy contradictory constraints.</p>
<p>After fixing the data to use explicit parentheses, accuracy reached 95% within the first epoch.</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before Fix</th>
<th>After Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>Token Accuracy</td>
<td>98%</td>
<td>99%+</td>
</tr>
<tr>
<td>Sequence Accuracy</td>
<td>68%</td>
<td>95%+</td>
</tr>
</tbody>
</table>
<h2 id="test-results">Test Results</h2>
<p>I built a systematic test suite covering 11 categories of mathematical patterns. The test script (<code>scripts/eval/test_finetune.py</code>) runs inference against hand-crafted test cases:</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>Test Cases</th>
<th>V3 Baseline</th>
<th>V4 After Fine-tuning</th>
</tr>
</thead>
<tbody>
<tr>
<td>Trig functions (sin, cos, tan)</td>
<td>8</td>
<td>0%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Log/Exp functions</td>
<td>6</td>
<td>0%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Left associativity (a - b - c)</td>
<td>5</td>
<td>~50%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Left associativity (a / b / c)</td>
<td>4</td>
<td>~50%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Operator precedence (no parens)</td>
<td>7</td>
<td>~67%</td>
<td><strong>86%</strong></td>
</tr>
<tr>
<td>Standalone negatives</td>
<td>8</td>
<td>~62%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Subscript variables (x1, x2)</td>
<td>7</td>
<td>~70%</td>
<td><strong>86%</strong></td>
</tr>
<tr>
<td>Unicode sqrt (√)</td>
<td>6</td>
<td>0%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Modulo (%)</td>
<td>4</td>
<td>N/A</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Implicit multiplication (2x)</td>
<td>4</td>
<td>80%</td>
<td><strong>100%</strong></td>
</tr>
<tr>
<td>Extended variables (r, c, d, v, g)</td>
<td>6</td>
<td>0%</td>
<td><strong>91%</strong></td>
</tr>
</tbody>
</table>
<p><strong>Overall on targeted patterns: 90.8%</strong></p>
<p>The validation set accuracy (92%) masked critical failures on patterns like extended variables and Unicode. Testing on held-out <em>patterns</em>, not just held-out <em>samples</em>, revealed gaps the validation set didn't cover.</p>
<h2 id="the-cli-and-testing-process">The CLI and Testing Process</h2>
<p>Vidai ships with a CLI for parsing expressions:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Parse expression to prefix notation</span>
vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;x^2 + 3*y&quot;</span>
<span class="c1"># Output: + ** x 2 * 3 y</span>

<span class="c1"># Parse and evaluate with substitution</span>
vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;x^2 + y&quot;</span><span class="w"> </span>--eval<span class="w"> </span><span class="nv">x</span><span class="o">=</span><span class="m">3</span><span class="w"> </span><span class="nv">y</span><span class="o">=</span><span class="m">4</span>
<span class="c1"># Output: + ** x 2 y = 13</span>

<span class="c1"># Pure arithmetic evaluation</span>
vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;3 + 5 * 2&quot;</span><span class="w"> </span>--eval
<span class="c1"># Output: + 3 * 5 2 = 13</span>

<span class="c1"># System info (available models, GPU, etc.)</span>
vidai<span class="w"> </span>info
</code></pre></div>

<p>The test suite runs inference against hand-crafted test cases organized by category:</p>
<div class="codehilite"><pre><span></span><code>python<span class="w"> </span>scripts/eval/test_finetune.py<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--checkpoint<span class="w"> </span>models/finetune_v1_best.pt<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--verbose
</code></pre></div>

<p>Each category tests specific patterns:<br />
- <strong>Trig functions</strong>: <code>sin(x)</code>, <code>cos(theta)</code>, <code>tan(z)</code><br />
- <strong>Left associativity</strong>: <code>a - b - c</code> must produce <code>- - a b c</code>, not <code>- a - b c</code><br />
- <strong>Unicode</strong>: <code>√x</code> must produce <code>sqrt x</code></p>
<p>The <code>--verbose</code> flag shows individual failures, which proved essential for diagnosing systematic issues.</p>
<h2 id="the-power-of-synthetic-data">The Power of Synthetic Data</h2>
<p>Every training example is generated programmatically. No human labeling. No scraping math from the web.</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>Synthetic</th>
<th>Human-Labeled</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cost</td>
<td>~$0</td>
<td>~$20K for 2M examples</td>
</tr>
<tr>
<td>Speed</td>
<td>35,000 samples/second</td>
<td>~100 samples/hour</td>
</tr>
<tr>
<td>Quality</td>
<td>Perfect by construction</td>
<td>Error-prone</td>
</tr>
<tr>
<td>Distribution control</td>
<td>Complete</td>
<td>Limited</td>
</tr>
</tbody>
</table>
<p>We build the tree first, then render it as text. The prefix notation label is correct by construction, no human judgment required.</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Generate 2M training samples (~60 seconds)</span>
python<span class="w"> </span>scripts/data/generate_parser_data.py<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--output-dir<span class="w"> </span>data/parser_v4<span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--samples<span class="w"> </span><span class="m">2000000</span><span class="w"> </span><span class="se">\</span>
<span class="w">    </span>--mixed
</code></pre></div>

<h2 id="dataset-evolution-across-versions">Dataset Evolution Across Versions</h2>
<p>The datasets evolved significantly across four major versions, each addressing specific failures discovered in evaluation:</p>
<table>
<thead>
<tr>
<th>Version</th>
<th>Examples</th>
<th>Key Change</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr>
<td>V1</td>
<td>500K</td>
<td>Baseline, no explicit parentheses</td>
<td>68% (precedence bug)</td>
</tr>
<tr>
<td>V2</td>
<td>1M</td>
<td>Added explicit parentheses</td>
<td>78%</td>
</tr>
<tr>
<td>V3</td>
<td>1M</td>
<td>Mixed notation formats</td>
<td>92% validation, 78% categories</td>
</tr>
<tr>
<td>V4</td>
<td>1M + 74K fine-tune</td>
<td>Extended variables, Unicode, trig</td>
<td>90.8%</td>
</tr>
</tbody>
</table>
<h3 id="v1-the-precedence-bug">V1: The Precedence Bug</h3>
<p>The initial dataset generated random expression trees and rendered them to infix notation without parentheses. The tree <code>Mul(Add(76, 25), 67)</code> became <code>76 + 25 * 67</code>, but standard precedence parses that as <code>76 + (25 * 67)</code>. The training data contained contradictory labels for identical inputs.</p>
<h3 id="v2-explicit-parentheses">V2: Explicit Parentheses</h3>
<p>After discovering the bug, I regenerated all data with explicit parentheses: <code>((76 + 25) * 67)</code>. This made tree structure unambiguous. Accuracy jumped from 68% to 78% immediately.</p>
<h3 id="v3v4-mixed-notation-formats">V3/V4: Mixed Notation Formats</h3>
<p>Real mathematical notation varies. To train robustness, V3 and V4 used a carefully designed mix that includes <strong>30% parentheses-free expressions</strong> to teach the model operator precedence:</p>
<table>
<thead>
<tr>
<th>Format</th>
<th>Proportion</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td>Explicit parentheses, spaces</td>
<td>30%</td>
<td><code>( ( 3 + 5 ) * 2 )</code></td>
</tr>
<tr>
<td>Explicit parentheses, no spaces</td>
<td>15%</td>
<td><code>((3+5)*2)</code></td>
</tr>
<tr>
<td><strong>No parentheses, spaces</strong></td>
<td><strong>20%</strong></td>
<td><code>3 + 5 * 2</code></td>
</tr>
<tr>
<td><strong>No parentheses, no spaces</strong></td>
<td><strong>10%</strong></td>
<td><code>3+5*2</code></td>
</tr>
<tr>
<td>Negative numbers</td>
<td>8%</td>
<td><code>-5</code>, <code>-3.14</code></td>
</tr>
<tr>
<td>High precision decimals</td>
<td>5%</td>
<td><code>3.14159</code></td>
</tr>
<tr>
<td>Unicode sqrt</td>
<td>5%</td>
<td><code>√x</code>, <code>√(x+1)</code></td>
</tr>
<tr>
<td>Long chains (4+ terms)</td>
<td>5%</td>
<td><code>a + b + c + d</code></td>
</tr>
<tr>
<td>Edge cases mixed</td>
<td>2%</td>
<td>Various</td>
</tr>
</tbody>
</table>
<p>The 30% parentheses-free data was critical for teaching operator precedence. Without it, the model would only learn to copy structure from parentheses rather than understanding <code>*</code> binds tighter than <code>+</code>.</p>
<p>V3 achieved 92% on the validation set, but when I tested on extended variables (<code>r</code>, <code>c</code>, <code>d</code>, <code>v</code>, <code>g</code>), accuracy was 0%. The validation set only tested interpolation, not generalization to unseen patterns.</p>
<h3 id="v4-targeted-fine-tuning">V4: Targeted Fine-tuning</h3>
<p>Rather than retrain from scratch, I generated 74K targeted examples covering:<br />
- Extended variables in diverse contexts<br />
- Unicode symbols (<code>√</code>, <code>×</code>, <code>÷</code>)<br />
- Trigonometric functions (<code>sin</code>, <code>cos</code>, <code>tan</code>)<br />
- Left associativity cases (<code>a - b - c</code>)</p>
<p>Fine-tuning for 3,500 steps (about $3 on Runpod) brought accuracy on these patterns from 0% to 90%+.</p>
<h2 id="cloud-training-from-modal-to-runpod">Cloud Training: From Modal to Runpod</h2>
<p>Local training on my MacBook Pro (M4 Pro with the Metal Performance Shaders, or MPS backend) runs at ~1.2 iterations/second. For 50,000 steps, that's 12+ hours. Being GPU poor in this this specific sense, I turned to Modal and then to Runpod.</p>
<p>I started with <a href="https://modal.com/">Modal</a>, which offers a clean Python-native API for serverless GPU compute. You define your training function, decorate it, and Modal handles containerization and scheduling:</p>
<div class="codehilite"><pre><span></span><code><span class="nd">@app</span><span class="o">.</span><span class="n">function</span><span class="p">(</span><span class="n">gpu</span><span class="o">=</span><span class="s2">&quot;A100&quot;</span><span class="p">,</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">3600</span><span class="p">)</span>
<span class="k">def</span><span class="w"> </span><span class="nf">train_model</span><span class="p">(</span><span class="n">config</span><span class="p">:</span> <span class="nb">dict</span><span class="p">):</span>
    <span class="c1"># Training code runs on A100</span>
    <span class="o">...</span>
</code></pre></div>

<p>Modal worked well, but at $3.19/hour for A100, costs added up during iteration. I didn't want to get the $250 team plan for this small hobby project. I switched to <a href="https://www.runpod.io/">Runpod</a> for the next few training runs.</p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>GPU</th>
<th>Throughput</th>
<th>Cost/Hour</th>
<th>50K Steps Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td>Local (Macbook with M4 Pro)</td>
<td>MPS</td>
<td>1.2 it/s</td>
<td>—</td>
<td>~12 hours</td>
</tr>
<tr>
<td>Modal</td>
<td>A100 40GB</td>
<td>50 it/s</td>
<td>$3.19</td>
<td>~$0.90</td>
</tr>
<tr>
<td><strong>Runpod</strong></td>
<td><strong>A100 80GB</strong></td>
<td><strong>50 it/s</strong></td>
<td><strong>$1.89</strong></td>
<td><strong>~$0.54</strong></td>
</tr>
<tr>
<td>Runpod</td>
<td>RTX 4090</td>
<td>30 it/s</td>
<td>$0.44</td>
<td>~$0.25</td>
</tr>
</tbody>
</table>
<p>Runpod's serverless API is straightforward, but I stumbled through a few bits such as the SSH which strangely did not allow file copies, and introduced its own CLIs <code>runpod</code> and <code>runpodctl</code>. Anyhow, the workflow is below:</p>
<div class="codehilite"><pre><span></span><code><span class="nb">export</span><span class="w"> </span><span class="nv">RUNPOD_API_KEY</span><span class="o">=</span><span class="s2">&quot;your_key&quot;</span>

<span class="c1"># Start training pod</span>
python<span class="w"> </span>scripts/train/runpod_train.py<span class="w"> </span>--start<span class="w"> </span>--github-token<span class="w"> </span><span class="nv">$GITHUB_TOKEN</span>

<span class="c1"># Check status</span>
python<span class="w"> </span>scripts/train/runpod_train.py<span class="w"> </span>--status<span class="w"> </span>--job-id<span class="w"> </span>&lt;job_id&gt;

<span class="c1"># Download results</span>
python<span class="w"> </span>scripts/train/runpod_train.py<span class="w"> </span>--download<span class="w"> </span>--pod-id<span class="w"> </span>&lt;pod_id&gt;
</code></pre></div>

<p>For a 44M parameter model, RTX 4090 (24GB VRAM) is sufficient and dramatically cheaper than A100. Total training cost for V4 (pre-training + fine-tuning): approximately <strong>$12</strong>. However, the RTX4090 was not available as everyone else had the same bright idea, and I was left with the A100 to train this one. A little more money but I am glad I could take a shot at doing this.</p>
<p>One lesson from my Runpod experience: don't use Runpod's SSH proxy (<code>ssh.runpod.io</code>). It blocks PTY and breaks SCP/rsync. Always use the direct IP:port from the Runpod API. This was a drag, and kept a pod running long after it was needed.</p>
<h2 id="what-i-learned">What I Learned</h2>
<p>Each plateau during development had a clear cause unrelated to model capacity:</p>
<table>
<thead>
<tr>
<th>Problem</th>
<th>Root Cause</th>
<th>Fix</th>
<th>Impact</th>
</tr>
</thead>
<tbody>
<tr>
<td>82% ceiling</td>
<td>Neural net memorizing arithmetic</td>
<td>Separate parsing from computation</td>
<td>+10pts</td>
</tr>
<tr>
<td>68% sequence accuracy</td>
<td>Contradictory training labels</td>
<td>Explicit parentheses in data</td>
<td>+27pts</td>
</tr>
<tr>
<td>92% validation, 0% on <code>r</code>, <code>c</code>, <code>d</code></td>
<td>Incomplete variable coverage</td>
<td>Extended variable list</td>
<td>+91pts (subset)</td>
</tr>
<tr>
<td>0% on trig functions</td>
<td>Missing training examples</td>
<td>Targeted fine-tuning</td>
<td>+100pts (subset)</td>
</tr>
</tbody>
</table>
<p><strong>The fix was never a better model. It was a clearer formulation of what the model should be learning.</strong></p>
<p>Key lessons:</p>
<ol>
<li><strong>Data quality &gt; model size &gt; training time</strong>: The jump from 68% to 95% came from fixing the data.</li>
<li><strong>The gap between token and sequence accuracy is diagnostic</strong>: A 30-point gap (98% token, 68% sequence) pointed directly at precedence ambiguity.</li>
<li><strong>Pre-training + fine-tuning is powerful even at small scale</strong>: 74K fine-tuning examples fixed patterns that 1M pre-training samples missed.</li>
<li><strong>Tokenizer coverage matters</strong>: Missing <code>ln</code> from the vocabulary means the model cannot output it, regardless of training.</li>
<li><strong>Test on held-out patterns, not just held-out samples</strong>: 92% on the validation set masked 0% on unseen variable names.</li>
</ol>
<h2 id="related-work-mathgpt-and-tree-decoders">Related Work: MathGPT and Tree Decoders</h2>
<p>Several approaches have explored tree-based representations for mathematical expressions in neural networks. It's worth comparing Vidai to these methods.</p>
<p><strong>MathGPT</strong> modifies GPT-2 by linearizing operator trees (OPTs) via depth-first traversal, adding tree position embeddings (binary representations of sibling indices) plus symbol type embeddings. It uses constrained decoding to ensure valid tree output. On equation extraction tasks, MathGPT achieves 52.4% tree match accuracy versus GPT-2's 47.8%.</p>
<p><strong>Tree Decoders</strong> (seq2tree, Graph2Tree) generate expression trees directly for Math Word Problems (MWPs). These approaches use graph-based encoders for input text and tree-structured decoders for output, achieving state-of-the-art results on benchmarks like Math23K.</p>
<p><strong>Skip-tree training</strong> masks subtrees in formal math corpora and trains LLMs to predict missing parts, yielding strong logical reasoning capabilities.</p>
<p>How does Vidai differ?</p>
<table>
<thead>
<tr>
<th>Aspect</th>
<th>MathGPT / Tree Decoders</th>
<th>Vidai</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Goal</strong></td>
<td>Learn math end-to-end</td>
<td>Learn parsing only</td>
</tr>
<tr>
<td><strong>Input encoding</strong></td>
<td>Token-level with tree position</td>
<td>Character-level ASCII</td>
</tr>
<tr>
<td><strong>Output format</strong></td>
<td>OPT via constrained decoding</td>
<td>Prefix notation (unconstrained)</td>
</tr>
<tr>
<td><strong>Computation</strong></td>
<td>Neural (learned)</td>
<td>Deterministic (SymPy)</td>
</tr>
<tr>
<td><strong>Architecture</strong></td>
<td>Decoder-only (GPT-based)</td>
<td>Encoder-decoder</td>
</tr>
<tr>
<td><strong>Training data</strong></td>
<td>Math word problems, proofs</td>
<td>Synthetic expression pairs</td>
</tr>
</tbody>
</table>
<p>The key philosophical difference: MathGPT and tree decoders attempt to learn mathematical reasoning end-to-end. Vidai deliberately avoids this. The neural network learns only to parse human notation into trees; the mathematics is delegated to symbolic engines that are correct by construction.</p>
<p>This separation has trade-offs:<br />
- <strong>Vidai's advantage</strong>: 100% computation accuracy, exact fractions, symbolic algebra via SymPy. No risk of the model "hallucinating" that 3 + 5 = 9.<br />
- <strong>Vidai's limitation</strong>: Cannot learn new mathematical operations or solve novel problem types without extending the symbolic engine. MathGPT can potentially generalize to new patterns if trained on enough examples.</p>
<p>For tasks where correctness is non-negotiable (financial calculations, engineering, scientific computing), Vidai's approach is safer. For exploratory mathematical reasoning where approximate or heuristic solutions are acceptable, end-to-end approaches like MathGPT may be more flexible.</p>
<p>The tree representation insight is shared: both MathGPT and Vidai recognize that mathematical expressions are fundamentally trees, not sequences. MathGPT encodes this through tree position embeddings; Vidai encodes it through prefix notation output. The difference is what happens after the tree is extracted.</p>
<h2 id="known-issues">Known Issues</h2>
<h3 id="what-works-without-parentheses">What Works Without Parentheses</h3>
<p>Testing with the CLI shows that simple two-term precedence works correctly:</p>
<div class="codehilite"><pre><span></span><code>$<span class="w"> </span>vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;3 + 5 * 2&quot;</span>
+<span class="w"> </span><span class="m">3</span><span class="w"> </span>*<span class="w"> </span><span class="m">5</span><span class="w"> </span><span class="nv">2</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">13</span>.0<span class="w">   </span>✓<span class="w">  </span><span class="o">(</span>correctly<span class="w"> </span>parsed<span class="w"> </span>as<span class="w"> </span><span class="m">3</span><span class="w"> </span>+<span class="w"> </span><span class="o">(</span><span class="m">5</span>*2<span class="o">))</span>

$<span class="w"> </span>vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;10 - 2 * 3&quot;</span>
-<span class="w"> </span><span class="m">10</span><span class="w"> </span>*<span class="w"> </span><span class="m">2</span><span class="w"> </span><span class="nv">3</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">4</span>.0<span class="w">   </span>✓<span class="w">  </span><span class="o">(</span>correctly<span class="w"> </span>parsed<span class="w"> </span>as<span class="w"> </span><span class="m">10</span><span class="w"> </span>-<span class="w"> </span><span class="o">(</span><span class="m">2</span>*3<span class="o">))</span>

$<span class="w"> </span>vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;a + b * c&quot;</span>
+<span class="w"> </span>a<span class="w"> </span>*<span class="w"> </span>b<span class="w"> </span>c<span class="w">          </span>✓<span class="w">  </span><span class="o">(</span>correctly<span class="w"> </span>parsed<span class="w"> </span>as<span class="w"> </span>a<span class="w"> </span>+<span class="w"> </span><span class="o">(</span>b*c<span class="o">))</span>
</code></pre></div>

<h3 id="what-fails-without-parentheses">What Fails Without Parentheses</h3>
<p>Complex expressions with implicit multiplication or 3+ terms still struggle:</p>
<div class="codehilite"><pre><span></span><code>$<span class="w"> </span>vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;x^2 + 3*y&quot;</span>
*<span class="w"> </span>+<span class="w"> </span>**<span class="w"> </span>x<span class="w"> </span><span class="m">2</span><span class="w"> </span><span class="m">3</span><span class="w"> </span>y<span class="w">     </span>✗<span class="w">  </span>Got<span class="w"> </span><span class="o">(</span>x²<span class="w"> </span>+<span class="w"> </span><span class="m">3</span><span class="o">)</span><span class="w"> </span>*<span class="w"> </span>y,<span class="w"> </span>expected<span class="w"> </span>x²<span class="w"> </span>+<span class="w"> </span><span class="o">(</span><span class="m">3</span>*y<span class="o">)</span>

$<span class="w"> </span>vidai<span class="w"> </span>parse<span class="w"> </span><span class="s2">&quot;a + b + c * d&quot;</span>
+<span class="w"> </span>a<span class="w"> </span>+<span class="w"> </span>b<span class="w"> </span>*<span class="w"> </span>c<span class="w"> </span>d<span class="w">      </span>✗<span class="w">  </span>Got<span class="w"> </span>a<span class="w"> </span>+<span class="w"> </span><span class="o">(</span>b<span class="w"> </span>+<span class="w"> </span><span class="o">(</span>c*d<span class="o">))</span>,<span class="w"> </span>expected<span class="w"> </span><span class="o">(</span>a<span class="w"> </span>+<span class="w"> </span>b<span class="o">)</span><span class="w"> </span>+<span class="w"> </span><span class="o">(</span>c*d<span class="o">)</span>
</code></pre></div>

<p>The model handles basic operator precedence but struggles with:<br />
1. Implicit multiplication combined with other operations<br />
2. Left-associativity in chains of 3+ terms</p>
<p>Despite having 30% parentheses-free data in training, these edge cases remain problematic. The workaround is to use explicit parentheses for complex expressions.</p>
<h3 id="other-issues">Other Issues</h3>
<p><strong><code>ln</code> function missing from tokenizer</strong></p>
<div class="codehilite"><pre><span></span><code>Input:  ln(y)
Expected: ln y
Got:      &lt;unk&gt;n y
</code></pre></div>

<p>The output vocabulary has <code>log</code> but not <code>ln</code>. Fix requires vocabulary expansion and retraining.</p>
<h2 id="whats-next-higher-mathematics-as-trees">What's Next: Higher Mathematics as Trees</h2>
<p>The same architecture extends naturally to higher mathematics. The key observation: differentiation, integration, differential equations, and other advanced operations are themselves tree transformations. The neural network's job remains parsing; the symbolic engine handles the mathematical heavy lifting.</p>
<h3 id="calculus-operations">Calculus Operations</h3>
<p>Differentiation and integration are operators that take an expression and a variable. Consider the derivative of <code>x³ + 2x</code> with respect to <code>x</code>:</p>
<div class="codehilite"><pre><span></span><code>Input:  &quot;d/dx(x³ + 2x)&quot;

Tree:
              diff
             /    \
            +      x
           / \
          ^   <span class="gs">*</span>
<span class="gs">         / \ / \</span>
<span class="gs">        x  3 2  x</span>

<span class="gs">Prefix: diff + *</span>* x 3 * 2 x x
</code></pre></div>

<p>The <code>diff</code> operator sits at the root, with the expression as its left child and the differentiation variable as its right child. The expression subtree is identical to what we saw in arithmetic.</p>
<p>Integration follows the same pattern:</p>
<div class="codehilite"><pre><span></span><code>Input:  &quot;∫ sin(x) dx&quot;

Tree:
          integrate
           /     \
         sin      x
          |
          x

Prefix: integrate sin x x
</code></pre></div>

<p>The neural network learns to parse the many notational variants ("d/dx", "∂/∂x", "f'(x)", "dy/dx", prime notation) into this canonical tree form.</p>
<h3 id="physics-the-kinematics-equation">Physics: The Kinematics Equation</h3>
<p>Consider a classic physics problem: an airplane accelerating down a runway. The displacement equation is <code>s = ut + ½at²</code>. Given initial velocity <code>u</code>, acceleration <code>a</code>, and time <code>t</code>, solve for <code>s</code>:</p>
<div class="codehilite"><pre><span></span><code>Input:  &quot;s = u*t + (1/2)*a*t²&quot;

Tree:
                  =
                 / \
                s   +
                   / \
                  <span class="k">*</span>   *
                 / \ / \
                u  t <span class="gs">*  ^</span>
<span class="gs">                    / \/ \</span>
<span class="gs">                   /  1  t  2</span>
<span class="gs">                  /   -</span>
<span class="gs">                 a    2</span>

<span class="gs">Prefix: = s + *</span> u t <span class="gs">* *</span> / 1 2 a ** t 2
</code></pre></div>

<p>The entire equation, including the equality, is a tree. Once parsed, SymPy can solve for any variable, substitute values, or derive related equations. The neural network's job is to understand that "½" and "1/2" and "0.5" all mean the same thing, that "t²" means <code>t**2</code>, and that the equation represents a relationship between physical quantities. The network can parse unicode strings and also understand fractions, meaning that this differential equation may be solved in some future version of vidai.</p>
<h3 id="linear-algebra-systems-of-equations">Linear Algebra: Systems of Equations</h3>
<p>A system of linear equations is also a tree structure. Consider solving:</p>
<div class="codehilite"><pre><span></span><code><span class="mf">2</span><span class="n">x</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mf">3</span><span class="n">y</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mf">7</span>
<span class="n">x</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="n">y</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="mf">1</span>
</code></pre></div>

<div class="codehilite"><pre><span></span><code>Input:  &quot;2x + 3y = 7, x - y = 1&quot;

Tree:
              system
              /    \
             =      =
            / \    / \
           +   7  -   1
          / \    / \
         <span class="k">*</span>   *  x   y
        / \ / \
       2  x 3  y

Prefix: system = + <span class="gs">* 2 x *</span> 3 y 7 = - x y 1
</code></pre></div>

<p>SymPy's <code>solve</code> function takes this tree and returns <code>{x: 2, y: 1}</code>. The neural network handles the parsing: commas versus newlines, implicit multiplication, various equation separator conventions.</p>
<h3 id="determinants-and-matrices">Determinants and Matrices</h3>
<p>Matrix operations extend the same idea. A determinant computation:</p>
<div class="codehilite"><pre><span></span><code>Input:  &quot;det([[a, b], [c, d]])&quot;

Tree:
            det
             |
           matrix
            / \
          row  row
          / \  / \
         a  b c  d

Prefix: det matrix row a b row c d
</code></pre></div>

<p>The symbolic engine computes <code>ad - bc</code>. For numerical matrices, NumPy handles the computation. For symbolic matrices, SymPy does this.</p>
<h3 id="the-key-point">The Key Point</h3>
<p>All of these, from simple arithmetic to differential equations to linear algebra, are text tokens that can be represented as trees. The encoder-decoder model learns the mapping from human notation to a tree structure from training data. The trees are then executed by symbolic engines that have been proved mathematically to work and which have a steady definition of how to execute.</p>
<p>The neural network never learns calculus or linear algebra itself. Instead, it learns that when humans write "d/dx", they mean differentiation, and that when they write "det", they mean determinant. The mathematics is handled by code that does not require gradient descent.</p>
<h3 id="the-general-pattern">The General Pattern</h3>
<table>
<thead>
<tr>
<th>Domain</th>
<th>Parse Target</th>
<th>Execution Engine</th>
</tr>
</thead>
<tbody>
<tr>
<td>Arithmetic</td>
<td>Prefix notation tree</td>
<td>Exact fraction arithmetic</td>
</tr>
<tr>
<td>Algebra</td>
<td>Expression tree</td>
<td>SymPy simplify/solve</td>
</tr>
<tr>
<td>Calculus</td>
<td>Diff/integrate trees</td>
<td>SymPy diff/integrate</td>
</tr>
<tr>
<td>ODEs</td>
<td>Equation + conditions</td>
<td>SymPy dsolve</td>
</tr>
<tr>
<td>PDEs</td>
<td>Equation + boundary</td>
<td>SymPy pdsolve</td>
</tr>
<tr>
<td>Linear Algebra</td>
<td>Matrix expressions</td>
<td>NumPy/SymPy</td>
</tr>
</tbody>
</table>
<p>Each domain follows the same template: the neural network converts human notation into a canonical tree representation, and a symbolic engine executes the mathematics. The parsing problem scales with notational complexity; the computation problem is already solved.</p>
<p>This extends beyond mathematics. Code (parse to AST, execute with interpreter), proofs (parse to inference steps, verify with proof checker), chemistry (parse to molecular structure, simulate with physics engine). The pattern is general: neural network for parsing noisy human notation, symbolic engine for deterministic computation. </p>
<h2 id="conclusion">Conclusion</h2>
<p>Building Vidai taught me something that seems obvious in retrospect: the hardest part of applying mathematical computation using neural networks, is not the computation itself, but the process of understanding what computation is being <em>asked</em> for. Once you frame the problem as parsing rather than calculating, the architecture of the neural network in question becomes clear. I foresee Vidai becoming an important bridge and integrating with frameworks like <a href="https://pravalagents.com">Praval</a> in future, thereby allowing agents to parse mathematical input natively. </p>
<p>The 27-point accuracy gain from fixing the precedence bug, compared to negligible gains from model scaling, reinforced a lesson that applies broadly: for structured tasks, data quality dominates model size. At some level we have known this since the <a href="https://arxiv.org/abs/2306.11644">Textbooks are all you need</a> paper. The model was never the bottleneck. The formulation of the problem was.</p>
<p>Vidai is far from complete. Complex precedence chains still fail. The <code>ln</code> token is missing from the vocabulary. Implicit multiplication combined with other operations confuses the parser. But the architecture is sound, and each failure points to a clear fix in the data or vocabulary rather than a fundamental limitation.</p>
<p>Perhaps most importantly, this project reminded me why I find machine learning compelling. Watching my son struggle with arithmetic, I saw the same parsing challenges that trip up neural networks. The structure must be understood before the computation can proceed. For humans and machines alike, the answer (விடை) comes only after the question is properly understood.</p>
<h2 id="try-it-yourself">Try It Yourself</h2>
<p>You can try Vidai directly in your browser. Enter a mathematical expression and see how the model parses it into prefix notation, then evaluates it using SymPy:</p>
<div style="display: flex; justify-content: center; margin: 2rem 0;">
<iframe
    src="https://aiexplorations-vidai-demo.hf.space"
    frameborder="0"
    width="100%"
    height="600"
    style="max-width: 900px; border: 1px solid #ddd; border-radius: 8px;"
></iframe>
</div>

<p><strong>Tips for best results:</strong><br />
- Simple expressions work reliably: <code>3 + 5 * 2</code>, <code>sin(pi/2)</code>, <code>sqrt(16)</code><br />
- Use parentheses for complex expressions: <code>(x^2) + (3*y)</code> instead of <code>x^2 + 3*y</code><br />
- Variable substitution: enter <code>x=3, y=4</code> in the substitutions field</p>
<hr />
<p><strong>Resources:</strong><br />
- <strong>Model</strong>: <a href="https://huggingface.co/aiexplorations/vidai">aiexplorations/vidai</a> on HuggingFace<br />
- <strong>Interactive Demo</strong>: <a href="https://huggingface.co/spaces/aiexplorations/vidai-demo">aiexplorations/vidai-demo</a> on HuggingFace Spaces<br />
- <strong>Source Code</strong>: <a href="https://github.com/aiexplorations/vidai">aiexplorations/vidai</a> on GitHub</p>
<h2 id="references">References</h2>
<p>Lample, G., &amp; Charton, F. (2020). Deep learning for symbolic mathematics. <em>ICLR 2020</em>. <a href="https://arxiv.org/abs/1912.01412">arXiv:1912.01412</a></p>
<p>Liu, T. (2023). Goat: Fine-tuned LLaMA outperforms GPT-4 on arithmetic tasks. <a href="https://arxiv.org/abs/2305.14201">arXiv:2305.14201</a></p>
<p>Nye, M., et al. (2021). Show your work: Scratchpads for intermediate computation with language models. <a href="https://arxiv.org/abs/2112.00114">arXiv:2112.00114</a></p>
<p>Trask, A., Hill, F., Reed, S., Rae, J., Dyer, C., &amp; Blunsom, P. (2018). Neural arithmetic logic units. <em>NeurIPS 2018</em>. <a href="https://arxiv.org/abs/1808.00508">arXiv:1808.00508</a></p>
<p>Wei, J., et al. (2022). Chain-of-thought prompting elicits reasoning in large language models. <em>NeurIPS 2022</em>. <a href="https://arxiv.org/abs/2201.11903">arXiv:2201.11903</a></p>
<p>Google DeepMind. (2024). <a href="https://deepmind.google/discover/blog/ai-solves-imo-problems-at-silver-medal-level/">AI achieves silver-medal standard solving International Mathematical Olympiad problems</a>.</p>
<p>Veličković, P. et al (2025). Category theory for neural networks. <a href="https://youtu.be/AWqvBdqCAAE">Machine Learning Street Talk</a>.</p>
<p><a href="https://arxiv.org/html/2411.16993v1">https://arxiv.org/html/2411.16993v1</a> </p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>vidai</category>
      <category>transformers</category>
      <category>symbolic-math</category>
      <category>parsing</category>
      <category>neuro-symbolic</category>
      <category>python</category>
    </item>
    <item>
      <title>ToDACoMM: Topology and The Shape of Learning Algorithms</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-12-26-todacomm.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-12-26-todacomm.html</guid>
      <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
      <description>When you train a neural network, you&apos;re running a dynamical system that carves out a representation space. ToDACoMM measures the topology of what gets carved, revealing a striking divide between encoders and decoders, and now extends to MLPs and large-scale transformer analysis.</description>
      <content:encoded><![CDATA[<p>A clock is a computer that performs modular arithmetic. When the hour hand points to 11 and three hours pass, it points to 2, not 14. The clock face encodes the fact that hours wrap around; there is no hour 14, only hour 2. This wrapping is modulo 12: $11 + 3 = 14 \equiv 2 \pmod{12}$.</p>
<p>A clock face is circular because the arithmetic is circular. Position on the circle encodes the hour; adding time means rotating around the circle. This Welch Labs <a href="https://www.youtube.com/watch?v=D8GOeCFFby4">video on grokking</a> uses this example to explain something surprising that researchers discovered when they trained a neural network on modular addition.</p>
<p>The model OpenAI's team built as per the <a href="https://arxiv.org/abs/2201.02177">Grokking paper</a> was tiny, a small transformer learning to compute $(a + b) \mod p$ for some prime $p$. It memorized the training examples quickly and the model loss dropped quite fast, but generalization was poor despite the loss being low. It turns out that someone on the OpenAI team left the model to continue training and came back to the unexpected discovery that performance on held-out test sets was excellent. Long after the training was deemed to be complete, the model had actually "learned" or "grokked". </p>
<p>When they visualized the learned representations, the OpenAI team found interesting weight representations in the layers of the model. The numbers 0 through $p-1$ were arranged around circular patterns in the weight space, with Fourier components encoding <em>position</em> on the circle. The model had independently discovered that addition mod $p$ lives on a circle, just like we discussed in the clock example at the start of this blog. The team had found Lissajous figures in the weight visualizations - the kind you will see on an oscilloscope in a signals laboratory. These were trigonometric embeddings, the same mathematical structure behind positional encodings in transformers, but discovered autonomously for this specific task.</p>
<p>I watched this video months with rapt attention a few days ago, months after I had started building what would become <a href="https://github.com/aiexplorations/todacomm">ToDACoMM</a>. Here was evidence that models learn structured geometric representations, and I had been building tools to measure exactly that kind of structure. </p>
<p>My own intuitions came from a few failed experiments on topological data analysis (TDA). I had figured that if only some vectors were represented significantly in vector space there would be voids in the data that was being fed to LLMs, and if the different attention layers and heads we described within models were learning from this data, they too would be developing such voids and other patterns with interesting and non trivial topologies in their weights. It followed that I should explore the nature of the geometry and topology of the weights, because this is where some insight into <em>how</em> models may learn, was likely to be present. I am sure I am not the first guy to think of this, and in fact there has been a lot of research in topological deep learning and TDA for deep learning models. TDA is itself a very old consideration, decades old if not centuries, but it seemed very topical and relevant.</p>
<h2 id="two-ways-of-seeing">Two Ways of Seeing</h2>
<p>In fact, there are two lenses through which I have come to see neural networks, and they are older than neural networks themselves.</p>
<p>The first is <strong>dynamical systems</strong>. Training is gradient flow on the loss landscape $\mathcal{L}(\theta)$, a trajectory through parameter space following $\dot{\theta} = -\nabla_\theta \mathcal{L}$. Deep learning practitioners know this intuitively: the optimizer moves through weight space, gets stuck in local minima, escapes via momentum or learning rate schedules, eventually settles somewhere useful. Grokking is a phase transition where the system escapes a memorization basin and finds a generalizing solution.</p>
<p>The forward pass is also dynamical. Layer by layer, representations evolve through a composition of nonlinear maps $f_L \circ f_{L-1} \circ \cdots \circ f_1$. Each layer transforms the geometry of the activation space. In deep learning terms: early layers extract low-level features, later layers compose them into higher-level representations. In dynamical systems terms: the input evolves through a sequence of nonlinear transformations, each reshaping the space.</p>
<p>The second is <strong>topology</strong>. Where dynamical systems ask "how does this evolve?", topology asks "what is the shape of the space it evolves in?"</p>
<p>For deep learning practitioners, think of it this way: when you visualize embeddings with t-SNE or UMAP, you see clusters (similar items grouped together) and sometimes you see loops or manifold structure. Topology formalizes this. Persistent homology captures shape at multiple scales: it tracks how connected components ($H_0$, roughly "clusters"), loops ($H_1$, roughly "circular patterns"), and voids ($H_2$) appear and disappear as you vary a distance threshold. The <em>persistence</em> of a feature measures its significance; noise creates short-lived features, real structure persists.</p>
<p>These two perspectives are not separate. The shape of a space constrains what can happen within it. If representations cluster tightly, certain distinctions become hard to learn. If they spread into loops or manifolds, certain patterns become natural to encode. Measuring the topology of learned representations tells us something about what the training dynamics carved out.</p>
<h2 id="reading-shape-from-points">Reading Shape from Points</h2>
<p>Imagine scattering a handful of coins on a table. Some land close together, others far apart. If you squint, you might see clusters; coins that fell near each other form natural groups. If you arranged them deliberately in a circle, you would see the ring shape even though the coins themselves are just points. This isn't dissimilar to finding clusters as you might in some data, except we're not looking for decision boundaries in topology.</p>
<p>Persistent homology is a method for detecting such "structure" algorithmically. The idea is simple: grow a ball around each point, starting from radius zero. At first, each point is isolated, and there are as many separate components as there are points. As the radius increases, balls begin to overlap. When two balls touch, their points become connected and two components merge into one. These are connected components. Keep growing, and eventually everything connects into a single blob.</p>
<p>The trick is to watch what happens along the way. Components that merge quickly were close together; they were probably part of the same cluster. Components that persist as separate until late in the process were genuinely far apart. A feature that appears and disappears quickly is likely noise, and a feature that persists across a wide range of radii reflects real structure in the data. The latter mechanism is quite intuitive if you had begun to imagine these balls which we grew intersecting and becoming connected components in your mind's eye.</p>
<p>This presence of features across a wide range of radii is the "persistent" in <em>persistent homology</em>: we care not just about what features exist, but how long they last.</p>
<p>Loops work similarly. As balls grow and overlap, they sometimes form closed rings before filling in completely. If five points are arranged in a pentagon, the balls will first connect into a cycle, and only later will the interior fill in when the radius grows large enough. The cycle is born when the ring closes and dies when the interior fills. A cycle that persists for a long time indicates genuine circular structure; one that dies immediately was just an accident of the point configuration.</p>
<p>The <em>Vietoris-Rips</em> complex is the specific construction that makes this precise, rather than just an arbitraty mechanism. At each radius $\epsilon$, we connect points that are within distance $\epsilon$ of each other. As $\epsilon$ grows from zero to infinity, features appear and disappear. Ripser is an algorithm, implemented as a Python library, that computes this efficiently even for thousands of points in dozens of dimensions. It returns a list of birth-death pairs: each pair records when a topological feature was born (at what radius) and when it died. The difference, death minus birth, is the persistence.</p>
<p>In the language of homology: $H_0$ counts connected components (clusters), $H_1$ counts loops (circular patterns), and $H_2$ counts voids (hollow cavities). For analyzing neural network representations, $H_0$ and $H_1$ are the most informative, because we are dealing with layers in neural networks. High $H_0$ persistence means the points are spread out, with well-separated clusters. High $H_1$ persistence means there are genuine circular or periodic structures in the geometry.</p>
<p>When I run Ripser on the activations of a transformer layer, I am asking: what is the shape of the space these representations occupy? Are they clustered or diffuse? Do they trace out loops? The answers turn out to differ dramatically between encoder and decoder architectures.</p>
<h2 id="what-models-learn">What Models Learn</h2>
<p>Neural network training is iterative error correction: forward pass, loss computation, backpropagation, weight update. The dynamics converge (when they converge) to regions of weight space where the model's internal representations support accurate prediction.</p>
<p>What are these representations? For a transformer processing text, each layer produces activations $h^{(l)} \in \mathbb{R}^{d}$ for each token. If you've worked with transformers, you know these aren't arbitrary vectors. The embedding layer maps tokens to a learned space where semantic similarity corresponds to geometric proximity; "king" and "queen" are closer than "king" and "banana". Attention layers then transform these representations based on context, and feedforward layers apply nonlinear transformations.</p>
<p>For the model to generalize, it must organize representations so that similar contexts cluster, syntactic patterns are geometrically encoded, and semantic relationships become spatial. The model learns a <em>representation manifold</em>, a high-dimensional space where the structure of language is reflected in geometry.</p>
<p>This manifold is shaped by the training dynamics. Each gradient update pushes and pulls the representation geometry, separating what should be distinguished, clustering what should be similar. When we measure the topology of trained representations, we are measuring what the optimization process carved out.</p>
<p>The Grokking paper showed one such carving, Fourier circles for modular arithmetic. Circles are the right geometry for cyclic groups. What shapes do language models carve? What is the topology of GPT-2's representation space versus BERT's? This is what ToDACoMM was built to investigate.</p>
<h2 id="the-metrics-what-we-measure-and-why">The Metrics: What We Measure and Why</h2>
<p>Before diving into the tool and its findings, it helps to understand what we are actually measuring and what each metric tells us about neural network representations.</p>
<h3 id="topological-metrics">Topological Metrics</h3>
<p><strong>H0: Connected Components (Cluster Structure)</strong></p>
<p>H0 counts how many separate "islands" exist in the data at different scales. If you imagine the point cloud of neural network activations, H0 asks: how many distinct clusters are there, and how far apart are they?</p>
<p>When H0 persistence is high, representations are spread out with well-separated groups. When it is low, everything clusters together. For a classifier, you might expect H0 to increase through the network as the model separates different classes into distinct regions. For a language model, the pattern is more complex; representations must both cluster (similar meanings together) and spread (different contexts distinguishable).</p>
<p><strong>H1: Loops (Cyclic Structure)</strong></p>
<p>H1 counts circular patterns in the data. If representations trace out a ring or cycle as you vary some property of the input, H1 detects it. The grokking model learned circles because modular arithmetic is inherently cyclic; H1 would capture this.</p>
<p>In language models, H1 might reflect periodic patterns: days of the week forming a cycle, verb conjugations with recurring structure, or the periodicity inherited from positional encodings. High H1 persistence means these cycles are robust features of the representation geometry, not artifacts.</p>
<p><strong>Persistence: Separating Signal from Noise</strong></p>
<p>Not all topological features are meaningful. Some clusters merge quickly; some loops fill in immediately. Persistence measures how long a feature survives as we vary the scale parameter. A feature with high persistence, one that appears early and dies late, reflects genuine structure. A feature with low persistence is likely noise.</p>
<p>When we report "total H0 persistence" or "max H1 lifetime," we are summarizing how much robust structure exists. A model with high total persistence has carved out a more structured representation space.</p>
<h3 id="geometric-metrics">Geometric Metrics</h3>
<p>Topology tells us about shape, but not about scale or density. The geometric metrics fill this gap.</p>
<p><strong>Intrinsic Dimension</strong></p>
<p>A 768-dimensional activation vector does not actually use all 768 dimensions. The data lies on a lower-dimensional manifold embedded in this high-dimensional space. Intrinsic dimension estimates the true dimensionality of this manifold.</p>
<p>If GPT-2's embeddings have intrinsic dimension 32, the model is using roughly 32 independent degrees of freedom to encode token meanings, despite the 768-dimensional container. Watching intrinsic dimension change through layers reveals how the network compresses or expands its representation complexity.</p>
<p><strong>Hubness</strong></p>
<p>In high-dimensional spaces, distance behaves strangely. Some points become "hubs," appearing in many other points' nearest-neighbor lists simply due to the geometry of high dimensions, not because they are semantically central. This is the curse of dimensionality manifesting in k-NN structure.</p>
<p>A hubness score of 1.0 indicates uniform k-NN structure: no pathological hubs. Scores above 1.0 indicate hub points exist. For neural network representations, we want hubness near 1.0; it means the learned space has healthy geometry where nearest-neighbor relationships are meaningful, not artifacts of dimensionality.</p>
<p><strong>Distance Distribution</strong></p>
<p>How far apart are representations? The mean, variance, and shape of the k-NN distance distribution tell us whether points are tightly packed, spread out, or somewhere in between. Tracking this through layers reveals whether the network expands representations (spreading them apart) or compresses them (pulling them together).</p>
<h3 id="why-these-metrics-together">Why These Metrics Together</h3>
<p>No single metric tells the full story. Intrinsic dimension might drop while H0 persistence rises; the network is compressing to fewer dimensions but spreading points within that subspace. Hubness might normalize while H1 count stays constant; the k-NN structure improves without changing the topological complexity.</p>
<p>The combination of topological metrics (H0, H1, persistence) and geometric metrics (intrinsic dimension, hubness, distance distribution) provides a multi-faceted view of representation geometry. Together, they characterize what gradient descent carved out.</p>
<h2 id="measuring-the-carved-space">Measuring the Carved Space</h2>
<p>ToDACoMM (Topological Data Analysis Comparison of Multiple Models) characterizes transformer representations using persistent homology. The pipeline:</p>
<ol>
<li>Extract activations ${h_i^{(l)}}$ at each layer $l$ for $n$ text samples</li>
<li>Project to $k=50$ principal components (retaining ~95% variance)</li>
<li>Compute Vietoris-Rips persistent homology via Ripser</li>
<li>Extract topological summaries: total persistence, max lifetimes, feature counts</li>
</ol>
<h3 id="system-architecture">System Architecture</h3>
<div class="mermaid-asset" style="--mermaid-natural-width: 1170px"><img src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/mermaid/mermaid-01-28e81e86311f.svg" alt="Mermaid diagram 1 for ToDACoMM: Topology and The Shape of Learning Algorithms" width="1170" height="1574" decoding="async"></div>

<h3 id="quick-start">Quick Start</h3>
<div class="codehilite"><pre><span></span><code><span class="c1"># Install</span>
git<span class="w"> </span>clone<span class="w"> </span>https://github.com/aiexplorations/todacomm
<span class="nb">cd</span><span class="w"> </span>todacomm
pip<span class="w"> </span>install<span class="w"> </span>-e<span class="w"> </span><span class="s2">&quot;.[dev]&quot;</span>

<span class="c1"># Analyze a single model</span>
todacomm<span class="w"> </span>run<span class="w"> </span>--model<span class="w"> </span>gpt2<span class="w"> </span>--samples<span class="w"> </span><span class="m">500</span>

<span class="c1"># Compare encoder vs decoder</span>
todacomm<span class="w"> </span>run<span class="w"> </span>--models<span class="w"> </span>gpt2,bert<span class="w"> </span>--samples<span class="w"> </span><span class="m">500</span>

<span class="c1"># Use all layers (14 for GPT-2)</span>
todacomm<span class="w"> </span>run<span class="w"> </span>--model<span class="w"> </span>gpt2<span class="w"> </span>--layers<span class="w"> </span>all

<span class="c1"># GPU acceleration</span>
todacomm<span class="w"> </span>run<span class="w"> </span>--model<span class="w"> </span>gpt2<span class="w"> </span>--device<span class="w"> </span>cuda
</code></pre></div>

<h3 id="supported-models">Supported Models</h3>
<p>ToDACoMM includes 20+ pre-configured transformer models under 1B parameters, plus configurable MLP architectures:</p>
<table>
<thead>
<tr>
<th>Family</th>
<th>Models</th>
<th>Parameters</th>
<th>Type</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>GPT-2</strong></td>
<td>gpt2, gpt2-medium, distilgpt2</td>
<td>82-354M</td>
<td>Decoder</td>
</tr>
<tr>
<td><strong>BERT</strong></td>
<td>bert, distilbert</td>
<td>66-110M</td>
<td>Encoder</td>
</tr>
<tr>
<td><strong>Pythia</strong></td>
<td>pythia-70m, pythia-160m, pythia-410m</td>
<td>70-410M</td>
<td>Decoder</td>
</tr>
<tr>
<td><strong>SmolLM2</strong></td>
<td>smollm2-135m, smollm2-360m</td>
<td>135-360M</td>
<td>Decoder</td>
</tr>
<tr>
<td><strong>Qwen</strong></td>
<td>qwen2-0.5b, qwen2.5-0.5b, qwen2.5-coder-0.5b</td>
<td>500M</td>
<td>Decoder</td>
</tr>
<tr>
<td><strong>OPT</strong></td>
<td>opt-125m, opt-350m</td>
<td>125-350M</td>
<td>Decoder</td>
</tr>
<tr>
<td><strong>MLP</strong></td>
<td>shallow_2, shallow_3, medium_4, medium_5, deep_6</td>
<td>&lt;1M</td>
<td>Feedforward</td>
</tr>
</tbody>
</table>
<p>MLP models support MNIST, FashionMNIST, and UCI tabular datasets (iris, wine, digits, breast_cancer). Custom HuggingFace models: <code>todacomm run --hf-model &lt;model-name&gt; --num-layers &lt;N&gt;</code></p>
<h3 id="output-structure">Output Structure</h3>
<p>Each experiment generates:</p>
<div class="codehilite"><pre><span></span><code>experiments/&lt;model&gt;_tda_&lt;timestamp&gt;/
├── runs/run_0/
│   ├── tda_summaries.json      # H0/H1 metrics per layer
│   ├── metrics.json            # Perplexity, accuracy
│   ├── tda_interpretation.md   # Human-readable analysis
│   └── visualizations/
│       ├── tda_summary.png     # 6-panel metric overview
│       ├── layer_persistence.png
│       └── betti_curves.png
└── reports/
    └── experiment_report.md    # Full analysis report
</code></pre></div>

<p>The visualization plots show:<br />
- <strong>tda_summary.png</strong>: H0/H1 count, total persistence, and max lifetime across layers<br />
- <strong>layer_persistence.png</strong>: Side-by-side comparison of H0 vs H1 evolution<br />
- <strong>betti_curves.png</strong>: Feature count trends through the transformer stack</p>
<h3 id="tda-methodology-details">TDA Methodology Details</h3>
<p>The dimensionality reduction step is practical: persistent homology on 768-dimensional point clouds is computationally prohibitive. PCA to 50 dimensions preserves most of the variance while making computation tractable. This is a tradeoff; we might miss structure in the discarded components, but the patterns that emerge are robust across different choices of $k$.</p>
<p>The Vietoris-Rips complex works by growing balls around each point. At radius $\epsilon = 0$, each point is its own connected component. As $\epsilon$ grows, balls overlap, points connect, and the topology changes. The algorithm tracks when topological features (components, loops) are born and when they die. A feature that persists across a wide range of $\epsilon$ is likely real structure; a feature that dies quickly is likely noise.</p>
<p>The key metrics:</p>
<ul>
<li>
<p><strong>H0 Total Persistence</strong>: Sum of lifetimes of all connected components. In deep learning terms: how spread out are the representations? If activations form tight clusters, H0 is low. If they spread across the space, H0 is high.</p>
</li>
<li>
<p><strong>H1 Total Persistence</strong>: Sum of lifetimes of all loops. In deep learning terms: are there circular or periodic patterns in the representation geometry? High H1 indicates the model has learned representations with loop structure.</p>
</li>
<li>
<p><strong>Expansion Ratio</strong>: $\text{peak}(H_0) / H_0^{(0)}$, where $H_0^{(0)}$ is the embedding layer. This captures how much the geometry transforms through the network. A ratio of 1x means the representation geometry doesn't change much from embedding to final layer. A ratio of 100x means dramatic expansion.</p>
</li>
</ul>
<p>I analyzed ten models across five architecture families (GPT-2, BERT, Pythia, SmolLM2, Qwen), each processing 500 WikiText-2 samples. Bootstrap resampling (B=100) provided 95% confidence intervals.</p>
<h2 id="beyond-transformers-mlps-as-a-baseline">Beyond Transformers: MLPs as a Baseline</h2>
<p>Before examining transformer topology in depth, it helps to understand what TDA reveals about simpler architectures. ToDACoMM now supports multi-layer perceptrons, the feedforward networks that predate attention mechanisms by decades. Without the recombinatory complexity of attention, the geometric transformations in MLPs are more direct, providing a cleaner baseline for interpretation.</p>
<p>A 3-layer MLP trained on MNIST (784 → 256 → 128 → 10) shows something quite different from transformers. The input layer, a 784-dimensional space of pixel intensities, has an intrinsic dimension around 15; the MNIST manifold is far smaller than the ambient space suggests. Through the hidden layers, this dimension compresses further, dropping to approximately 7 by the output layer. The network learns a progressively lower-dimensional representation as it approaches the 10-class decision boundary.</p>
<p><img alt="Combined geometry and TDA analysis for a 3-layer MLP on MNIST, showing monotonic compression of intrinsic dimension and topological complexity through the network" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/mlp_shallow3_combined.png" /></p>
<p>The hubness score tells a parallel story. In high-dimensional spaces, some points become "hubs", appearing disproportionately often in other points' nearest-neighbor lists due to the concentration of measure. Good representations should have hubness near 1.0, indicating uniform k-NN structure. The MLP achieves this; starting at 0.63 and remaining below 1.0 throughout, the network maintains uniform neighborhood structure as it compresses.</p>
<p>Both H0 and H1 persistence decrease monotonically through the MLP layers. The input has the highest topological complexity; the output has the lowest. This is compression in the topological sense: the network simplifies the representation space as it projects toward class centroids.</p>
<p><img alt="Comparison across MLP depths showing that deeper networks achieve lower final dimensionality and simpler topology" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/mlp_model_comparison.png" /></p>
<p>Comparing across depths reveals a consistent pattern: deeper networks compress more aggressively. The 2-layer network ends with intrinsic dimension around 7.7; the 6-layer network compresses to 4.5. Hubness drops toward zero in deeper networks, and both H0 and H1 persistence decrease with depth. More layers means more opportunity to simplify the representation geometry.</p>
<p>This contrasts sharply with the expansion patterns of transformers. Where GPT-2 spreads representations outward as it processes tokens, MLPs compress them inward. Both are valid geometric strategies serving different computational goals: transformers accumulate context across tokens, while MLPs project inputs toward decision boundaries.</p>
<h2 id="scaling-up-20000-samples">Scaling Up: 20,000 Samples</h2>
<p>The initial transformer findings with 500 samples raised a question: would the patterns persist at scale, or were they artifacts of limited sampling? With 500 points, the Vietoris-Rips complex is computationally tractable, but statistical confidence is limited.</p>
<p>The latest version of ToDACoMM supports extraction of 10,000 to 50,000 activation samples from transformers, with intelligent subsampling for the TDA computation itself. The workflow: extract activations for all samples, characterize geometry (intrinsic dimension, hubness, distance distributions) on the full set, then subsample to 2,000 points for Ripser. This preserves the statistical benefits of large samples while keeping homology computation feasible.</p>
<p>The geometry characterization step, new in this version, computes several metrics before TDA:</p>
<ul>
<li><strong>MLE intrinsic dimension</strong>: Maximum likelihood estimate of the manifold dimension</li>
<li><strong>Local PCA dimension</strong>: Average dimensionality of local neighborhoods</li>
<li><strong>Hubness score</strong>: Skewness of the k-occurrence distribution</li>
<li><strong>Distance statistics</strong>: Mean, variance, and distribution of k-NN distances</li>
</ul>
<p>These metrics are cheaper to compute than persistent homology and provide complementary information. Intrinsic dimension tells us how many degrees of freedom the representations actually use; hubness indicates whether the space has pathological concentration; distance distributions reveal the spread and clustering of points.</p>
<h2 id="gpt-2-at-scale-compression-through-depth">GPT-2 at Scale: Compression Through Depth</h2>
<p>Running GPT-2 (124M parameters) and GPT-2-medium (354M parameters) on 20,000 WikiText-2 samples revealed something I had suspected but not confirmed at smaller scales: the intrinsic dimension of representations compresses dramatically through the network.</p>
<p><img alt="Geometry evolution through GPT-2's 12 layers, showing the 32→14 intrinsic dimension compression and hubness normalization from 4.58 to 1.05" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/gpt2_geometry_evolution.png" /></p>
<p>At the embedding layer, GPT-2's MLE intrinsic dimension is approximately 32. By the final layer, it drops to around 14, a 57% reduction. The model starts with high-dimensional token embeddings and progressively squeezes them into a lower-dimensional subspace as it builds contextual representations.</p>
<p>The hubness trajectory is equally striking. The embedding layer has hubness of 4.58, indicating severe hub structure; some token embeddings appear in many other tokens' nearest-neighbor lists. Through the layers, hubness drops monotonically, reaching 1.05 by the final layer. The network transforms pathologically concentrated embeddings into uniformly distributed representations.</p>
<p>The distance distribution reveals something unexpected: representations spread apart dramatically in middle layers (peaking at layer 9), then reconsolidate in final layers. The mean k-NN distance rises from 0.48 at embedding to 34.2 at layer 9, then falls to 8.6 at the final layer. This expansion-then-contraction pattern suggests the network first separates representations to build context-specific meanings, then gathers them back for prediction.</p>
<p><img alt="Combined geometry and TDA analysis for GPT-2, showing the interplay between dimensionality compression, hubness normalization, and topological complexity" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/gpt2_combined_analysis.png" /></p>
<p>The TDA metrics corroborate this picture. H0 total persistence (cluster spread) peaks at layer 9, matching the distance distribution peak. H1 count (loop structures) remains relatively stable at around 1,700 across all layers, suggesting the cyclic structure is inherent to the representation manifold rather than an artifact of particular layers. The "Dim vs Topology" scatter plot shows the progression: embedding starts high-dimensional with moderate loops, middle layers spread out in both dimensions, and final layers compress dimensionally while maintaining topological structure.</p>
<p><img alt="Comparison of GPT-2 and GPT-2-medium showing how model scale affects representation geometry" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/transformer_model_comparison.png" /></p>
<p>Comparing GPT-2 to GPT-2-medium reveals scaling effects. The larger model maintains slightly higher intrinsic dimension (15.0 vs 13.8) and achieves near-perfect hubness normalization (1.0 vs 1.05). Interestingly, GPT-2-medium has fewer H1 loops (1,438 vs 1,720) despite having nearly 3x the parameters. More capacity may enable simpler topological structure; the larger model can represent the same information with less geometric complexity.</p>
<h2 id="smollm-a-different-architecture-a-different-trajectory">SmolLM: A Different Architecture, A Different Trajectory</h2>
<p>SmolLM presents a contrast to GPT-2. Where GPT-2 has 12 layers with 768-dimensional hidden states, SmolLM-135M has 30 layers with 576-dimensional hidden states. More layers, smaller width. Running the same 20,000-sample analysis reveals a fundamentally different geometric trajectory.</p>
<p><img alt="Geometry evolution through SmolLM-135M's 30 layers, showing the two-phase compression pattern and the dramatic penultimate layer anomaly" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/smollm_geometry_evolution.png" /></p>
<p>The intrinsic dimension trajectory shows two distinct phases. In layers 0-7, dimension remains high around 43, essentially unchanged from the embedding. Then a sharp transition occurs: by layer 14, dimension drops to 27, where it plateaus through layer 21. This is not the smooth, monotonic compression of GPT-2; it is a phase transition, a discrete jump in the representation regime.</p>
<p>The penultimate layer anomaly is the most striking feature. Layer 28 shows hubness spiking to 7.81, far above any other layer. The k-NN distance distribution explodes, with mean distance reaching 204 (compared to 64 at layer 21). Something dramatic happens in the penultimate layer: representations spread apart explosively, creating severe hub structure, before the final layer re-normalizes them.</p>
<p>What could cause this? One possibility: the penultimate layer is creating maximally separated "hub" representations to give the final layer clean inputs for prediction. Another: the architecture's depth-to-width ratio creates bottlenecks that manifest as geometric instability near the output. The pattern is reproducible; SmolLM-360M shows the same penultimate spike at layer 24, though attenuated (hubness 3.33, distance 156).</p>
<p><img alt="Comparison of SmolLM-135M and SmolLM-360M showing similar final-layer metrics despite different trajectories" src="/blog/ai-explorations/posts/2025-12-26-todacomm/images/smollm_model_comparison.png" /></p>
<p>Comparing SmolLM to GPT-2 at similar parameter counts reveals architectural fingerprints:</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Params</th>
<th>Layers</th>
<th>Final Dim</th>
<th>Final Hubness</th>
<th>H1 Count</th>
</tr>
</thead>
<tbody>
<tr>
<td>GPT-2</td>
<td>124M</td>
<td>12</td>
<td>13.8</td>
<td>1.05</td>
<td>1,720</td>
</tr>
<tr>
<td>SmolLM-135M</td>
<td>135M</td>
<td>30</td>
<td>20.1</td>
<td>1.99</td>
<td>1,969</td>
</tr>
<tr>
<td>GPT-2-medium</td>
<td>355M</td>
<td>24</td>
<td>15.0</td>
<td>1.00</td>
<td>1,438</td>
</tr>
<tr>
<td>SmolLM-360M</td>
<td>362M</td>
<td>32</td>
<td>19.1</td>
<td>1.87</td>
<td>2,051</td>
</tr>
</tbody>
</table>
<p>SmolLM retains higher intrinsic dimension (20 vs 14), maintains higher hubness (~2 vs ~1), and builds more topological loops (~2,000 vs 1,400-1,700). The deeper, narrower architecture preserves more structure through to the final layer. Whether this is beneficial depends on the task; more structure means more information retained, but also more complexity to navigate during inference.</p>
<p>The two-phase compression and penultimate anomaly are SmolLM's topological signature. They distinguish it from GPT-2's smooth expansion-contraction as clearly as GPT-2's 95x expansion ratio distinguishes decoders from BERT's 2x.</p>
<h2 id="the-encoder-decoder-divide">The Encoder-Decoder Divide</h2>
<p>BERT showed an expansion ratio of 2x. Representations in the final layer were only twice as spread as in the embedding layer.</p>
<p>GPT-2 showed 95x. Other decoders ranged from 55x (DistilGPT-2) to 694x (SmolLM2-360M).</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Parameters</th>
<th>Expansion Ratio</th>
</tr>
</thead>
<tbody>
<tr>
<td>BERT</td>
<td>110M</td>
<td>2x</td>
</tr>
<tr>
<td>DistilGPT-2</td>
<td>82M</td>
<td>55x</td>
</tr>
<tr>
<td>GPT-2</td>
<td>117M</td>
<td>95x</td>
</tr>
<tr>
<td>Pythia-70M</td>
<td>70M</td>
<td>143x</td>
</tr>
<tr>
<td>Pythia-410M</td>
<td>410M</td>
<td>189x</td>
</tr>
<tr>
<td>SmolLM2-135M</td>
<td>135M</td>
<td>298x</td>
</tr>
<tr>
<td>Qwen2-0.5B</td>
<td>500M</td>
<td>629x</td>
</tr>
<tr>
<td>SmolLM2-360M</td>
<td>360M</td>
<td>694x</td>
</tr>
</tbody>
</table>
<p>Why such a stark difference?</p>
<p>Consider what BERT and GPT-2 are doing differently. BERT uses <em>bidirectional attention</em>: every token attends to every other token from layer one. When processing "The cat sat on the mat", the representation of "cat" at layer 1 already incorporates information from "sat", "mat", and everything else. The full relational structure is available immediately.</p>
<p>GPT-2 uses <em>causal attention</em>: each token can only attend to preceding tokens. The representation of "cat" at layer 1 only knows about "The". By layer 6, it knows about "The cat sat on the". By the final layer, it has accumulated the full prefix. This progressive accumulation requires the representation to expand; each layer must encode more context than the last.</p>
<p>In geometric terms: BERT's representations don't need to unfold because all context is accessible from the start. GPT-2's representations must unfold progressively, encoding an expanding window of context into the geometry. The 2x versus 55-694x expansion ratios are the topological signature of this architectural difference.</p>
<h2 id="architecture-fingerprints">Architecture Fingerprints</h2>
<p>Within decoder families, topological signatures remained consistent across training variations. The three Qwen variants (2-0.5B, 2.5-0.5B, Coder-0.5B) showed expansion ratios of 629x, 673x, and 642x respectively. These models differ in training data (general vs code) and version, but their topological fingerprint is stable.</p>
<p>Pythia scaled with model size: 143x at 70M parameters, 189x at 410M. More parameters means more capacity to expand the representation space.</p>
<p>These are fingerprints. Architecture determines topological regime more than training recipe does. If you told me a model's expansion ratio, I could likely guess its architecture family.</p>
<p>SmolLM2 was anomalous, with expansion varying from 298x (135M) to 694x (360M). This might reflect architectural differences between sizes, or something about how this family encodes information. The variance is worth investigating.</p>
<h2 id="cyclic-structure">Cyclic Structure</h2>
<p>Every model showed non-trivial H1 at 500 samples. There are loops in the representation geometry of every transformer I examined.</p>
<p>What does H1 mean in deep learning terms? If representations trace out a circular path in activation space as you vary some property of the input, that's H1. The grokking model learned circles because modular arithmetic is cyclic. What circular structure might language models learn?</p>
<p>Possibilities: syntactic patterns that recur (subject-verb-object cycles), semantic fields with circular relationships (days of the week, compass directions), positional patterns from the periodic positional encodings. The H1 persistence might be detecting some of this learned periodicity.</p>
<p>SmolLM2-360M stood out: H1 total persistence of 129.52, more than 3x higher than the next model. This model builds unusually strong cyclic structure. I do not yet understand what this corresponds to in terms of learned features, but it distinguishes this model topologically.</p>
<h2 id="limitations">Limitations</h2>
<p>ToDACoMM is <em>descriptive</em>, not predictive. It measures representation geometry but does not explain <em>why</em> models behave as they do. The 55x expansion of DistilGPT-2, for example, coincides with best-in-class perplexity among decoders, but we know that correlation does not automatically imply causation and so we cannot claim that that is a causal relationship. However, ToDACoMM may reveal a few interesting directions for teams who want to build models, and who might use such findings as a way to steer the direction of their model's development.</p>
<p>Further, ten models across five families is enough to see some general patterns and form hypotheses. It is not enough to make strong claims about the mechanism of learning purely using topological methods or measures. From a data standpoint, WikiText-2 is one dataset, and our findings on the topology of learning in these models might differ on other datasets.</p>
<p>Another thing worth bearing in mind, is that persistent homology is a <em>coarse invariant</em>. Two spaces with identical $H_0$ and $H_1$ can differ in geometrically significant ways. We are measuring coarse-grained shape, and not fine structure.</p>
<p>The PCA projection discards information. Patterns in the discarded 5% of variance might matter. This is a pragmatic choice, not an ideal one.</p>
<p>While these are not reasons to dismiss the findings, they impose restrictions on what we can claim. ToDACoMM is, therefore, an empirical tool for characterization, not a full theory of representation learning.</p>
<h2 id="the-shape-of-what-was-carved">The Shape of What Was Carved</h2>
<p>Gradient descent on the loss landscape carves out a representation manifold. The topology of this manifold reflects both the optimization dynamics and the structure of the training data.</p>
<p>Encoders, with bidirectional attention, carve compact spaces; context is globally available, so representations don't need to expand to encode it. Decoders, with causal attention, carve expansive spaces; context must be accumulated layer by layer, and the accumulation manifests as geometric expansion.</p>
<p>The 2x versus 55-694x divide follows from attention's arrow. This isn't a mysterious emergent property; it's a direct consequence of what the architectures are computing.</p>
<p>Within the carved spaces, cyclic structures form. Whether these reflect the periodicity of language, learned positional structure, or something else, they are consistently present. The grokking paper showed that models can learn geometrically appropriate representations (circles for cyclic arithmetic). The H1 findings suggest language models also learn geometric structure that reflects their training data.</p>
<p>Poincaré connected topology and dynamics in the 19th century. Neural networks are a domain where this connection can be measured empirically.</p>
<h2 id="directions">Directions</h2>
<p>The large-scale experiments with GPT-2 and the MLP baselines have answered some initial questions while opening new ones. The encoder-decoder divide persists at 20,000 samples; the expansion-then-contraction pattern in GPT-2's distance distributions is robust. But several threads remain:</p>
<ul>
<li>
<p><strong>Training dynamics</strong>: Track topological changes during training. Does grokking have a topological signature? When does the encoder-decoder divide emerge? The MLP training pipeline now makes this tractable; one could checkpoint activations at each epoch and watch the geometry evolve.</p>
</li>
<li>
<p><strong>The expansion-contraction pattern</strong>: GPT-2's representations spread dramatically in middle layers then reconsolidate. SmolLM shows a different pattern: two-phase compression with a penultimate layer explosion. Is this a width-vs-depth tradeoff? Do wider models (GPT-2) smooth their trajectories while deeper, narrower models (SmolLM) accumulate instabilities?</p>
</li>
<li>
<p><strong>The penultimate layer anomaly</strong>: SmolLM's layer 28 shows extreme hubness (7.58) and H0 persistence (253k), an order of magnitude above surrounding layers. This pattern is reproducible across SmolLM sizes. What computational purpose does this serve? Is it a bottleneck effect, a feature of the architecture, or something about how the model was trained?</p>
</li>
<li>
<p><strong>MLP depth effects</strong>: Deeper MLPs show monotonically simpler final topology. Is there a critical depth beyond which additional layers provide diminishing topological simplification? The 6-layer preset approaches this question but does not answer it.</p>
</li>
<li>
<p><strong>$H_1$ interpretation</strong>: The cyclic structures persist across model scales and architectures. What do they correspond to? The stability of H1 count across GPT-2's layers (around 1,700 throughout) suggests these cycles are fundamental to the representation manifold, not layer-specific artifacts.</p>
</li>
<li>
<p><strong>Connection to weight dynamics</strong>: I've been exploring a complementary approach in <a href="https://github.com/aiexplorations/deep_learning_dynamics">Deep Learning Dynamics</a>, which uses perturbation analysis and Lyapunov exponents to measure how neural network weights evolve during training. ToDACoMM measures what gets carved in activation space; Deep Learning Dynamics measures how the carving happens in weight space. The preliminary finding that transformers universally diverge in weight space while showing dramatic expansion ratios in activation space suggests these phenomena may be related. The new geometry characterization metrics (intrinsic dimension, hubness) provide additional handles for connecting the two perspectives.</p>
</li>
</ul>
<p>The framework is open source. The methodology and statistical analysis are documented. Others can extend this to their own models and domains.</p>
<hr />
<p><em>ToDACoMM is available on <a href="https://github.com/aiexplorations/todacomm">GitHub</a>. The technical report includes methodology, bootstrap confidence intervals, and ablation studies.</em></p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>mechanistic-interpretability</category>
      <category>topology</category>
      <category>dynamical-systems</category>
      <category>persistent-homology</category>
      <category>transformers</category>
      <category>mlp</category>
      <category>tda</category>
    </item>
    <item>
      <title>AI Explorations - 2025 In Review</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-12-27-year-2025-review.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-12-27-year-2025-review.html</guid>
      <pubDate>Sat, 27 Dec 2025 00:00:00 GMT</pubDate>
      <description>A comprehensive look back at my year in AI - scaling up at work, building the Praval agentic AI framework, Vajra Search, Tlon mathematics, ToDACoMM, exploring mechanistic interpretability, honest research failures, and reflections on curiosity vs. accomplishment.</description>
      <content:encoded><![CDATA[<h1 id="2025-year-in-review-ai-explorations">2025 Year in Review: AI Explorations</h1>
<p>It is December, 2025 and I find myself reflecting on a very eventful year for me - a year of prolific building, exploration, reflection and scaling up - both at work and in terms of technical and intellectual side projects that touch AI. We've all been bombarded by AI innovations and enhancements from the frontiers and I am no different. This has been a year of plenty in that sense, in the sense of discovering how to work with AI tools to build, explore, and scale that which we want to do. The new AI projects both on the work front and open source AI innovation on the personal front have been plentiful.</p>
<p>This year I made 587 GitHub contributions, across 18 new repositories, and published two frameworks - <a href="https://github.com/aiexplorations/praval">Praval</a> and <a href="https://github.com/aiexplorations/vajra_bm25">Vajra Search</a> - on PyPI. Professionally, this year saw tens of new initiatives at work, and have been for the past few months in an expanded role at work that touches the strategy of how we use and build AI features, how we build with AI, and more. The above contributions are not including the stuff I do at work - where I lead a team building a SaaS platform for HR. </p>
<p>Looking back on 2025, I find a tension running through everything I built, everything I abandoned, and everything I learned. <em>This tension was born out of a recognition that the field of AI and many other fields as of 2025 stand forever changed</em>. Professionals like me are using AI tools to become more productive and expand our execution capabilities on the one hand, even as we are aware of the dependency on AI tools on the other hand. This has led to a tension I have discussed elsewhere - of needing to use the tools on the one hand to benefit from them, and on the other hand, not wanting to abandon the craft of programming and engineering.</p>
<p>In this post, I will take you through what it was to build, explore and do much more than I would individually have been able to do with the help of AI, and what I learned from these experiences. I will also walk through Praval, Vajra, and other things I worked on.</p>
<h2 id="2025-at-work-a-short-summary">2025 at Work - A Short Summary</h2>
<p>At work, I brought both greater innovation and greater professional growth to my team. We built a credible capability in building AI agents end-to-end, and have been leveraging this for numerous projects. I believe that the older LLM powered workflows will soon be replaced by AI Agents for a number of use cases. The AI teams I've worked with, which are my own and the team I collaborate with, have been incredible. </p>
<p>From a people standpoint, finding the right talent brings me a lot of satisfaction these days, and my team have coalesced into an effective unit in the last eight months. In mid 2025, I was promoted to a broader role, and this was a vindication of some of the calls I have had to take, with respect to career, team, company and more. I'd say looking back that deep involvement and an innovation mindset have been strong contributors to my own growth. Despite running a remote team, synergies have been possible, thanks to the leadership and the horizon-scanning we all in AI leadership do. </p>
<p>The innovation capacity of this team, and a forward-thinking leadership and strategy team keep me interested - these are the nuts and bolts of how I have been able to deliver through others effectively at work. It takes a new breed of leader to successfully navigate the changing landscape of new models, new benchmarks, new AI tools, while handling the interpersonal and team dynamics of a team that has high-end talent and which is shipping AI products. I am grateful to be working with such leaders at work. There's never been a more <a href="https://en.wikipedia.org/wiki/VUCA">VUCA</a> environment than 2023 for me, but 2025 was close. In a sense, this was a good kind of uncertainty, driven by innovation and not Covid or post-Covid based risk. That is perhaps why I think this year was a great test of our ability to stay relevant as a team and for me personally, as a leader and innovator in the AI space.</p>
<p>My team is trying to stay relevant in the fast-changing software engineering space. As I lead a team that's building software for a more traditional industry that's being yanked into the world of AI powered SaaS, we had to learn to build and ship at scale and fast. Despite processes, constraints, architecture support, and lots of resources, it takes strong behavioural changes to actually ship things fast and at high quality. For me, this has meant digging into every system at work, be it our clusters, our repos across products and how we run them, how the team tests and ships things, how we write specifications, etc. One of the big enablers here too has been AI. I have not seen a single user story in the last 3 months at work which did not have objectives, acceptance criteria and the like written out in it. Rarely have seen a PR of late without a good conventional commit style description. This took a while, and the tooling has, I feel, finally caught up with the ambitions I have had for our standards of work. While we have always worked in two week sprints for the last several years, of late, we tried the 3 week development sprint with a testing mini sprint that's bolted on to it, to test and ship things each month. A new SDLC realignment helped us go back to the faster 2 week sprint's pace.</p>
<p>I've honestly grown quite fond of the team I'm working with and leading at work in the last year. The incredible innovation and speed they've shown is especially inspiring, especially on AI agents and the process of building full end-to-end features with them. Not only does my team understand the fast changing landscape of tools, technologies and capabilities, but are able to scale up to new challenges and innovate really fast. This has made all the difference to our effectiveness over the last year. </p>
<h1 id="coding-assistants-and-ai-powered-tools-in-2025-and-looking-ahead">Coding Assistants and AI Powered Tools in 2025 and Looking Ahead</h1>
<p>All through the year, my team's uptake of Cursor and Agentic IDEs has been a significant phenomenon. This inspired me to use Claude Code, to which I have effectively switched, for all my personal projects and open source projects. From rules and configurations that make Cursor effective for us, to subagents, extensive process level checks, and many more innovations, the results of AI powered software engineering have been commendable. A big realization for me personally, has been that <strong>there's no going back</strong>. Software will be written by AI agents, and we will be building AI agents for different use cases in future. In this sense the "AI is eating software" narrative continues from 2025 into 2026 as well, since the innovation in that space does not stop. There are caveats just as there are with any technology - AI slop is one of the defininig negative trends of 2025. While this is discussed in the context of the <em>enshittification</em> of the internet and its content, there is something to be said for the same for code. Vibe coded applications have gone mainstream in 2025, and at work too, there was a tension on the use of coding assistants. My best engineers leverage AI tools for what it is good at. Although the puck keeps moving on this one, as models and agents get better, it helps to be prepared to write things the way humans wrote them for years before the advent of AI agents and coding assistants. </p>
<p>I learnt many other lessons that threw caution to the winds as well. Chiefly, the architectural and first principles thinking that's crucial to us, and as I say to my team - "the need to be the pilot even if you have an AI copilot". This is crucial. The direction and the vision for any product or system we build with AI or without has to be owned by humans, and not by AI. Many friends feel that GPT-5.2, Claude Opus 4.5 are two-of-a-kind models as of December 2025, and we don't know if this represents a form of <em>artificial super intelligence</em>. (Note that I didn't say <em>artificial general intelligence</em> as I think that is a pipe dream.) I would not entirely disagree with them, because I think scaling laws have gotten to a point where complex internal representations in deep learning models can yield true conceptual understanding. This all reminds me of the <a href="https://arxiv.org/abs/2201.02177">Grokking</a> paper, which describes how neural networks actually learn. Why is this relevant? Well, if an LLM is an ideation partner, where do we draw the line in terms of what is directed by human intelligence and what is directed by AI? Where do we draw the line in terms of the genesis or the validation of ideas? I am reminded of a talk by Qodo founder Itamar Friedman <a href="https://www.madrona.com/engineering-ai-era-qodo-dedy-kredo-on-ai-powered-sdlc/">here</a> - his pivot from building coding assistants to building code quality tools for AI coding assistants is an interesting pivot. The standards we need can only be defined by humans, and not by AI. And this is possible by humans because <em>the biggest context engineering pipeline is the physical layer of reality</em>. That might sound cheeky, but it is perhaps the truth. This crucial mental model has to be internalized if AI professionals have to stay  relevant in 2026 and beyond.</p>
<p>Another key lesson from 2025, is that ideas are extremely cheap, and execution is becoming cheaper. The GLM-4.x models from Z.ai have become both inexpensive to run and a worthy code generation competitor to Claude Code with the Claude Opus 4.5 model. This has happened just in the past few days, and I expect this momemtum to carry on into 2026. Kimi K2 Thinking and DeepSeek v3.2 represent SOTA AI models that are available at the fraction of the cost of the big $200/month subscriptions from Claude Code or OpenAI. This makes them more than suitable for content and code generation at scale and low cost. I expect that in 2026, tokens will become too cheap to meter for many SOTA models. The ChatGPT Go subscription launched in India is an example of a big lab taking advantage of this phenomenon, and we are likely to see others follow suit. This race to the bottom will make advanced capabilities table stakes more often in 2026, and have a further impact on the use of these tools for work, coding and so on. As of this writing in December 2025, AI agents are still <em>unable</em> to build, test and deploy entire software applications yet at scale with one prompt or one click. I think this is set to change in the coming year or two. OpenAI's founder alluded to "on-demand software" as being a trend during his talk at the GPT-5 launch. I personally think this is a secular trend, like small language models have been in 2025. This will all help make coding assistants become less expensive, faster and higher quality in 2026. </p>
<hr />
<h2 id="personal-projects-and-ai-explorations-what-i-built-in-2025">Personal Projects and AI Explorations: What I Built in 2025</h2>
<p>Building agentic AI applications in 2025 was a natural extension to the LLM based applications I built in 2024. While Ollama and local LMs were the flavour of last year, the reducing costs of AI APIs has meant that I did a lot more experimentation in 2025 with OpenAI. This year has also seen me embrace Claude Code and terminal UIs and back track on using Cursor a little bit for personal coding projects and open source projects. Cursor still remains a competent agentic IDE and especially after Cursor's Composer model, seems to have been rejuvenating developer interest in the tool. Cursor helped me build a number of applications earlier in the year, but the bulk of what I've built in the latter part of the year has been with Claude.</p>
<p>Architectural thinking is one of the biggest areas where I have gained from these experiences. As someone who came into 2025 without a lot of experience building full stack applications, this year has been transformative. Having spent the initial portion of the year at work building on top of agentic AI and MCP capabilities, I found myself pivoting to my own tooling in the latter part of the year for these tasks. I wrote a few MCP servers and my own agentic AI framework, both of which were exciting developments.</p>
<p>As the year progressed, I found myself exploring frontiers I had not touched a lot more. A good example of this is Tlon mathematics, and another is category theory based search, viz. Vajra Search. I also dipped into mechanistic interpretability of deep learning and transformer models later in the year. I will discuss these below. Behind this front of three or four open source projects that are meaty and substantial, there were at least a hundred experiments on various topics. </p>
<p>Below, I cover each of these projects in a little bit of detail. On this site, there are other posts that describe numerous challenges, issues and obstacles I have overcome in the year, and how I did so. Here, though, there is a short summary in each case.</p>
<h3 id="praval">Praval</h3>
<p>The personal project I am most excited about this year is a multi-agent AI framework where agents collaborate like coral polyps forming a reef. This is now open source, on PyPI and Github and eagerly seeking contributors to build on top of it. The name I chose for this framework is <em>Praval</em>, which is Sanskrit word for coral (प्रवाल), and the metaphor runs deep; just as coral reefs emerge from simple organisms coordinating without central control, Praval agents broadcast knowledge through "spores" and respond to what they find relevant, with no manager directing traffic.</p>
<p>The framework reached version 0.7.20 this year, published on PyPI, with a decorator-based API that feels natural to Python developers:</p>
<div class="codehilite"><pre><span></span><code><span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;researcher&quot;</span><span class="p">,</span> <span class="n">responds_to</span><span class="o">=</span><span class="p">[</span><span class="s2">&quot;query&quot;</span><span class="p">])</span>
<span class="k">def</span><span class="w"> </span><span class="nf">researcher</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
    <span class="n">findings</span> <span class="o">=</span> <span class="n">chat</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Research: </span><span class="si">{</span><span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="p">[</span><span class="s1">&#39;topic&#39;</span><span class="p">]</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="n">broadcast</span><span class="p">({</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;analysis_request&quot;</span><span class="p">,</span> <span class="s2">&quot;data&quot;</span><span class="p">:</span> <span class="n">findings</span><span class="p">})</span>
</code></pre></div>

<p>Praval allows for a similar decorator for tools (<code>@tool</code>), native memory capabilities using Chroma DB, and with support for Qdrant. Agent to agent communication is a native feature of Praval, as is knowledge passing between agents using Spores. Check out the framework at the <a href="https://pravalagents.com">official website</a>.</p>
<p>What excites me about Praval isn't just the code; it's what it <em>represents</em>. Most agent frameworks assume a hierarchical structure, with orchestrators directing workers. Praval bets on emergence, on the idea that intelligence can arise from peer interactions without anyone being in charge. Whether that bet pays off in production systems remains to be seen, but the exploration has been valuable regardless.</p>
<p>I also found out where Praval does <em>not</em> work well - Praval Code being one such project. Code generation agents, like <strong>Claude Code</strong>, which I love using for all my work, are <em>not</em> a good fit for Praval. The core reason for this is that code generation seems to be favoured by hierarchical, sub-agent patterns, unlike Praval, which is set up to be suitable for large scale multi-agent collaboration without a central orchestrator. I tried building <strong>Praval Code</strong> but unsuccessfully. That said, I expect this to be a fruitful area of exploration in the future.</p>
<p>The ecosystem for Praval has <a href="https://github.com/aiexplorations/praval_deep_research">Praval Deep Research</a> as a showcase project. This is a cool local-first app for researchers to find papers on ArXiv and <em>chat</em> with their findings. Apart from other experimental projects I've been exploring with friends, such as Praval Analytics, I've also been sketching out Praval Medha, a conceptual system where agents spawn other agents based on problem requirements; <em>agents that architect agents</em>. This is a project I am very excited about, and I expect to be able to build on it in the coming year.</p>
<p>What is the end-game here with Praval? I was asked this by many friends and mentors. Making Praval an open source project allowed it to become a community project, and I expect this to be a fruitful area of exploration in the future. Thanks to incredible collaborators like <a href="https://www.linkedin.com/in/bargava/">Bargava</a> and mentors like <a href="https://www.linkedin.com/in/chandramoulics/">CM</a> I have been able to sink some time into this framework. What I look forward to with Praval:</p>
<ul>
<li>Contributors who I can work with to add many new features for Praval</li>
<li>Users who can build on top of Praval to create new and innovative applications</li>
<li>Users who can help me build a community around Praval and educate and engage with developers who use Praval</li>
</ul>
<p><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/praval">github.com/aiexplorations/praval</a> | <strong>PyPI</strong>: <a href="https://pypi.org/project/praval/">pypi.org/project/praval</a></p>
<p><strong>Related Project</strong>: <a href="https://github.com/aiexplorations/praval_deep_research">github.com/aiexplorations/praval_deep_research</a></p>
<h3 id="vajra-bm25">Vajra BM25</h3>
<p>If Praval was about agentic AI and multi-agent collaboration, <a href="https://github.com/aiexplorations/vajra_bm25">Vajra BM25</a> was about mathematical foundations and high-performance search powered by a combination of clean and composable search abstractions and low latency performance. The name "Vajra" comes from Sanskrit (वज्र, "thunderbolt"), and the project began as an experiment: could category theory abstractions make a search engine's code cleaner? The inspiration came from <a href="https://www.youtube.com/watch?v=I8LbkfSSR58&amp;list=PLbgaMIhjbmEnaH_LTkxLI7FMa2HsnawM_">Bartosz Milewski's lectures</a> on category theory, combined with work on Elasticsearch at my day job.</p>
<p>With category theory, I was able to frame BM25 search using coalgebras (state → possible next states) and morphisms (composable transformations). Search becomes "coalgebraic unfolding" where a query state unfolds into ranked results. The codebase has a <code>categorical/</code> module with <code>Morphism</code>, <code>Functor</code>, and <code>Coalgebra</code> base classes that derived implementations extend.</p>
<p>There's something interesting that I learnt about category theory and its relevance and applicability to search in this project. In a nutshell, category theory did not make Vajra fast. The speed came from engineering choices - NumPy vectorization, sparse matrices, LRU caching, inverted index filtering, and partial sort for top-k. What category theory provided was clean code organization and a unified interface that works for both graph search and document retrieval. This is more valuable than it seems, because when you get into the numbers game, you tend to look at pure performance at all costs without due attention to the underlying architecture. And as I said earlier, the architecture is one area I have been paying a lot of attention to this year.</p>
<p>The latest benchmarks show Vajra achieving <strong>~1.2-1.3x faster latency</strong> than BM25S on BEIR and Wikipedia datasets, with sub-2ms query times even at 500K documents. Vajra includes built-in LRU caching for production workloads - cold queries take 0.14-1.9ms (depending on corpus size), while repeated queries with caching enabled return in microseconds. This combination of solid cold performance and optional caching makes it practical for real-world applications.</p>
<p>The project reached v0.3.0 on PyPI this year, with an interactive CLI (<code>vajra-search</code>) that lets you point at any JSONL corpus and have a search engine in your terminal. There's an <a href="/blog/ai-explorations/2025-12-18-vajra-bm25-comparison.html">extensive blog post</a> documenting the benchmarks and architecture if you want the full technical deep-dive.</p>
<p><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/vajra_bm25">github.com/aiexplorations/vajra_bm25</a> | <strong>PyPI</strong>: <a href="https://pypi.org/project/vajra-bm25/">pypi.org/project/vajra-bm25</a></p>
<h3 id="tlon-tlon-mathematics">Tlön (Tlon) Mathematics</h3>
<p>Inspired by Borges' "Tlön, Uqbar, Orbis Tertius" - a story about a world where language has no nouns, only verbs - I built a mathematical framework where <em>processes</em> are primitive and <em>objects</em> emerge as stable patterns. The name comes from Sanskrit "Tlön" (though Borges invented the word), and the project began as an experiment: could we formalize the idea that a rock isn't a thing but a pattern of atomic processes that happens to be stable?</p>
<p>The framework is built on <strong>20 axioms across 6 groups</strong>, with the key definition being stability: $\text{Stable}(\pi) \Leftrightarrow \pi \circ \pi \approx \pi$. A stable process is one where doing it twice is equivalent to doing it once - these are the emergent "objects" of Tlön.</p>
<p>One of the most striking results is the <strong>Doctrine of No Inverses</strong>: for any process with positive duration, there exists no inverse that annihilates it to nothing. Happenings cannot un-happen. The correct concept is <em>reversal</em> (completing a stable cycle), not inverse (annihilation).</p>
<p>The simulations bring these ideas to life:<br />
- <strong>Double pendulum</strong>: Demonstrates <em>transient</em> processes - chaotic trajectories that never return to themselves<br />
- <strong>N-body problem</strong>: Shows that <em>stability is special, not generic</em> - N=2 is stable, but adding just one body (N=3) destroys stability<br />
- <strong>Lotka-Volterra predator-prey</strong>: Demonstrates <em>resonance</em> - two individually unstable processes (prey and predator) that together form a stable oscillating pattern</p>
<p>Additionally, Tlon mathematics has been used to build machine learning algorithms. I've implemented several common machine learning models (traditional ML algorithms, as well as deep learning models such as Dense Networks, ConvNets and Transformers). These implementations have been built on top of Tlon abstractions, and while they use the existing linear algebra, optimization and other applications, they help frame model training in Tlon terms - stability, emergence and other vital concepts from Tlon mathematics are being discussed in the context of ML.</p>
<p>There's an <a href="/blog/ai-explorations/2025-12-25-tlon-mathematics.html">extensive blog post</a> documenting the axioms, theorems, code structure, and simulations if you want the full deep-dive. I'm seeking mathematician review to check the proofs and identify gaps.</p>
<p><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/tlon_math">github.com/aiexplorations/tlon_math</a></p>
<h3 id="the-incomplete-book">The (Incomplete) Book</h3>
<p>I began writing a book on ML/AI design patterns; 128 patterns across 14 chapters, roughly 450 pages. It covered foundation model integration, agentic AI, MLOps, safety, governance. Active delivery through February 2026. The discipline of writing for publication, of having to explain ideas clearly enough for strangers to understand, has been as valuable as the technical work itself.</p>
<p>Unfortunately, I wasn't able to do justice to the material of the book, given the schedules committed, and the book is not yet complete. I plan to revisit the book in the coming year or two. I wish to thank my wife Meera for her support and encouragement of me in "writer" mode. Big thanks to CM for being so encouraging and helping me out. I also want to thank BPB publications for giving me the opportunity - too bad it didn't work, and perhaps we'll cross paths again on this or other book projects!</p>
<p>As they say, never waste a good crisis - and I say this in the context of the book here, because the decision to terminate this project didn't come easy, since I had written three chapters and over a hundred pages in all, with code, examples and diagrams. The crisis here taught me a lot about the process of writing technical books. There are many positives for me. This book project:</p>
<ul>
<li>Tested my knowledge and understanding - I found myself doing a lot of research, and reading lots of papers, and buying books I knew I wanted but hadn't invested in.</li>
<li>Tested my ability to communicate and write lucidly - as someone with a wordy and explanatory style, the constraints and needs of book writing made me develop new writing styles and skills</li>
<li>Put me in front of a publisher! This was an interesting experience for me, as a first time author.</li>
<li>Helped me build a discipline for consuming and writing that I knew I had the potential for</li>
<li>Helped me understand what a good workflow for writing a technical book is. This ranged from research workflow, making jots, code, experimentation, reading research papers, validation from experts, and putting together a manuscript. This is a vital skill!</li>
</ul>
<p>I have other ideas for writing books, for sure. In some sense, I have been authoring booklets and technical reports (although these are not quite the same thing as a book) for the different projects I'm working on. </p>
<h3 id="deep-lyapunov-deep-learning-model-training-as-a-dynamical-systems-process">Deep Lyapunov - Deep Learning Model Training as a Dynamical Systems Process</h3>
<p>Recently, I have begun digging a lot into mechanistic interpretability. This is the sub-field of AI research that seeks to explain how models work. I had done some work on this, when I built a system to understand the chaotic dynamics of agent to agent conversations, earlier this year. While I have understood Lyapunov exponents in the context of dynamical systems and used these methods in dynamical systems analysis in the past, I had not applied this to the conversation that could happen between humans and AI, or one AI agent with another.</p>
<p>After many experiments, I found myself writing a pipeline to study how neural network weights evolve during training, using perturbation analysis and Lyapunov exponents to understand trajectory stability. The question driving this work: do small changes in initialization lead to similar or different final solutions? Understanding this helps with reproducibility, ensemble diversity, and architecture selection.</p>
<p>You'll below the experimental repo in which deep learning dynamics have been analyzed, and also Deep Lyapunov, a library I built and released to PyPI yesterday.</p>
<p><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/deep_learning_dynamics">github.com/aiexplorations/deep_learning_dynamics</a>, <a href="https://github.com/aiexplorations/deep-lyapunov">github.com/aiexplorations/deep-lyapunov</a> | <strong>Blog Post</strong>: <a href="/blog/ai-explorations/2025-12-26-deep-learning-dynamics.html">Deep Lyapunov - Deep Learning Dynamics</a></p>
<p><strong>PyPI</strong>: <a href="https://pypi.org/project/deep-lyapunov/">pypi.org/project/deep-lyapunov</a></p>
<p><strong>Related Project</strong>: <a href="https://github.com/aiexplorations/agentic_nld">github.com/aiexplorations/agentic_nld</a></p>
<hr />
<h3 id="todacomm-topological-signatures-of-transformer-representations">ToDACoMM: Topological Signatures of Transformer Representations</h3>
<p>If Deep Learning Dynamics asks "how do weights evolve during training?", ToDACoMM (Topological Data Analysis Comparison of Multiple Models) asks the complementary question: "what is the shape of the representation space that training carves out?"</p>
<p>The project uses persistent homology to characterize transformer activations. The pipeline extracts hidden states from each layer, projects to 50 principal components, and computes Vietoris-Rips persistent homology via Ripser. The key metrics are H0 (connected components, roughly "how spread out are the clusters?") and H1 (loops, roughly "are there circular patterns in the geometry?").</p>
<p>The central finding is categorical: <strong>encoder and decoder architectures occupy fundamentally different topological regimes</strong>. BERT (bidirectional attention) shows an expansion ratio of 2x from embedding to final layer. GPT-2 (causal attention) shows 95x. Other decoders range from 55x (DistilGPT-2) to 694x (SmolLM2-360M). This isn't gradual variation - it's a stark divide.</p>
<p>The explanation follows from how attention works. BERT's bidirectional attention gives each token access to full context from layer one - representations don't need to expand because all information is already accessible. GPT-2's causal attention means each layer must encode more context than the last as the model accumulates the prefix. The 2x vs 55-694x expansion is the topological signature of this architectural difference.</p>
<p>Every model showed non-trivial H1 at 500 samples - there are loops in representation geometry. Whether these reflect syntactic patterns, semantic cycles, or learned positional structure remains an open question. SmolLM2-360M showed H1 total persistence of 129.52, more than 3x higher than any other model - an anomaly worth investigating.</p>
<p>These two projects - Deep Learning Dynamics and ToDACoMM - are complementary lenses on the same phenomenon. One measures how the optimization carves the weight space; the other measures what gets carved in activation space. Together they suggest a research direction: do divergent weight trajectories (high Lyapunov exponents) correlate with distinct topological signatures in activations?</p>
<p>While there is no PyPI package yet for ToDACoMM, I intend to put one together in the coming year.</p>
<p><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/todacomm">github.com/aiexplorations/todacomm</a> | <strong>Blog Post</strong>: <a href="/blog/ai-explorations/2025-12-26-todacomm.html">The Shape of Learning</a></p>
<h2 id="what-didnt-work">What Didn't Work</h2>
<p>The projects that failed taught me more than the ones that succeeded. They also provided me a lot of scaffolding on top of which to build further ideas and with more confidence</p>
<p>The TDA-DNN project, where I explore H0 and H1 homologies in the weights of deep neural networks was a really interesting and engrossing project. This allowed me to think about how the weights of a network are likely to be distributed and whether the present of some topological primitives in would help build a case for mechanistic interpretability of the same. The persistence of homologies led to a hypothesis that I explored unfruitfully. The project did provide interesting opportunities for improvement, and a scaffolding to build ToDACoMM on top of, and so this was ultimately helpful and instructive.</p>
<p>Building with Praval was another exciting learning exercise. Elsewhere on this blog, I have described and characterized some of the issues I encountered there, but in a nutshell, building your own framework and an application around it teaches you a lot about how you set up the foundations and how to ensure you don't mess those up. Adding over a thousand tests, fixing async race conditions and many framework level bugs were important milestones in my own growth technically. They were great instructors in how to effectively manage technical debt. Praval Code, my failed experiment of using the Praval Multi-Agent framework may be resurrected in future with more confidence thanks to the lessons learned from my earlier experiences this year.</p>
<hr />
<h2 id="what-i-learned">What I Learned</h2>
<h3 id="on-research-direction">On Research Direction</h3>
<p>Rich Sutton's Bitter Lesson has been on my mind all year: general methods that leverage computation tend to beat clever, hand-designed approaches. This doesn't mean cleverness is worthless, but it suggests where to place bets. I've been trying to internalize this, to resist the temptation of elegant solutions that don't scale. Especially with ToDACoMM and Tlon mathematics, the bitter lesson was an evident and direct dictum worth remembering.</p>
<p>I've also learned that dynamical systems knowledge remains underutilized in practical AI. Koopman operators, Lyapunov analysis, phase space methods; these tools have been developed over decades in physics and applied mathematics, but their application to neural networks and AI systems is still sparse. There may be opportunities there.</p>
<p>I think there is a case to be made for a mathematics first approach to building deep learning models, in which text based next token prediction works alongside numerical quantity prediction. As Petar Velichkovich says in a recent interview, we literally have models that perform hundreds or thousands of multiplications internally but cannot multiply numbers put into the input as part of the context window. This paradox has to be solved if deep learning in its current form has to evolve to become more useful. We seem to have cracked at least some of the code of how code generation agents can be built, but we are yet to crack the core of how mathematics can be done with deep learning models natively.</p>
<h3 id="on-containers">On Containers</h3>
<p>The environment you work in matters as much as the work itself. I've started calling this "choosing the right container." A misaligned container, wrong role, wrong company, wrong domain, dampens even the best ideas. Looking back at my career, the periods of highest productivity coincided with containers that gave me latitude to explore. And the prestige of the position automatically came - in an interesting way, this seems to mirror a belief in Hindu spirituality, where Goddess Lakshmi, the Goddess of Wealth, arrives once Goddess Saraswati, the Goddess of Learning, has already planted herself. I think that working with curiosity is a superpower, and working diligently and with attention to detail without expectation of a reward is a reward. </p>
<h3 id="on-curiosity-and-accomplishment">On Curiosity and Accomplishment</h3>
<p>This year forced me to confront a pattern in my projects: I start with genuine intellectual curiosity, go deep, then lose interest when comparing to genuinely ground-breaking research that has perhaps taken entire teams and years of expertise to unlock. Such a comparison itself is flawed, for clear and evident reasons. When it comes to these personal projects, I'm just a guy with a powerful AI coding assistant, and some spare time in which to explore big ideas, which makes this all very worth doing, when you frame it that way!</p>
<p>Approaching ideas with a sense of genuine curiosity, and taking the time to read up about things, structure my thoughts, and be factual and consistent before I build (even if we have the tools for getting past some of) will come back to be important once again. Outsourcing one's thinking, as many techies have become comfortable doing in 2025, is something I have begun to grow out of for the important projects. Sometimes this learning and exploration are indistinguishable - with proofs of Tlon primitives I attempted a month ago, for example, the learning is in the doing.</p>
<p>Another thing about curiosity. There is now a <em>responsibility</em> on the part of those of us using powerful AI tools, to build impactful, new, differentiated things. This is a responsibility because if we use tools that are super powerful in specific ways to do basic things or things not meant for the tool, we're likely to be eventually disappointed with the results.</p>
<h3 id="tiny-projects-are-powerful">Tiny Projects are Powerful</h3>
<p>Claude Code has enabled rapid prototyping of ideas for millions of developers worldwide, and I'm just one more individual that's benefited from this glut of reasoning and agentic coding power.</p>
<p>During the last year, I experimented with many small projects that don't get a mention here. The pattern I saw here with such rapid prototyping is roughly as follows:</p>
<ul>
<li>Inspiration: I get inspired by a conversation, a podcast, book or another project</li>
<li>Exploration: Use Claude to quickly spin up a working project, having explored some fundamental ideas related to the subject in question.</li>
<li>Flare and Focus: Discard the ones that don't show promise, and expand on those which do.</li>
<li>Publish and post: Generally, the flaring portion of promising projects leads to some kind of published work. It could be a library or a package, or a post or a paper.</li>
</ul>
<p>Overall, tiny projects that are centred on only inspiration and exploration are supremely powerful. They help understand the deeper motivations that you have in a project, more than anything.</p>
<hr />
<h2 id="looking-forward">Looking Forward</h2>
<p>The technical threads running through 20+ years of work remain the following sometimes intersecting things - engineering, mathematical structure, statistical and computational problem solving, dynamics, optimization, complex systems, and perhaps more. What changes often is the frontier where that intersection is explored. </p>
<p>For 2026, my priorities on the personal projects front include continuing development on ToDACoMM (adding training dynamics tracking and larger model analysis), preparing Tlön mathematics for arXiv, and deploying Praval Deep Research as my default research platform. Strategically, I'm exploring what it would mean to set up an independent research structure, finding collaborators who share the interest in mathematical foundations.</p>
<p>At work, there are numerous priorities that span technical, organizational and stratetic elements that I look forward to - the team and the next year of innovation there beckons.</p>
<p>And another piece of important work is perhaps internal. Can I maintain the posture of genuine curiosity, of learning for its own sake, while still exploring ideas and producing projects that matter? Can I let some explorations lead nowhere without judging them failures?</p>
<p>The projects that worked best this year weren't the ones that were only chasing impact. They were the ones that let curiosity lead, with honesty about what the evidence showed, even when it showed that I was wrong. This applied to Praval, Vajra Search, ToDACoMM, Deep Lyapunov especially.</p>
<p>Sometimes the learning is the point, and the results will follow.</p>
<hr />
<p><em>If you want to explore any of these projects, find them at <a href="https://github.com/aiexplorations">github.com/aiexplorations</a>. I'd welcome your thoughts; reach out via the contact page or find me on LinkedIn.</em></p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>2025-in-review</category>
      <category>artificial intelligence</category>
      <category>ai-explorations</category>
      <category>praval</category>
      <category>vajra bm25</category>
      <category>tlon mathematics</category>
      <category>todacomm</category>
      <category>category-theory</category>
      <category>AI research</category>
      <category>search</category>
      <category>deep learning</category>
      <category>mechanistic interpretability</category>
      <category>tda</category>
    </item>
    <item>
      <title>Deep-Lyapunov: Deep Learning Dynamical Systems Analysis</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-12-26-deep-learning-dynamics.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-12-26-deep-learning-dynamics.html</guid>
      <pubDate>Fri, 26 Dec 2025 00:00:00 GMT</pubDate>
      <description>Applying perturbation analysis and Lyapunov exponents to neural network training. Dense networks converge; transformers diverge. The architecture determines the stability.</description>
      <content:encoded><![CDATA[<h2 id="random-seeds-and-training-trajectories">Random Seeds and Training Trajectories</h2>
<p>Every neural network training run begins with a random seed. A different seed means different initial weights, which means a different trajectory through the loss landscape, which may or may not arrive at the same destination.</p>
<p>I've been thinking about this for a while, in the context of understanding the relationship between dynamical systems and the internal workings of neural networks. The question: do small changes in initialization lead to similar or different final solutions? In other words, do deep learning models that are essentially learning from feedback loops and errors, subject to sensitive dependence upon the initial conditions of initialization of weights and biases?</p>
<h2 id="methodology">Methodology</h2>
<p>The methodology I developed to answer this question treats training as a dynamical system and asks: how sensitive is this system to its initial conditions? The approach borrows from Lyapunov's work on dynamical stability, a framework developed in the late 19th century to understand whether planetary orbits would remain stable or fly apart. This same analysis has been replicated and used numerous times in the late 20th century as numerous dynamical and chaotic systems came to be studied. I am certain that this stability analysis has also been performed on deep neural networks especially since there are so many labs interested in performing training with as low a resource footprint as possible, which in turn bring stability of training into the foreground.</p>
<p>The procedure I used here is straightforward. Initialize a model with a fixed seed. Create several copies with small Gaussian perturbations, typically 1% of each parameter's standard deviation, enough to nudge the weights without fundamentally changing them. Train each perturbed model independently on the same data. Record the weight vectors after each epoch. Project the high-dimensional trajectories to principal components. Measure how the trajectories spread or converge.</p>
<div class="codehilite"><pre><span></span><code><span class="nd">@dataclass</span>
<span class="k">class</span><span class="w"> </span><span class="nc">LayerSnapshot</span><span class="p">:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Snapshot of a single layer&#39;s state at a point in time.&quot;&quot;&quot;</span>

    <span class="c1"># Weight statistics</span>
    <span class="n">weight_mean</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">weight_std</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">weight_min</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">weight_max</span><span class="p">:</span> <span class="nb">float</span>
    <span class="n">weight_norm</span><span class="p">:</span> <span class="nb">float</span>

    <span class="c1"># Gradient statistics (if available)</span>
    <span class="n">grad_mean</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">float</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
    <span class="n">grad_std</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">float</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
    <span class="n">grad_norm</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">float</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>

    <span class="c1"># Eigenvalue statistics for weight matrix</span>
    <span class="n">weight_singular_values</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="n">np</span><span class="o">.</span><span class="n">ndarray</span><span class="p">]</span> <span class="o">=</span> <span class="kc">None</span>
</code></pre></div>

<p>What emerges is a picture of the optimization landscape, not as a static surface but as a flow field where trajectories either merge toward attractors or diverge toward distinct basins.</p>
<h2 id="stability-metrics">Stability Metrics</h2>
<p>Four quantities capture most of what matters about training dynamics:</p>
<p><strong>Convergence Ratio</strong> measures whether trajectories come together or spread apart. It's the ratio of final spread to initial spread in the projected weight space. Below 1.0, the models ended up closer than they started; they converged. Above 1.0, they diverged.</p>
<p><strong>Lyapunov Exponent (λ)</strong> quantifies the exponential rate of this convergence or divergence:</p>
<p>
<script type="math/tex; mode=display">\lambda = \frac{1}{T} \log\left(\frac{\text{final spread}}{\text{initial spread}}\right)</script>
</p>
<p>Positive λ indicates chaotic dynamics where small perturbations grow exponentially, the signature of a system sensitive to initial conditions. Negative λ indicates stable dynamics where perturbations decay. The exponent tells you not just whether trajectories diverge, but how fast.</p>
<p><strong>Early/Late Ratio</strong> reveals when divergence happens. A ratio above 1.0 suggests early divergence, where models quickly find different paths. Below 1.0 suggests late divergence, where they track together initially but separate as training progresses. This distinction matters for understanding whether instability is fundamental or emergent.</p>
<p><strong>PCA Variance Explained</strong> indicates how structured the dynamics are. Higher values suggest trajectories follow a low-dimensional manifold; lower values suggest high-dimensional, less predictable evolution.</p>
<h2 id="results">Results</h2>
<p>I analyzed 21 neural network architectures across three families: 10 dense networks trained on the circles dataset, 5 CNNs on MNIST, and 6 transformers on SST-2 sentiment classification. Each architecture was trained with multiple perturbed initializations, and the trajectories were compared.</p>
<p><img alt="Master Summary" src="/blog/ai-explorations/posts/2025-12-26-deep-learning-dynamics/images/master_summary.png" /><br />
<em>The master summary reveals stark differences: Dense networks cluster around convergence (ratio ≈ 1), while CNNs and transformers spread toward divergence.</em></p>
<p>The results were categorical:</p>
<table>
<thead>
<tr>
<th>Family</th>
<th>Architectures</th>
<th>Convergent</th>
<th>Avg Ratio</th>
<th>Lyapunov λ</th>
</tr>
</thead>
<tbody>
<tr>
<td>Dense</td>
<td>10</td>
<td>6 (60%)</td>
<td>1.09×</td>
<td>-0.004</td>
</tr>
<tr>
<td>CNN</td>
<td>5</td>
<td>1 (20%)</td>
<td>2.01×</td>
<td>+0.100</td>
</tr>
<tr>
<td>Transformer</td>
<td>6</td>
<td>0 (0%)</td>
<td>1.42×</td>
<td>+0.170</td>
</tr>
</tbody>
</table>
<p>Dense networks are the most stable. Six of ten configurations converged, with an average spread ratio barely above unity. If you train a dense network twice with slightly different initializations, you'll often get very similar final weights. The Lyapunov exponent is negative; perturbations are dampened.</p>
<p>CNNs are substantially less stable. Only one of five configurations converged. The average spread ratio of 2.01× means perturbed models typically end up twice as far apart as they started. The convolutional structure, with its weight sharing and spatial hierarchies, admits multiple distinct solutions.</p>
<p>Transformers never converged. Zero out of six configurations. The attention mechanism creates a landscape where small initial differences cascade into large final differences. The Lyapunov exponent is the highest of all families.</p>
<h2 id="dense-networks">Dense Networks</h2>
<p>Within the dense family, larger networks tended toward convergence. The 3-layer XLarge configuration with 2,305 parameters converged with a ratio of 0.44×, the most stable architecture in the entire study. The 5-layer Tiny configuration with 51 parameters diverged at 2.71×, the least stable.</p>
<p>Depth had a nuanced effect. Shallower networks (3-layer) showed slightly more stable training than deeper ones (5-layer), with average convergence ratios of 1.01× versus 1.17×. This might seem counterintuitive; one might expect deeper networks to have more regularization through gradient flow constraints. But depth also means longer paths for perturbations to propagate.</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">DynamicalSystemsAnalyzer</span><span class="p">:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Analyzes neural network training through the lens of dynamical systems.</span>

<span class="sd">    Treats the weight space as a phase space and training as a trajectory</span>
<span class="sd">    through this space, applying tools from nonlinear dynamics.</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span>
        <span class="bp">self</span><span class="p">,</span>
        <span class="n">tracker</span><span class="p">:</span> <span class="n">DynamicsTracker</span><span class="p">,</span>
        <span class="n">model</span><span class="p">:</span> <span class="n">nn</span><span class="o">.</span><span class="n">Module</span><span class="p">,</span>
        <span class="n">n_pca_components</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">10</span><span class="p">,</span>
    <span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">tracker</span> <span class="o">=</span> <span class="n">tracker</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">model</span> <span class="o">=</span> <span class="n">model</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">n_components</span> <span class="o">=</span> <span class="n">n_pca_components</span>
</code></pre></div>

<p>The practical interpretation: for reproducible results on simple classification tasks, dense networks offer consistent behavior across seeds. For ensemble learning where you want diverse models, you might intentionally choose less stable configurations.</p>
<h2 id="transformers">Transformers</h2>
<p>Transformers presented a different picture entirely. No transformer configuration converged. BERT variants (bidirectional attention) showed average divergence of 1.50×. GPT variants (causal attention) showed 1.34×, slightly better but still divergent.</p>
<p><img alt="Family Comparison" src="/blog/ai-explorations/posts/2025-12-26-deep-learning-dynamics/images/family_comparison.png" /><br />
<em>Box plots reveal that transformers, despite universal divergence, show tighter distributions than CNNs. The chaos is consistent.</em></p>
<p>The difference between BERT and GPT is interesting. One might expect bidirectional attention to be more stable, since each position has access to full context during training. But the data suggests otherwise. Causal masking in GPT apparently constrains the optimization landscape in ways that marginally improve stability, perhaps by reducing the degrees of freedom in gradient flow.</p>
<p>Larger transformers showed better convergence properties. GPT-medium with 12 million parameters achieved a ratio of 1.07×, while GPT-tiny with 780,000 parameters reached 1.38×. Overparameterization may provide smoother loss surfaces.</p>
<h2 id="trajectory-spread-over-training">Trajectory Spread Over Training</h2>
<p>Trajectory spread evolves differently across architectures:</p>
<p><img alt="Spread Comparison" src="/blog/ai-explorations/posts/2025-12-26-deep-learning-dynamics/images/spread_comparison.png" /><br />
<em>Spread evolution across families. Dense networks show falling curves (convergence); CNNs and transformers show rising curves (divergence).</em></p>
<p>Falling curves indicate convergent training, where perturbations are "forgotten" as training progresses. Rising curves indicate divergent training, where small initial differences amplify over time. The steepness of early changes suggests the initial learning phase is most sensitive.</p>
<p>Dense networks show predominantly falling curves. The optimization is pulling trajectories together. CNNs and transformers show predominantly rising curves. The optimization is pushing trajectories apart.</p>
<p>This has implications for checkpointing. If you're training a transformer and want to reduce initialization sensitivity, averaging checkpoints across different runs might help. The trajectories diverge, but early checkpoints before the divergence has compounded could be closer.</p>
<h2 id="a-dynamical-systems-perspective">A Dynamical Systems Perspective</h2>
<p><img alt="Dynamics Overview" src="/blog/ai-explorations/posts/2025-12-26-deep-learning-dynamics/images/dynamics_overview.png" /><br />
<em>Cross-family dynamics comparison showing the full picture of convergence ratios, Lyapunov exponents, and accuracy distributions.</em></p>
<p>Applying dynamical systems theory to neural networks connects to older questions. Poincaré developed these tools to understand celestial mechanics, to predict whether the solar system would remain stable or eventually fly apart. The question was whether small perturbations, a slight change in Jupiter's position, would grow or decay over time.</p>
<p>Neural network training is a different system, but the same question applies. Small perturbations with a slight change in initial weights, grow or decay over epochs. The Lyapunov exponent here measures the sensitivity, just as it does for planetary orbits.</p>
<p>There is a distinc difference in the dynamics of neural networks and planetary orbits, however, as planetary orbits have continuous dynamics and training neural networks has discrete dynamics. Further, planetary systems are conservative; training has friction (weight decay) and injection (data). But the core insight transfers from one space to another, that some systems are stable, some are chaotic, and the architecture determines which ones pan out which way.</p>
<h2 id="practical-implications">Practical Implications</h2>
<p><strong>For reproducibility</strong>: If you need consistent results across training runs, prefer dense architectures or accept that transformer training is inherently variable. Two transformer runs with different seeds will find different solutions. This isn't a failure of the training procedure; it's a property of the architecture.</p>
<p><strong>For ensembles</strong>: The divergent architectures are your friends for ensemble diversity. Transformers naturally explore different solutions without needing special tricks. Dense networks may require more aggressive perturbation to achieve ensemble diversity.</p>
<p><strong>For debugging</strong>: If a transformer training run fails, try a different seed before concluding the architecture is broken. The same configuration that fails with one initialization may succeed with another.</p>
<p><strong>For architecture selection</strong>: Stability is a design consideration alongside accuracy and efficiency. For safety-critical applications where reproducibility matters, the extra stability of dense networks may outweigh their lower expressiveness.</p>
<h2 id="what-this-doesnt-tell-us">What This Doesn't Tell Us</h2>
<p>This analysis uses small-scale training, typically 10-50 epochs on reduced datasets. Production-scale training with billions of tokens might show different dynamics. The perturbation magnitude of 1% is somewhat arbitrary; different magnitudes might reveal different structure.</p>
<p>The metrics capture global properties of the trajectory ensemble but miss local structure. Two configurations with similar convergence ratios might have very different trajectory geometries.</p>
<p>And crucially: convergence or divergence says nothing about generalization and actual model performance. It is fair to say that the lyapunov exponents are not a good metric for generalization or model performance. A model that converges to the same weights every time might still overfit. A model that finds different solutions might find ones that generalize better. The dynamics describe the optimization, not the learned representation.</p>
<h2 id="connection-to-todacomm">Connection to ToDACoMM</h2>
<p>There's a natural connection to <a href="/blog/ai-explorations/posts/2025-12-26-todacomm.html">ToDACoMM</a>, the project I built to measure the topology of trained representations using persistent homology. Deep Learning Dynamics measures how weights evolve during training; ToDACoMM measures what gets carved in activation space after training.</p>
<p>The preliminary observation is suggestive: transformers show both the most chaotic training dynamics (highest Lyapunov exponents, 0% convergence) and the most dramatic topological expansion (55-694× expansion ratios in activation space). Dense networks show both stable training and modest topological transformation.</p>
<p>Is this correlation causal? Do architectures with divergent weight trajectories necessarily produce topologically distinct representations? This remains an open question, one I'm continuing to investigate.</p>
<h2 id="the-library-deep-lyapunov">The Library: deep-lyapunov</h2>
<p>The methodology described in this post has been packaged as <strong>deep-lyapunov</strong>, a Python library for analyzing neural network training stability. It's available on PyPI:</p>
<div class="codehilite"><pre><span></span><code>pip<span class="w"> </span>install<span class="w"> </span>deep-lyapunov
</code></pre></div>

<p>The library provides a clean API for stability analysis on any PyTorch model:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">torch.nn</span><span class="w"> </span><span class="k">as</span><span class="w"> </span><span class="nn">nn</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">deep_lyapunov</span><span class="w"> </span><span class="kn">import</span> <span class="n">StabilityAnalyzer</span>

<span class="c1"># Your model</span>
<span class="n">model</span> <span class="o">=</span> <span class="n">nn</span><span class="o">.</span><span class="n">Sequential</span><span class="p">(</span>
    <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="mi">784</span><span class="p">,</span> <span class="mi">128</span><span class="p">),</span>
    <span class="n">nn</span><span class="o">.</span><span class="n">ReLU</span><span class="p">(),</span>
    <span class="n">nn</span><span class="o">.</span><span class="n">Linear</span><span class="p">(</span><span class="mi">128</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
<span class="p">)</span>

<span class="c1"># Analyze stability</span>
<span class="n">analyzer</span> <span class="o">=</span> <span class="n">StabilityAnalyzer</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="n">model</span><span class="p">,</span>
    <span class="n">perturbation_scale</span><span class="o">=</span><span class="mf">0.01</span><span class="p">,</span>  <span class="c1"># 1% perturbation</span>
    <span class="n">n_trajectories</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>          <span class="c1"># Compare 5 perturbed copies</span>
<span class="p">)</span>

<span class="n">results</span> <span class="o">=</span> <span class="n">analyzer</span><span class="o">.</span><span class="n">analyze</span><span class="p">(</span>
    <span class="n">train_fn</span><span class="o">=</span><span class="n">your_training_function</span><span class="p">,</span>
    <span class="n">train_loader</span><span class="o">=</span><span class="n">train_loader</span><span class="p">,</span>
    <span class="n">n_epochs</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
<span class="p">)</span>

<span class="c1"># Results</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Convergence Ratio: </span><span class="si">{</span><span class="n">results</span><span class="o">.</span><span class="n">convergence_ratio</span><span class="si">:</span><span class="s2">.2f</span><span class="si">}</span><span class="s2">x&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Lyapunov Exponent: </span><span class="si">{</span><span class="n">results</span><span class="o">.</span><span class="n">lyapunov</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Behavior: </span><span class="si">{</span><span class="n">results</span><span class="o">.</span><span class="n">behavior</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># &#39;convergent&#39; or &#39;divergent&#39;</span>

<span class="c1"># Generate HTML report with embedded visualizations</span>
<span class="n">results</span><span class="o">.</span><span class="n">save_report</span><span class="p">(</span><span class="s2">&quot;stability_analysis/&quot;</span><span class="p">)</span>
</code></pre></div>

<p>The library handles all the complexity: creating perturbed model copies, tracking weight trajectories during training, projecting to PCA space, computing Lyapunov exponents, and generating publication-ready reports.</p>
<p>For custom training loops, there's a manual recording mode:</p>
<div class="codehilite"><pre><span></span><code><span class="n">analyzer</span> <span class="o">=</span> <span class="n">StabilityAnalyzer</span><span class="p">(</span><span class="n">model</span><span class="p">)</span>
<span class="n">analyzer</span><span class="o">.</span><span class="n">start_recording</span><span class="p">()</span>

<span class="k">for</span> <span class="n">epoch</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="mi">10</span><span class="p">):</span>
    <span class="n">train_one_epoch</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">train_loader</span><span class="p">)</span>
    <span class="n">analyzer</span><span class="o">.</span><span class="n">record_checkpoint</span><span class="p">()</span>

<span class="n">results</span> <span class="o">=</span> <span class="n">analyzer</span><span class="o">.</span><span class="n">compute_metrics</span><span class="p">()</span>
</code></pre></div>

<p><strong>Logging</strong> is built in for visibility into what's happening:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">logging</span>
<span class="n">logging</span><span class="o">.</span><span class="n">basicConfig</span><span class="p">(</span><span class="n">level</span><span class="o">=</span><span class="n">logging</span><span class="o">.</span><span class="n">INFO</span><span class="p">)</span>
<span class="c1"># Shows: Starting stability analysis, Training trajectory 1/5, Analysis complete...</span>
</code></pre></div>

<p>The library is available at <a href="https://github.com/aiexplorations/deep-lyapunov">github.com/aiexplorations/deep-lyapunov</a> and <a href="https://pypi.org/project/deep-lyapunov/">pypi.org/project/deep-lyapunov</a>.</p>
<h2 id="the-research-code">The Research Code</h2>
<p>The full experimental pipeline used for the analysis in this post is also open source:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Clone and install</span>
git<span class="w"> </span>clone<span class="w"> </span>https://github.com/aiexplorations/deep_learning_dynamics
<span class="nb">cd</span><span class="w"> </span>deep_learning_dynamics
pip<span class="w"> </span>install<span class="w"> </span>-e<span class="w"> </span>.<span class="o">[</span>dev<span class="o">]</span>

<span class="c1"># Run full analysis</span>
python<span class="w"> </span>-m<span class="w"> </span>experiments.unified_pipeline<span class="w"> </span>--output<span class="w"> </span>outputs/analysis

<span class="c1"># Quick test</span>
python<span class="w"> </span>-m<span class="w"> </span>experiments.unified_pipeline<span class="w"> </span>--quick

<span class="c1"># Specific families only</span>
python<span class="w"> </span>-m<span class="w"> </span>experiments.unified_pipeline<span class="w"> </span>--transformer-only
</code></pre></div>

<p>The output includes comprehensive reports, visualizations, and machine-readable metrics for all architectures analyzed.</p>
<h2 id="looking-forward">Looking Forward</h2>
<p>Several directions seem worth pursuing:</p>
<p><strong>Scale</strong>: How do dynamics change with model size? Do larger transformers become more or less chaotic? The preliminary evidence suggests larger models are more stable, but this needs verification at scale.</p>
<p><strong>Training recipes</strong>: Do learning rate schedules, warmup, or optimizer choices affect convergence? Can we engineer stability into otherwise divergent architectures?</p>
<p><strong>Checkpointing</strong>: Can we identify stable checkpoints where perturbation sensitivity is lowest? This could inform checkpoint selection for production deployment.</p>
<p><strong>Cross-architecture</strong>: Train the same task with dense, CNN, and transformer architectures. Compare not just final accuracy but trajectory stability. Understand the tradeoff.</p>
<p>The dice we roll when we pick a random seed matter more for some architectures than others. Understanding when and why is part of understanding what neural networks actually do.</p>
<hr />
<p><em>The <strong>deep-lyapunov</strong> library is available on <a href="https://pypi.org/project/deep-lyapunov/">PyPI</a> (<code>pip install deep-lyapunov</code>) and <a href="https://github.com/aiexplorations/deep-lyapunov">GitHub</a>. The research code and experimental pipeline are available at <a href="https://github.com/aiexplorations/deep_learning_dynamics">deep_learning_dynamics</a>.</em></p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>deep-learning</category>
      <category>dynamical-systems</category>
      <category>training-dynamics</category>
      <category>lyapunov-exponents</category>
      <category>neural-networks</category>
      <category>reproducibility</category>
      <category>deep-lyapunov</category>
    </item>
    <item>
      <title>Vajra BM25: Building a Search Engine with Category Theory</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-12-24-vajra-bm25.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-12-24-vajra-bm25.html</guid>
      <pubDate>Wed, 24 Dec 2025 00:00:00 GMT</pubDate>
      <description>After months of development, Vajra BM25 achieves ~1.2-1.3x faster latency than BM25S while maintaining competitive accuracy. I share what I learned building it and benchmark results across BEIR and Wikipedia datasets.</description>
      <content:encoded><![CDATA[<p>I spent the last few months building <strong>Vajra BM25</strong>, a search engine that uses category theory as its organizing principle. The name "Vajra" comes from the Sanskrit word for "thunderbolt" or "diamond," and the project began as an experiment: <em>could mathematical abstractions from category theory make a search engine's code cleaner without sacrificing performance?</em></p>
<p>The interesting thing when I went down this category theory rabbit hole, was that the category theory framing produced cleaner code and neat abstractions, and introduced me to a cool way of thinking about search. The resulting search engine also became remarkably fast over time. Initially, I was captivated by the elegance of using the category theory framing, but as time progressed, I realized that this could be built into a competent search engine as well, and leveraged some of the best scientific Python packages, vectorization, SIMD, and other optimizations to deliver three minor releases of Vajra search. It has been a very satisfying journey! The project started off as an innocent way to think of category theory abstractions to represent graph search. As I was working with text based search at work (Elastic) a lot, I realized that this could also be attempted. And here we are!</p>
<p>The latest benchmarks show Vajra achieving <strong>~1.2-1.3x faster latency</strong> than BM25S (one of the fastest Python BM25 libraries), with sub-2ms query times even at 500K documents. This post documents what I learned along the way, including comprehensive benchmarks across multiple BM25 implementations and datasets. But first, let me share how search engines work, because understanding the fundamentals made all the difference in building something fast.</p>
<h2 id="the-anatomy-of-a-search-engine">The Anatomy of a Search Engine</h2>
<p>At its core, every text search engine solves the same problem: given a query, find the most relevant documents from a corpus of documents. The solution has three fundamental components that I came to appreciate deeply while building <em>Vajra</em>.</p>
<h3 id="1-the-index">1. The Index</h3>
<p>The index is the data structure that actually makes search fast. Without an index, you'd have to scan every document for every query, which becomes impossibly slow for large corpora, and in computational complexity terms, is $O(nm)$ where n is the number of documents and m is the number of queries.  Indexing solves this by reducing the complexity to $O(n+m)$ by using a data structure that allows for efficient retrieval of documents that contain a given term. There are two ways to think about indexing:</p>
<p><strong>Forward Index</strong> (document → terms):</p>
<div class="codehilite"><pre><span></span><code>doc_1 → [&quot;machine&quot;, &quot;learning&quot;, &quot;algorithms&quot;]
doc_2 → [&quot;deep&quot;, &quot;learning&quot;, &quot;neural&quot;, &quot;networks&quot;]
doc_3 → [&quot;machine&quot;, &quot;learning&quot;, &quot;neural&quot;, &quot;networks&quot;]
</code></pre></div>

<p><strong>Inverted Index</strong> (term → documents):</p>
<div class="codehilite"><pre><span></span><code><span class="ss">&quot;machine&quot;</span><span class="w">   </span><span class="err">→</span><span class="w"> </span><span class="o">[</span><span class="n">doc_1, doc_3</span><span class="o">]</span>
<span class="ss">&quot;learning&quot;</span><span class="w">  </span><span class="err">→</span><span class="w"> </span><span class="o">[</span><span class="n">doc_1, doc_2, doc_3</span><span class="o">]</span>
<span class="ss">&quot;neural&quot;</span><span class="w">    </span><span class="err">→</span><span class="w"> </span><span class="o">[</span><span class="n">doc_2, doc_3</span><span class="o">]</span>
<span class="ss">&quot;networks&quot;</span><span class="w">  </span><span class="err">→</span><span class="w"> </span><span class="o">[</span><span class="n">doc_2, doc_3</span><span class="o">]</span>
<span class="ss">&quot;algorithms&quot;</span><span class="err">→</span><span class="w"> </span><span class="o">[</span><span class="n">doc_1</span><span class="o">]</span>
<span class="ss">&quot;deep&quot;</span><span class="w">      </span><span class="err">→</span><span class="w"> </span><span class="o">[</span><span class="n">doc_2</span><span class="o">]</span>
</code></pre></div>

<p>The inverted index is the key tool of efficiency in most search engines. Instead of asking "what terms are in this document?", we ask "which documents contain this term?". For a query like "neural networks", we instantly get the candidate set <code>{doc_2, doc_3}</code> without scanning all documents. This inversion of perspective, I later realized, has a natural categorical interpretation.</p>
<h3 id="2-the-scorer">2. The Scorer</h3>
<p>Now that we have candidate documents, we need to rank them by relevance. <strong>BM25</strong> (Best Match 25) is the dominant scoring function, developed in the 1990s and still used by Elasticsearch, Solr, and most modern search engines.</p>
<p>BM25 scores each document based on three factors: how often the query term appears in the document (term frequency), how rare the term is across all documents (inverse document frequency), and a normalization factor for document length. The formula balances these with tunable parameters <code>k1</code> and <code>b</code>.</p>
<p>What struck me while implementing BM25 in <em>Vajra</em> is that scoring is fundamentally a <em>morphism</em>, a mathematical arrow from the product of query and document to a real number. This isn't just notation; it clarifies what the scorer does and how it composes with other operations. In category theory, a morphism is a function that preserves the structure of the category. In this case, the structure is the product of query and document, and the real number is the score.</p>
<p>This allows us to organize the code of our search engine in terms of the category theory abstractions we see here. And that gives us the potential to reuse these abstractions to build a different kind of mutable <em>search pipeline</em>. The scoring, then, is one of the steps of this pipeline.</p>
<h3 id="3-the-ranker">3. The Ranker</h3>
<p>With scores computed, the final step is returning the top-k results. This seems trivial, but at scale it matters: sorting a million scores is $O(n log n)$, while finding just the top 10 is O(n) with a partial sort. Small optimizations here compound quickly.</p>
<h2 id="the-search-engine-landscape">The Search Engine Landscape</h2>
<p>In the process of building Vajra Search, as especially as I gravitated from graph search like BFS and DFS, which are common algorithms, I had the opportunity to explore some giants of the search engine landscape. </p>
<h3 id="lucene-the-industry-foundation">Lucene: The Industry Foundation</h3>
<p><strong>Apache Lucene</strong> (2000) is arguably the foundation of modern enterprise search. Written in Java, it powers Elasticsearch, Solr, and countless enterprise systems. Lucene's architecture reflects 25 years of production optimization:</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 840px"><img src="/blog/ai-explorations/posts/2025-12-24-vajra-bm25/images/mermaid/mermaid-01-1fba36891433.svg" alt="Mermaid diagram 1 for Vajra BM25: Building a Search Engine with Category Theory" width="840" height="412" decoding="async"></div>

<p>Lucene is designed for <strong>production durability</strong>: indexes persist to disk, support concurrent updates, and are able to scale horizontally via sharding. The trade-off is <em>complexity</em> and JVM overhead. <strong>Pyserini</strong> wraps Lucene via the Anserini toolkit, providing a Python API that can help us build with these capabilities in native Python applications (but with JVM dependencies nevertheless).</p>
<h3 id="tantivy-lucene-in-rust">Tantivy: Lucene in Rust</h3>
<p><strong>Tantivy</strong> (2016) reimplements Lucene's architecture in Rust, gaining memory safety and roughly 2x performance over Java. It maintains the segment-based, disk-first design:</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 870px"><img src="/blog/ai-explorations/posts/2025-12-24-vajra-bm25/images/mermaid/mermaid-02-064eab82d2a5.svg" alt="Mermaid diagram 2 for Vajra BM25: Building a Search Engine with Category Theory" width="870" height="269" decoding="async"></div>

<p>Tantivy excels for applications needing persistent, updatable indexes with Rust's performance. However, the disk-based design adds I/O latency for pure query workloads. At some point, disk I/O is something Vajra is going to deal with. For the time being, I have preferred building indexes in memory. There is some caching possible within Vajra and in this sense, it is inspired by what Tantivy is able to do. Tantivy is super fast and performant given that it does this with disk I/O.</p>
<h3 id="rank-bm25-the-python-baseline">Rank-bm25: The Python Baseline</h3>
<p><strong>Rank-bm25</strong> (2018) is the simplest BM25 implementation, a single Python file that's easy to understand:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">BM25Okapi</span><span class="p">:</span>
    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">corpus</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">corpus_size</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">corpus</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">doc_freqs</span> <span class="o">=</span> <span class="p">[]</span>  <span class="c1"># Forward index: doc → term frequencies</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">idf</span> <span class="o">=</span> <span class="p">{}</span>        <span class="c1"># Precomputed IDF values</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">get_scores</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">query</span><span class="p">):</span>
        <span class="n">scores</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">zeros</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">corpus_size</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">term</span> <span class="ow">in</span> <span class="n">query</span><span class="p">:</span>
            <span class="c1"># Score ALL documents for this term</span>
            <span class="k">for</span> <span class="n">doc_idx</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">corpus_size</span><span class="p">):</span>
                <span class="n">scores</span><span class="p">[</span><span class="n">doc_idx</span><span class="p">]</span> <span class="o">+=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_score_term</span><span class="p">(</span><span class="n">term</span><span class="p">,</span> <span class="n">doc_idx</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">scores</span>
</code></pre></div>

<p>Rank-BM25 was my starting point to understanding the typical Python BM25 implementation meant for small tasks, and which is also not meant to be memory efficient, or fast. Indexing even smallish corpuses takes minutes, and it works in-memory. This is a tool for a limited job. The problem is clear: rank-bm25 uses a <strong>forward index</strong> and scores every document for every query. With a million documents and five query terms, that's five million score computations per query. While it not a great use of memory or compute, Rank-BM25 does get the job done and is simple for some of the smaller projects you may be building. It does not have complex abstractions like Vajra or ace engineering like BM25S, Tantivy or Lucene. </p>
<h3 id="bm25s-leveraging-eager-scoring">BM25S: Leveraging Eager Scoring</h3>
<p><strong>BM25S</strong> (2024) introduced a paradigm shift that I found elegant: <strong>eager scoring</strong>. Instead of computing scores at query time, pre-compute everything at index time:</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 674px"><img src="/blog/ai-explorations/posts/2025-12-24-vajra-bm25/images/mermaid/mermaid-03-c658e9ca2e4f.svg" alt="Mermaid diagram 3 for Vajra BM25: Building a Search Engine with Category Theory" width="674" height="520" decoding="async"></div>

<p>The sparse score matrix stores pre-computed BM25 scores for every term-document pair:</p>
<table>
<thead>
<tr>
<th>Term</th>
<th>doc_0</th>
<th>doc_1</th>
<th>doc_2</th>
<th>doc_3</th>
</tr>
</thead>
<tbody>
<tr>
<td>term_0</td>
<td>0</td>
<td>2.3</td>
<td>0</td>
<td>0</td>
</tr>
<tr>
<td>term_1</td>
<td>1.5</td>
<td>0</td>
<td>0</td>
<td>3.2</td>
</tr>
<tr>
<td>term_2</td>
<td>0</td>
<td>0</td>
<td>0.5</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>So, what happens when you supply a query? Query time operations shrink to just sparse matrix slicing and addition. There are no IDF lookups, no BM25 formula evaluation, and none of the heavy lifting. This achieves 100-500x speedup over rank-bm25. The trade-off is memory footprint and no query-time flexibility. So, BM25S is a great tool for the job, and is probably a good starting point for building a search engine. I enjoyed benchmarking against it as I built Vajra search and the BM25S paper made for good reading and provided ideas and hints as to eager scoring, a technique which Vajra borrows from BM25S, in its own way.</p>
<h2 id="building-vajra-a-different-path">Building Vajra: A Different Path</h2>
<p>When I started Vajra, I wanted to combine ideas from different paradigms while using category theory to think about and organize the code. Category theory provides a scaffolding for structuring mathematical abstractions (morphisms, functors, coalgebras) that map naturally onto search operations. My approach was to use this framing to <em>reframe</em> how we think about search, then combine it with aggressive engineering optimizations. </p>
<p>We have discussed above how we need to compute inverted indexes, inverse document frequency, and perform BM25 computation and top-k sorting. These things don't change from implementation to implementation, and Vajra is no different in implementing these also in its pipeline, since after all, it too is a BM25 search engine. However the way these are implemented allows a kind of lazy computation and caching that is not implemented in other frameworks. Let's see more below.</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 805px"><img src="/blog/ai-explorations/posts/2025-12-24-vajra-bm25/images/mermaid/mermaid-04-0b48d83df55c.svg" alt="Mermaid diagram 4 for Vajra BM25: Building a Search Engine with Category Theory" width="805" height="818" decoding="async"></div>

<p>Let's take a look at how Vajra's BM25 flow actually works, through this flow diagram. We will review different individual aspects of Vajra in this context below as well.</p>
<p><img alt="Vajra BM25 Categorical Flow" src="/blog/ai-explorations/posts/2025-12-24-vajra-bm25/images/bm25_flow.png" /></p>
<p>The insight that made Vajra fast came from thinking about what operations actually matter for speed and performance.</p>
<ol>
<li>
<p><strong>Inverted index filtering</strong>: For a query with three terms, only about 1% of documents contain all terms. Why score 500,000 documents when you can score 5,000?</p>
</li>
<li>
<p><strong>LRU query caching</strong>: Real workloads have repeated queries. After the first execution, results are cached; subsequent calls return in nanoseconds.</p>
</li>
<li>
<p><strong>Vectorized scoring</strong>: NumPy's SIMD operations score thousands of candidates in a single vectorized operation.</p>
</li>
<li>
<p><strong>Lazy computation</strong>: Unlike BM25S, Vajra computes scores on-demand. This uses less memory and allows query-time flexibility.</p>
</li>
</ol>
<p>The category theory framing (morphisms for scoring, coalgebras for search unfolding) provides clean code organization and composability. Combined with these engineering optimizations, Vajra delivers both architectural elegance and raw speed.</p>
<h2 id="the-category-theory-foundation">The Category Theory Foundation</h2>
<p>Category theory provides the architectural scaffolding for Vajra. It offers composable abstractions (morphisms, functors, coalgebras) that map directly onto search operations, making the code modular and extensible. The speed comes from combining this clean architecture with engineering optimizations: NumPy vectorization, sparse matrices, and aggressive caching.</p>
<p>Here's how the abstractions organize the code.</p>
<h3 id="how-the-abstractions-organize-code">How the Abstractions Organize Code</h3>
<p>The codebase has a <code>categorical/</code> module with three base abstractions: <code>Morphism</code>, <code>Functor</code>, and <code>Coalgebra</code>. Each provides an interface that derived classes implement.</p>
<p><strong>Morphisms</strong> define composable transformations. The base class enforces an <code>apply()</code> method and provides composition via the <code>&gt;&gt;</code> operator:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Morphism</span><span class="p">(</span><span class="n">ABC</span><span class="p">,</span> <span class="n">Generic</span><span class="p">[</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">]):</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">apply</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">a</span><span class="p">:</span> <span class="n">A</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">B</span><span class="p">:</span> <span class="o">...</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__rshift__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s1">&#39;Morphism[B, C]&#39;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;Morphism[A, C]&#39;</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">ComposedMorphism</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">)</span>
</code></pre></div>

<p>This lets you build pipelines like where each step is a morphism with clear input and output types. The BM25 scorer becomes a morphism <code>(Query, Document) → ℝ</code>, which forces you to think about exactly what data flows in and out. Let is dig in a little. </p>
<p>Let's say we want to build a pipeline that preprocesses a query, tokenizes it, and then scores it. We can do this by composing the following morphisms:</p>
<div class="codehilite"><pre><span></span><code><span class="n">preprocess</span> <span class="o">&gt;&gt;</span> <span class="n">tokenize</span> <span class="o">&gt;&gt;</span> <span class="n">score</span>
</code></pre></div>

<p>This is a pipeline that preprocesses a query, tokenizes it, and then scores it. The preprocess morphism is responsible for preprocessing the query, the tokenize morphism is responsible for tokenizing the query, and the score morphism is responsible for scoring the query. This provides in practice a neat way to organize the code and to make it more readable and maintainable.</p>
<p><strong>Coalgebras</strong> capture the "unfolding" pattern. In a nutshell, an unfolding is a way to represent a data structure as a tree. The base class requires a <code>structure_map()</code> method that takes a state and produces its successors:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Coalgebra</span><span class="p">(</span><span class="n">ABC</span><span class="p">,</span> <span class="n">Generic</span><span class="p">[</span><span class="n">X</span><span class="p">,</span> <span class="n">FX</span><span class="p">]):</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">structure_map</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">state</span><span class="p">:</span> <span class="n">X</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">FX</span><span class="p">:</span> <span class="o">...</span>
</code></pre></div>

<p>From this, I derived <code>SearchCoalgebra</code>, <code>TreeSearchCoalgebra</code>, and <code>ConditionalCoalgebra</code>. Search Coalgebras are a way to represent a search as a tree, and are used to represent the search space. Tree Search Coalgebras are a way to represent a search as a tree, and are used to represent the search space. Conditional Coalgebras are a way to represent a search as a tree, and are used to represent the search space. The BM25 search implementation extends <code>Coalgebra</code> with the signature <code>QueryState → List[SearchResult]</code>:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">BM25SearchCoalgebra</span><span class="p">(</span><span class="n">Coalgebra</span><span class="p">[</span><span class="n">QueryState</span><span class="p">,</span> <span class="n">List</span><span class="p">[</span><span class="n">SearchResult</span><span class="p">]]):</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">structure_map</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">state</span><span class="p">:</span> <span class="n">QueryState</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">List</span><span class="p">[</span><span class="n">SearchResult</span><span class="p">]:</span>
        <span class="n">candidates</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">index</span><span class="o">.</span><span class="n">get_candidates</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">query_terms</span><span class="p">)</span>
        <span class="n">ranked</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">scorer</span><span class="o">.</span><span class="n">rank</span><span class="p">(</span><span class="n">state</span><span class="o">.</span><span class="n">query_terms</span><span class="p">,</span> <span class="n">candidates</span><span class="p">)</span>
        <span class="k">return</span> <span class="p">[</span><span class="n">SearchResult</span><span class="p">(</span><span class="n">doc</span><span class="p">,</span> <span class="n">score</span><span class="p">,</span> <span class="n">rank</span><span class="p">)</span> <span class="k">for</span> <span class="o">...</span><span class="p">]</span>
</code></pre></div>

<p>The same interface works for graph search (BFS, DFS) and tree exploration but with different derived classes. I found this genuinely useful; once you implement <code>structure_map()</code>, you get <code>unfold()</code>, <code>trajectory()</code>, and other coalgebraic operations for free.</p>
<p><strong>Functors</strong> in Vajra are abstractions that allow you to "lift" a transformation—such as a morphism—so it works over entire containers rather than just single values. This is captured in code via an interface: every <code>Functor</code> lets you map any morphism across the relevant structure. For example, the <code>ListFunctor</code> implements a <code>fmap_morphism</code> method that takes a morphism and returns a new morphism, which processes each element of a list independently and collects the results. </p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">ListFunctor</span><span class="p">(</span><span class="n">Functor</span><span class="p">[</span><span class="n">A</span><span class="p">,</span> <span class="n">List</span><span class="p">[</span><span class="n">A</span><span class="p">]]):</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">fmap_morphism</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">f</span><span class="p">:</span> <span class="n">Morphism</span><span class="p">[</span><span class="n">A</span><span class="p">,</span> <span class="n">B</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="n">Morphism</span><span class="p">[</span><span class="n">List</span><span class="p">[</span><span class="n">A</span><span class="p">],</span> <span class="n">List</span><span class="p">[</span><span class="n">B</span><span class="p">]]:</span>
        <span class="k">return</span> <span class="n">FunctionMorphism</span><span class="p">(</span><span class="k">lambda</span> <span class="n">xs</span><span class="p">:</span> <span class="p">[</span><span class="n">f</span><span class="o">.</span><span class="n">apply</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">x</span> <span class="ow">in</span> <span class="n">xs</span><span class="p">])</span>
</code></pre></div>

<p>In the context of search, this means you can write a morphism that operates on an individual query or document, then seamlessly apply it to a whole batch of queries or search candidates using <code>ListFunctor</code>, enabling vectorized or batch-style computation in places where search might branch or aggregate multiple possibilities. This makes the code for candidate generation, ranking, and batch query processing both concise and highly reusable. The same pattern applies to <code>MaybeFunctor</code>, which handles computations that may fail by encapsulating values that might be missing, allowing the rest of the pipeline to remain clean and compositional even in the presence of errors.</p>
<h3 id="the-benefits-of-vajras-architectural-choices">The benefits of Vajra's architectural choices</h3>
<p>By adopting category-theoretic abstractions like morphisms, functors, and coalgebras, Vajra cleanly disentangles data types from transformations and search strategies:</p>
<ul>
<li>Morphisms provide a systematic way to build and compose pipelines with explicit input/output types</li>
<li>Functors let you automatically “lift” these pipelines across collections or optional values for batch or robust computation</li>
<li>Coalgebras encode the core search dynamics in a way that's generic across strategies (tree, graph, conditional) so new algorithms can plug into the same interface and instantly inherit useful unfolding and traversal methods</li>
</ul>
<p>This architectural clarity made it straightforward to add new scorers or search strategies: each abstraction exposes focused extension points, so it’s always clear where to slot in new logic and which minimal methods need to be implemented for composability. The monoid homomorphism insight, that BM25 scores are additive across terms (<code>score(q₁ + q₂) = score(q₁) + score(q₂)</code>) is also interesting and has practical consequences: you can cache term-level scores and sum them at query time, or compute upper bounds on partial scores for early termination. The algebraic structure pointed at optimization opportunities.</p>
<h2 id="benchmarking-vajra">Benchmarking Vajra</h2>
<p>Building the abstractions isn't where it stops, and to build a useful software application, we have to look at what's out there, benchmark the tool we're building to see where it fits in, and what relative benefits it may offer to users. So, to validate Vajra's performance, I benchmarked against a few other implementations:</p>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Language</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>vajra</strong></td>
<td>Python</td>
<td>Vectorized NumPy/SciPy with LRU caching</td>
</tr>
<tr>
<td><strong>vajra-parallel</strong></td>
<td>Python</td>
<td>Vajra + thread pool parallelism</td>
</tr>
<tr>
<td><strong>bm25s</strong></td>
<td>Python</td>
<td>Eager scoring with pre-computed sparse matrices</td>
</tr>
<tr>
<td><strong>bm25s-parallel</strong></td>
<td>Python</td>
<td>BM25S with multi-threading</td>
</tr>
<tr>
<td><strong>tantivy</strong></td>
<td>Rust</td>
<td>Rust-based Lucene alternative (via Python bindings)</td>
</tr>
<tr>
<td><strong>pyserini</strong></td>
<td>Java</td>
<td>Lucene wrapper via Anserini (requires Java 11+)</td>
</tr>
</tbody>
</table>
<p>To be clear, these are all BM25 implementations. All implementations compute the same essential BM25 formula:</p>
<p>
<script type="math/tex; mode=display">
\text{BM25}(q, d) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, d) \cdot (k_1 + 1)}{f(q_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)}
</script>
</p>
<p>The differences are in __how_ they compute it, and <em>when</em>.</p>
<h3 id="benchmark-results">Benchmark Results</h3>
<p>I spent a lot of time benchmarking the initial build of Vajra against <code>Rank-BM25</code>, and then began excluding it from my benchmarks because of how slow the latter was. I moved on to <code>BM25S</code> as the peer framework of choice that I was benchmarking against, and also began testing against Tantivy and Pyserini eventually. While the latter is exceptional in its recall performance, with excellent Recall\@10 and NDCG\@10 (which measure whether the right docs came up, and in the right order) BM25S and Vajra have ultimately be the more comparable frameworks. </p>
<p>See the video below for a Vajra specific search demo. This is using Vajra CLI, which is a command line based search tool which you can point at a JSON, and have a fast search engine right in your terminal!</p>
<div style="text-align: center;">
<iframe width="315" height="560" src="https://www.youtube.com/embed/gsCut5ord9U" frameborder="0" allowfullscreen></iframe>
</div>

<h4 id="beirscifact-5183-documents-300-queries">BEIR/SciFact (5,183 documents, 300 queries)</h4>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Latency</th>
<th>Recall@10</th>
<th>NDCG@10</th>
<th>QPS</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>vajra</strong></td>
<td><strong>0.14ms</strong></td>
<td>78.9%</td>
<td>67.0%</td>
<td><strong>7,133</strong></td>
</tr>
<tr>
<td>bm25s</td>
<td>0.18ms</td>
<td>77.4%</td>
<td>66.2%</td>
<td>5,512</td>
</tr>
<tr>
<td>tantivy</td>
<td>0.28ms</td>
<td>72.5%</td>
<td>60.0%</td>
<td>3,539</td>
</tr>
</tbody>
</table>
<p>Vajra is <strong>~1.3x faster</strong> than BM25S on this dataset, with slightly better recall.</p>
<h4 id="wikipedia200k-200000-documents-500-queries">Wikipedia/200K (200,000 documents, 500 queries)</h4>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Build Time</th>
<th>Latency</th>
<th>Recall@10</th>
<th>NDCG@10</th>
<th>QPS</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>vajra</strong></td>
<td>78s</td>
<td><strong>0.89ms</strong></td>
<td>44.4%</td>
<td>35.1%</td>
<td><strong>1,125</strong></td>
</tr>
<tr>
<td>bm25s</td>
<td>91s</td>
<td>1.08ms</td>
<td>44.6%</td>
<td>35.2%</td>
<td>925</td>
</tr>
<tr>
<td>tantivy</td>
<td>6s</td>
<td>6.68ms</td>
<td><strong>45.6%</strong></td>
<td><strong>36.4%</strong></td>
<td>150</td>
</tr>
</tbody>
</table>
<h4 id="wikipedia500k-500000-documents-500-queries">Wikipedia/500K (500,000 documents, 500 queries)</h4>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Build Time</th>
<th>Latency</th>
<th>Recall@10</th>
<th>NDCG@10</th>
<th>QPS</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>vajra</strong></td>
<td>416s</td>
<td><strong>1.89ms</strong></td>
<td>49.6%</td>
<td>36.7%</td>
<td><strong>529</strong></td>
</tr>
<tr>
<td>bm25s</td>
<td>257s</td>
<td>2.45ms</td>
<td>49.8%</td>
<td>37.1%</td>
<td>409</td>
</tr>
<tr>
<td>tantivy</td>
<td>29s</td>
<td>5.52ms</td>
<td><strong>51.6%</strong></td>
<td><strong>38.3%</strong></td>
<td>181</td>
</tr>
</tbody>
</table>
<p>At 500K documents, Vajra maintains sub-2ms latency and is ~1.3x faster than BM25S. Tantivy has the best accuracy but higher latency.</p>
<h4 id="wikipedia1m-1000000-documents-500-queries">Wikipedia/1M (1,000,000 documents, 500 queries)</h4>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Build Time</th>
<th>Latency</th>
<th>Recall@10</th>
<th>NDCG@10</th>
<th>QPS</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>vajra</strong></td>
<td>17.0 min</td>
<td><strong>3.40ms</strong></td>
<td>45.6%</td>
<td>36.3%</td>
<td><strong>294</strong></td>
</tr>
<tr>
<td>bm25s</td>
<td>11.3 min</td>
<td>5.44ms</td>
<td>45.8%</td>
<td>36.7%</td>
<td>184</td>
</tr>
</tbody>
</table>
<p>At 1M documents, Vajra is <strong>~1.6x faster</strong> than BM25S on single queries (3.40ms vs 5.44ms). Build time has been significantly optimized using per-document array concatenation, bringing it down to 17 minutes. Query latency remains sub-4ms even at million-document scale.</p>
<h2 id="improving-vajras-performance">Improving Vajra's Performance</h2>
<p>Vajra search started off being about as fast as BM25 and was for a couple of months quite a lot slower than BM25S! Tantivy and Pyserini were not in the picture at that stage of the project. My initial thinking was to see if I could build on top of category theory abstractions and see how we could speed things up, but came to the realization quickly that I would have to resort to engineering best practices: caching, filtering, scoring, and sparse matrices. Let's review each of these improvements below. If you use Vajra, you'll see <code>numba</code> used quite a lot in Vajra and for good reason.</p>
<h3 id="lru-query-caching">LRU Query Caching</h3>
<p>This was the single biggest optimization. Real workloads have repeated queries, and caching complete results means subsequent calls return instantly:</p>
<div class="codehilite"><pre><span></span><code><span class="nd">@lru_cache</span><span class="p">(</span><span class="n">maxsize</span><span class="o">=</span><span class="mi">10000</span><span class="p">)</span>
<span class="k">def</span><span class="w"> </span><span class="nf">search</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">top_k</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">10</span><span class="p">):</span>
    <span class="c1"># First call: compute BM25 scores</span>
    <span class="c1"># Subsequent calls: instant cache hit</span>
</code></pre></div>

<p>Combine this with user preference based caches and you can make Vajra search very performant and fantastically usable for a use case where repeated queries will come through from the bulk of users but different in every case. </p>
<h3 id="candidate-filtering-with-inverted-index">Candidate Filtering with Inverted Index</h3>
<p>Instead of scoring all documents like BM25S does with its sparse matrix, Vajra uses the inverted index to identify only the documents that contain query terms:</p>
<div class="codehilite"><pre><span></span><code>Query: &quot;machine learning algorithms&quot;

Traditional (Rank-BM25 or similar): Score 500,000 documents = 500,000 computations
Vajra: Filter to ~5,000 candidates, score only those = 5,000 computations
</code></pre></div>

<p>A large part of building a performant system seems to be to not try to do everything at once, but stage it all out, so that limited resources can be used in the best possible way.</p>
<h3 id="vectorized-scoring">Vectorized Scoring</h3>
<p>Once candidates are identified, scoring uses NumPy vectorization:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Score all candidates in one vectorized operation</span>
<span class="n">scores</span> <span class="o">=</span> <span class="n">idf_vector</span> <span class="o">@</span> <span class="n">tf_matrix</span> <span class="o">*</span> <span class="n">normalization_factors</span>
</code></pre></div>

<p>No point reinventing the wheel that Numpy already provides. Matrix operations are super fast thanks to CPython bindings in Numpy and while it may not be as scalable as pure C or Fortran, it provides a significant speed up.</p>
<h3 id="sparse-matrix-storage">Sparse Matrix Storage</h3>
<p>For corpora over 10K documents, the term-document matrix is 99%+ zeros. SciPy's CSR format avoids storing and computing on those zeros. This is another example of not overdoing stuff in the quest for performance.</p>
<h3 id="partial-sort-for-top-k">Partial Sort for Top-K</h3>
<p>Instead of sorting all scores, <code>np.argpartition</code> provides $O(n)$ average complexity:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># O(n) partial sort instead of O(n log n) full sort</span>
<span class="n">top_indices</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">argpartition</span><span class="p">(</span><span class="n">scores</span><span class="p">,</span> <span class="o">-</span><span class="n">k</span><span class="p">)[</span><span class="o">-</span><span class="n">k</span><span class="p">:]</span>
</code></pre></div>

<p>Note that none of the above are exotic mathematics even if category theory does inform the architecture of Vajra BM25. Most of it is sane engineering you would recognize as a senior engineer or architect, and would likely build into any other project which you want high performance in. </p>
<h2 id="understanding-the-latency-numbers">Understanding the Latency Numbers</h2>
<p>The reported latencies are measured with query caching disabled for fair comparison. Here's what to expect in practice:</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>Typical Latency</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Cold query</strong></td>
<td>0.14-3.4ms</td>
<td>Full BM25 computation (depends on corpus size)</td>
</tr>
<tr>
<td><strong>Repeated query (cached)</strong></td>
<td>~0.001ms</td>
<td>LRU cache hit, near-instant return</td>
</tr>
</tbody>
</table>
<p>Vajra includes an optional LRU cache (<code>cache_size</code> parameter, default 1000) that can dramatically speed up repeated queries. For production workloads with query repetition, this can provide sub-millisecond latency.</p>
<p>As of <a href="https://pypi.org/project/vajra-bm25/0.3.1/">v0.3.1</a>, if you're using the Vajra CLI with single queries (<code>$ vajra-search -q "query"</code>), each invocation creates a new engine with an empty cache. In <em>interactive mode</em>, subsequent queries benefit from caching. The CLI is a super easy way to see Vajra in action. Note that v0.3.1 includes significant index building optimizations (42% faster at 1M scale) using per-document array concatenation.</p>
<p>For library usage, keep the <code>VajraSearchOptimized</code> instance alive across queries to benefit from the LRU cache.</p>
<h2 id="how-do-the-engines-compare">How Do the Engines Compare?</h2>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Key Characteristics</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>vajra</strong></td>
<td>Eager scoring + sparse matrices; optional LRU query caching</td>
</tr>
<tr>
<td><strong>bm25s</strong></td>
<td>Eager scoring + sparse matrices; similar approach to Vajra</td>
</tr>
<tr>
<td><strong>tantivy</strong></td>
<td>Disk-based index; Rust-native; optimized for persistence</td>
</tr>
</tbody>
</table>
<h3 id="vajra-vs-bm25s">Vajra vs BM25S</h3>
<p>Vajra and BM25S use similar underlying techniques (eager scoring with sparse matrices). Vajra is ~1.2-1.6x faster in benchmarks due to optimizations in scoring and top-k selection.</p>
<h3 id="tantivys-different-trade-offs">Tantivy's Different Trade-offs</h3>
<p>Tantivy is written in Rust and optimized for different use cases:</p>
<ol>
<li><strong>Disk-based index</strong>: Supports persistence and updates, but adds I/O latency</li>
<li><strong>Python bridge</strong>: The <code>tantivy-py</code> bindings add some overhead</li>
<li><strong>Different priorities</strong>: Tantivy optimizes for durability and concurrent access, not pure query speed</li>
</ol>
<p>Tantivy has the best accuracy on Wikipedia datasets (+1.6% NDCG over Vajra).</p>
<h2 id="accuracy-analysis">Accuracy Analysis</h2>
<p>How does accuracy compare across engines? Vajra outperforms BM25S and Tantivy on BEIR datasets, while Pyserini (Lucene) leads by 1-2%. On Wikipedia, Tantivy leads with Vajra close behind. Here are the metrics.</p>
<p>BEIR dataset searches: </p>
<table>
<thead>
<tr>
<th>Engine</th>
<th>SciFact NDCG@10</th>
<th>NFCorpus NDCG@10</th>
</tr>
</thead>
<tbody>
<tr>
<td>pyserini</td>
<td><strong>68.8%</strong></td>
<td><strong>32.6%</strong></td>
</tr>
<tr>
<td>vajra</td>
<td>67.0%</td>
<td>30.9%</td>
</tr>
<tr>
<td>bm25s</td>
<td>66.2%</td>
<td>30.7%</td>
</tr>
<tr>
<td>tantivy</td>
<td>60.0%</td>
<td>28.5%</td>
</tr>
</tbody>
</table>
<p>Pyserini leads on BEIR accuracy by 1-2%. Vajra ranks second, ahead of BM25S and Tantivy.</p>
<p>Wikipedia dataset searches: </p>
<table>
<thead>
<tr>
<th>Engine</th>
<th>200K NDCG@10</th>
<th>500K NDCG@10</th>
</tr>
</thead>
<tbody>
<tr>
<td>tantivy</td>
<td><strong>36.4%</strong></td>
<td><strong>38.3%</strong></td>
</tr>
<tr>
<td>vajra</td>
<td>35.1%</td>
<td>36.7%</td>
</tr>
<tr>
<td>bm25s</td>
<td>35.2%</td>
<td>37.1%</td>
</tr>
<tr>
<td>pyserini</td>
<td>31.5%</td>
<td>32.3%</td>
</tr>
</tbody>
</table>
<p>On Wikipedia, Tantivy leads accuracy while Pyserini drops significantly. Vajra holds second place, 1-2% behind Tantivy.</p>
<h2 id="when-to-use-each-engine">When to Use Each Engine</h2>
<table>
<thead>
<tr>
<th>Priority</th>
<th>Best Choice</th>
<th>Why</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Fastest Python BM25</strong></td>
<td>vajra</td>
<td>~1.2-1.3x faster than BM25S</td>
</tr>
<tr>
<td><strong>Sub-2ms latency at scale</strong></td>
<td>vajra</td>
<td>0.14-1.9ms cold, sub-ms with caching</td>
</tr>
<tr>
<td><strong>Best Wikipedia accuracy</strong></td>
<td>tantivy</td>
<td>+1.6% NDCG</td>
</tr>
<tr>
<td><strong>Minimal dependencies</strong></td>
<td>bm25s or vajra</td>
<td>Pure Python, NumPy/SciPy</td>
</tr>
<tr>
<td><strong>Production at billion scale</strong></td>
<td>Elasticsearch</td>
<td>Distributed, battle-tested</td>
</tr>
</tbody>
</table>
<h2 id="benchmark-methodology">Benchmark Methodology</h2>
<p>For reproducibility, here's how I ran the benchmarks:</p>
<h3 id="hardware">Hardware</h3>
<table>
<thead>
<tr>
<th>Spec</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Machine</strong></td>
<td>MacBook Pro</td>
</tr>
<tr>
<td><strong>Chip</strong></td>
<td>Apple M4 Pro</td>
</tr>
<tr>
<td><strong>CPU Cores</strong></td>
<td>14 (10 performance + 4 efficiency)</td>
</tr>
<tr>
<td><strong>Memory</strong></td>
<td>24 GB</td>
</tr>
<tr>
<td><strong>OS</strong></td>
<td>macOS Darwin 25.1.0</td>
</tr>
</tbody>
</table>
<h3 id="process">Process</h3>
<p>The benchmark script follows this sequence for each engine:</p>
<ol>
<li><strong>Load Dataset</strong>: BEIR datasets from HuggingFace; Wikipedia from pre-downloaded JSONL</li>
<li><strong>Check Index Cache</strong>: Compute corpus hash, look for cached index</li>
<li><strong>Build Index</strong> (if not cached): Each engine has its own indexing logic</li>
<li><strong>Save Index to Cache</strong>: Pickle indexes for subsequent runs</li>
<li><strong>Query Evaluation</strong>: Run queries with query caching disabled (<code>cache_size=0</code>) for fair comparison</li>
<li><strong>Compute Metrics</strong>: Recall@10, NDCG@10, latency per query</li>
</ol>
<p><strong>Note</strong>: Query caching is disabled during benchmarks to ensure fair comparison across engines. In production, enabling Vajra's LRU cache can provide sub-millisecond latency for repeated queries.</p>
<h3 id="index-caching">Index Caching</h3>
<p>I used persistent index caching to avoid expensive rebuilds:</p>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Caching</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>vajra</td>
<td>Yes</td>
<td>VectorizedIndexSparse + precomputed IDF</td>
</tr>
<tr>
<td>bm25s</td>
<td>Yes</td>
<td>Pre-computed score matrix</td>
</tr>
<tr>
<td>tantivy</td>
<td>No</td>
<td>Rebuilds each run (~30-45s for large corpora)</td>
</tr>
<tr>
<td>pyserini</td>
<td>No</td>
<td>Lucene index in temp directory</td>
</tr>
</tbody>
</table>
<p>For Vajra on 500K documents, index build takes about 16 minutes. With caching, subsequent runs load in under 5 seconds.</p>
<h2 id="running-the-benchmarks-yourself">Running the Benchmarks Yourself</h2>
<p>Start with a new Python environment and execute the following bash commands:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Clone the repository to get the benchmarking scripts</span>
git<span class="w"> </span>clone<span class="w"> </span>https://github.com/aiexplorations/vajra_bm25.git
<span class="nb">cd</span><span class="w"> </span>vajra_bm25

<span class="c1"># (Optional) Create a virtual environment here if you prefer isolation</span>
<span class="c1"># python -m venv .venv</span>
<span class="c1"># source .venv/bin/activate</span>

<span class="c1"># Install dependencies</span>
pip<span class="w"> </span>install<span class="w"> </span>vajra-bm25<span class="o">[</span>optimized<span class="o">]</span><span class="w"> </span>rank-bm25<span class="w"> </span>bm25s<span class="w"> </span>beir<span class="w"> </span>rich<span class="w"> </span>tantivy
<span class="c1"># doing a pip install is needed to have the whl in your environment</span>

<span class="c1"># Optional: Pyserini (requires Java 11+)</span>
pip<span class="w"> </span>install<span class="w"> </span>pyserini

<span class="c1"># Benchmark script is present only in the repo, does not ship with the whl file.</span>
<span class="c1"># Run BEIR benchmarks</span>
python<span class="w"> </span>benchmarks/benchmark.py<span class="w"> </span>--datasets<span class="w"> </span>beir-scifact<span class="w"> </span>beir-nfcorpus

<span class="c1"># Run Wikipedia benchmarks</span>
python<span class="w"> </span>benchmarks/benchmark.py<span class="w"> </span>--datasets<span class="w"> </span>wiki-200k<span class="w"> </span>wiki-500k

<span class="c1"># Force rebuild (ignore cache)</span>
python<span class="w"> </span>benchmarks/benchmark.py<span class="w"> </span>--datasets<span class="w"> </span>wiki-200k<span class="w"> </span>--no-cache
</code></pre></div>

<h1 id="using-vajra-search">Using Vajra Search</h1>
<p>Vajra works well for latency-sensitive Python workloads. If you're using it, I'd like to hear about your use case.</p>
<p>For starters, since I have built <a href="https://github.com/aiexplorations/praval">Praval</a> earlier, I want to integrate <em>Vajra BM25</em> into it. I have already built a CLI for Vajra BM25 and want to use it in Praval. One of the cool use cases I see for Vajra is in enabling agents to quickly search code bases, or datasets, or text corpuses. This can become a game changer for large scale multi-agent systems.</p>
<h2 id="recent-updates-v032">Recent Updates (v0.3.2)</h2>
<p>The latest release adds two features that extend Vajra's usability:</p>
<p><strong>PDF Document Support</strong>: You can now index and search PDF documents directly, without converting them to JSONL first. This makes it easy to build a search engine over research papers, reports, or any PDF collection:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">vajra_bm25</span><span class="w"> </span><span class="kn">import</span> <span class="n">DocumentCorpus</span><span class="p">,</span> <span class="n">VajraSearchOptimized</span>

<span class="c1"># Load PDFs - single file, directory, or auto-detect</span>
<span class="n">corpus</span> <span class="o">=</span> <span class="n">DocumentCorpus</span><span class="o">.</span><span class="n">load_pdf_directory</span><span class="p">(</span><span class="s2">&quot;./papers/&quot;</span><span class="p">,</span> <span class="n">recursive</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
<span class="n">engine</span> <span class="o">=</span> <span class="n">VajraSearchOptimized</span><span class="p">(</span><span class="n">corpus</span><span class="p">)</span>
<span class="n">results</span> <span class="o">=</span> <span class="n">engine</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="s2">&quot;attention mechanism&quot;</span><span class="p">,</span> <span class="n">top_k</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</code></pre></div>

<p>The CLI supports this too: <code>vajra-search --corpus ./papers/ -q "neural networks"</code></p>
<p>Install PDF support with: <code>pip install vajra-bm25[pdf]</code></p>
<p><strong>Documentation Site</strong>: Comprehensive documentation is now available at <a href="https://aiexplorations.github.io/vajra_bm25/">aiexplorations.github.io/vajra_bm25</a>, covering installation, API reference, BM25 parameter tuning, performance optimization, and the category theory foundations.</p>
<h2 id="closing-thoughts">Closing Thoughts</h2>
<p>Vajra is the fastest Python BM25 implementation available today: ~1.2-1.6x faster than BM25S depending on corpus size, with sub-4ms latency even at 1M documents. LRU caching delivers sub-millisecond responses for repeated queries in production.</p>
<p>The category theory framing shaped how I organized the code. Morphisms for scoring, coalgebras for search unfolding, functors for batch operations: these abstractions separate concerns cleanly and make the codebase extensible. The monoid homomorphism structure of BM25 scoring opened up optimization opportunities I wouldn't have seen otherwise.</p>
<p>I built Vajra because I wanted a fast, hackable BM25 engine for Python that I could integrate into agentic systems like <a href="https://github.com/aiexplorations/praval">Praval</a>. It's open source, actively maintained, and ready for your projects. </p>
<h2 id="links-and-references">Links and References:</h2>
<ul>
<li><strong>Vajra BM25 Repo</strong>: <a href="https://github.com/aiexplorations/vajra_bm25">github.com/aiexplorations/vajra_bm25</a></li>
<li><strong>Vajra BM25 Documentation</strong>: <a href="https://aiexplorations.github.io/vajra_bm25/">aiexplorations.github.io/vajra_bm25</a></li>
<li><strong>Vajra BM25 on PyPI</strong>: <a href="https://pypi.org/project/vajra-bm25/">pypi.org/project/vajra-bm25</a></li>
<li><strong>Vajra BM25 Benchmark docs</strong>: <a href="https://github.com/aiexplorations/vajra_bm25/blob/main/docs/vajra_benchmark_comparison.md">docs/vajra_benchmark_comparison.md</a></li>
<li><strong>Category Theory for Programmers</strong>: <a href="https://bartoszmilewski.com/2014/10/28/category-theory-for-programmers-the-preface/">Bartosz Milewski</a></li>
<li><a href="https://github.com/castorini/pyserini"><strong>Pyserini</strong></a></li>
<li><a href="https://huggingface.co/blog/xhluca/bm25s"><strong>BM25S</strong>:</a></li>
<li><a href="https://github.com/tantivy-search/tantivy"><strong>Tantivy</strong></a></li>
<li><a href="https://github.com/dorianbrown/rank_bm25"><strong>Rank-BM25</strong></a></li>
<li><a href="https://github.com/aiexplorations/praval"><strong>Praval</strong></a></li>
<li><a href="https://github.com/beir-cellar/beir"><strong>BEIR</strong></a></li>
<li><a href="https://dumps.wikimedia.org/enwiki/20251201/enwiki-20251201-pages-articles-multistream.xml.bz2"><strong>Wikipedia</strong></a></li>
<li><a href="https://jsonlines.org/"><strong>JSONL</strong></a></li>
<li><a href="https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_Recently_Used_(LRU)"><strong>LRU Cache</strong></a></li>
<li><a href="https://en.wikipedia.org/wiki/Partial_sorting"><strong>Partial Sort</strong></a></li>
</ul>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>vajra</category>
      <category>bm25</category>
      <category>search</category>
      <category>high performance python</category>
      <category>python</category>
      <category>numba</category>
      <category>lexical search</category>
      <category>sparse matrices</category>
      <category>lr caching</category>
      <category>partial sort</category>
      <category>top-k</category>
      <category>query optimizations</category>
      <category>index optimizations</category>
      <category>eager scoring</category>
      <category>category-theory</category>
      <category>information-retrieval</category>
      <category>benchmarks</category>
      <category>tantivy</category>
      <category>lucene</category>
    </item>
    <item>
      <title>Tlön Mathematics: A Process-First Framework Inspired by Borges</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-12-03-tlon-mathematics.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-12-03-tlon-mathematics.html</guid>
      <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
      <description>Building a mathematical framework where processes are primitive and objects emerge as stable patterns - with 20 axioms, rigorous proofs, and simulations of dynamical systems that demonstrate the core insight: stability is special, not generic.</description>
      <content:encoded><![CDATA[<p>Jorge Luis Borges' short story "Tlön, Uqbar, Orbis Tertius" describes a fictional world where the inhabitants speak a language with no nouns - only verbs. There are no objects, only processes. The moon rising over water is not a thing acting upon another thing; it is a single verb, a happening. Below is the page from Borges' aforementioned story that inspired this line of thinking.</p>
<p><img alt="A speculative diagram of Tlön's &quot;Ursprache&quot;, a world of processes, not objects." src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/tlon_ursprache.jpeg" /></p>
<p>This idea was lodged in my mind for years. What if we took it seriously? What if we built a mathematical framework where <em>processes</em> are primitive and <em>objects</em> emerge as stable patterns? The philosophical implications of this seemed enormous, each time I thought about it. Every cell in the human body has an expiration date, but the human body itself maintains its rough shape over time. We experience the process of aging. Turbulence transforms the way molecules of water behave, as they leave the outlet of a tap and drop down; we see laminar flow transition to turbulent flow. Double pendulums behave in very unpredictable ways even if we repeat experiments with them from just mildly different initial conditions, and at the same time, grand phenomena such as the Great Red Spot on Jupiter are stable and predictable for years. The moon's orbit around the Earth seems a stable process but over millennia, there are minor variations in the orbits of all celestial bodies. You get the idea - the list of phenomena that could be characterised as emergent are endless and all around us. </p>
<p>What I have been exploring with Tlon mathematics is the possibility that we could describe many of these emergent phenomena, or even phenomena we consider rule-based, or stable, from the lens of a process-first mathematical paradigm. I'd learned some category theory basics earlier in the year, and wanted to see if I could build an algebra that addressed just such a process-centric approach to systems modelling. Later in the year, with Claude Code as my collaborator, I finally attempted to build the scaffolding, proofs, and the "house" on top of which actual mathematical systems and conjectures could be posited.</p>
<p>The result of all this rumination and exploration with Claude Code is <strong>Tlön Mathematics</strong>. I will interchangeably use Tlön and Tlon, for the convenience of typing is an important aspect of the process of communication. Tlön (Tlon) mathematics is a mathematical framework built on top of 20 axioms across 6 groups, with theorems, proofs, and concrete implementations in Python that convert the mathematical foundation into a computational foundation that demonstrates the concepts through various systems simulations, including simulations of dynamical systems. As a purely intellectual exercise, this was quite satisfying, because, well, a set of core axioms was converted through sequential rules into a whole mathematical world view, and one inspired by a work of fiction, by one of my favourite authors, at that. Working with the ideas of Tlon mathematics has made me appreciate how we can frame dynamical systems and the emergence of objects in the physical world in a different way.</p>
<h2 id="the-central-inversion">The Central Inversion</h2>
<p>In conventional mathematics, objects exist first and processes act upon them. A rock exists; erosion happens to it. A planet exists; orbital motion happens to it.</p>
<p>Tlön inverts this, in a specific way: processes are fundamental, and objects are stable patterns - processes that maintain themselves through repetition. This isn't to say that Tlon mathematics bypasses the established rules of mathematics, and to the contrary, Tlon mathematics is built on top of a category theoretic structure, like other algebras. Identities, relationships like associativity and the like can be proven in this system of mathematics like in others. </p>
<p>As I described in examples above, objects are our default mode of viewing and understanding the world around us, and Tlon challenges this convention, based on the phenomena that we do see that indicate how this object-property-interaction ontology in our heads cannot explain transient objects or objects like the Mandelbrot set. Through the lens of Tlon, therefore, a rock isn't an object, but the result of a stable process that has produced an aggregation of things that appears to be a rock, and that we can classify as such. The rock's changes over time, be it erosion through wind or rain or other phenomena, and these processes that give the rock its characteristics are also processes. A planet is a specific aggregation of matter that has emerged as a stable process (or a large set of stable processes), resulting in the object we see as the planet. While this may seem like splitting hairs when we write it down in English, it becomes much clearer when we write it down in terms of definitions, assertions we have as axioms, and allow the system of mathematics to guide us in terms of the properties that emerge as a consequence of these foundations. Tlon serves as a mathematical scaffolding for discussing processes whose physics have been established, and as a process algebra, but perhaps does not serve as a physics scaffolding, and I have a hunch that this is something I will discover in time as I work with Tlon mathematics.</p>
<h2 id="how-objects-emerge-from-processes">How Objects Emerge from Processes</h2>
<p>The key insight is formal: an <strong>object</strong> in Tlon is defined as an equivalence class of stable processes:</p>
<p>
<script type="math/tex; mode=display">\text{Object} := [\pi]_{\approx} \text{ where } \text{Stable}(\pi)</script>
</p>
<p>A process is stable when repeating it is equivalent to doing it once:</p>
<p>
<script type="math/tex; mode=display">\text{Stable}(\pi) \Leftrightarrow \pi ; \pi \approx \pi</script>
</p>
<p>This definition inverts traditional ontology:</p>
<table>
<thead>
<tr>
<th>Traditional View</th>
<th>Tlon View</th>
</tr>
</thead>
<tbody>
<tr>
<td>Objects exist primitively</td>
<td>Processes exist primitively</td>
</tr>
<tr>
<td>Processes act on objects</td>
<td>Objects emerge from stable processes</td>
</tr>
<tr>
<td>"What things exist?"</td>
<td>"Which processes are stable?"</td>
</tr>
</tbody>
</table>
<p><strong>Concrete examples:</strong></p>
<ul>
<li><strong>Flame</strong>: The combustion process $\pi_{\text{flame}}$ is stable; sustained burning is equivalent to momentary burning in terms of the pattern. The "flame" we perceive is the equivalence class $[\pi_{\text{flame}}]$.</li>
<li><strong>Sorted array</strong>: The sorting process satisfies $\text{sort} ; \text{sort} \approx \text{sort}$. A sorted array is the stable fixed point.</li>
<li><strong>Planetary orbit</strong>: One complete orbit $\pi_{\text{orbit}}$ satisfies $\pi_{\text{orbit}} ; \pi_{\text{orbit}} \approx \pi_{\text{orbit}}$. The "orbit" is the stable pattern, not the planet.</li>
<li><strong>Standing wave</strong>: Two traveling waves interfere to produce a pattern that maintains itself through time.</li>
</ul>
<p>In each case, the "object" is not a thing but a self-sustaining pattern of activity. The question "what objects exist?" becomes an algebraic question: "which processes are idempotent under sequential composition?"</p>
<h2 id="tlons-processes-and-stability">Tlon's processes and stability</h2>
<p>Tlon could potentially allow us to model phenomena around us in dynamic slices. For example, a planet need not be modelled as an object that has a certain range of motions owing to fundamental forces such as gravity, but it could be modelled in Tlon terms as an orbital process that is stable, resulting in accretion and the the object we see as the planet. Geophysical phenomena may be similarly modeled starting from the processes we observe. </p>
<p>Stability is defined as follows:</p>
<p>
<script type="math/tex; mode=display">\text{Stable}(\pi) \Leftrightarrow \pi \circ \pi \approx \pi</script>
</p>
<p>A stable process is one where a repetition of the process is equivalent to performing it once. In algebra, such elements are called <em>idempotent</em>. These are the closest thing to "objects" in Tlön - self-maintaining patterns that persist through repetition. </p>
<h2 id="the-20-axioms">The 20 Axioms</h2>
<p>The framework is built on 20 axioms organized into 6 groups:</p>
<table>
<thead>
<tr>
<th>Group</th>
<th>Axioms</th>
<th>What They Establish</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>A</strong></td>
<td>A1-A2</td>
<td>Existence: Processes exist and have duration</td>
</tr>
<tr>
<td><strong>B</strong></td>
<td>B1-B4</td>
<td>Sequential composition. We introduce the $;$ operator "then"</td>
</tr>
<tr>
<td><strong>C</strong></td>
<td>C1-C5</td>
<td>Concurrent composition ($\parallel$): "while"</td>
</tr>
<tr>
<td><strong>D</strong></td>
<td>D1-D5</td>
<td>Interference: How concurrent processes affect each other</td>
</tr>
<tr>
<td><strong>E</strong></td>
<td>E1-E2</td>
<td>Stability: Idempotent processes as "objects"</td>
</tr>
<tr>
<td><strong>F</strong></td>
<td>F1-F4</td>
<td>Equivalence: Behavioral interchangeability</td>
</tr>
</tbody>
</table>
<h3 id="complete-axiom-list">Complete Axiom List</h3>
<p><strong>Group A: Existence</strong><br />
- <strong>A1</strong>: $\exists\pi$ (Process($\pi$)) — Processes exist<br />
- <strong>A2</strong>: $\exists!\varepsilon$ $(|\varepsilon| = 0)$ — Unique null process with zero duration</p>
<p><strong>Group B: Sequential Composition</strong><br />
- <strong>B1</strong>: $\forall\pi,\rho \Rightarrow \text{Process}(\pi ; \rho)$ — Closure<br />
- <strong>B2</strong>: $(\pi ; \rho) ; \sigma \approx \pi ; (\rho ; \sigma)$ — Associativity<br />
- <strong>B3</strong>: $\pi ; \varepsilon \approx \pi \approx \varepsilon ; \pi$ — Identity<br />
- <strong>B4</strong>: $|\pi ; \rho| = |\pi| + |\rho|$ — Duration additivity</p>
<p><strong>Group C: Concurrent Composition</strong><br />
- <strong>C1</strong>: $\forall\pi,\rho \Rightarrow \text{Process}(\pi \parallel \rho)$ — Closure<br />
- <strong>C2</strong>: $(\pi \parallel \rho) \parallel \sigma \approx \pi \parallel (\rho \parallel \sigma)$ — Associativity<br />
- <strong>C3</strong>: $\pi \parallel \rho \approx \rho \parallel \pi$ — Commutativity<br />
- <strong>C4</strong>: $\pi \parallel \varepsilon \approx \pi$ — Identity<br />
- <strong>C5</strong>: $|\pi \parallel \rho| = \max(|\pi|, |\rho|)$ — Duration maximum</p>
<p><strong>Group D: Interference</strong><br />
- <strong>D1</strong>: $\text{Int}(\pi, \rho) \approx \text{Int}(\rho, \pi)$ — Symmetry<br />
- <strong>D2</strong>: $\text{Int}(\pi, \pi) \approx \pi$ — Self-interference<br />
- <strong>D3</strong>: $\text{Int}(\pi, \varepsilon) \approx \pi$ — Null non-interference<br />
- <strong>D4</strong>: $|\pi| = |\rho| \Rightarrow \pi \parallel \rho \approx \pi ; \text{Int}(\pi, \rho) ; \rho$ — Decomposition<br />
- <strong>D5</strong>: $\pi \approx \pi' \land \rho \approx \rho' \Rightarrow \text{Int}(\pi, \rho) \approx \text{Int}(\pi', \rho')$ — Congruence</p>
<p><strong>Group E: Stability</strong><br />
- <strong>E1</strong>: $\text{Stable}(\pi) \Leftrightarrow \pi ; \pi \approx \pi$ — Idempotence definition<br />
- <strong>E2</strong>: $\exists\pi$ $(\text{Stable}(\pi) \land \pi \not\approx \varepsilon)$ — Non-trivial stability exists</p>
<p><strong>Group F: Equivalence</strong><br />
- <strong>F1</strong>: $\pi \approx \pi$ — Reflexivity<br />
- <strong>F2</strong>: $\pi \approx \rho \Rightarrow \rho \approx \pi$ — Symmetry<br />
- <strong>F3</strong>: $\pi \approx \rho \land \rho \approx \sigma \Rightarrow \pi \approx \sigma$ — Transitivity<br />
- <strong>F4</strong>: $\approx$ respects $;$ and $\parallel$ — Congruence</p>
<p>Three axioms deserve particular attention:</p>
<p><strong>Axiom E1 (Stability Definition)</strong>:<br />
<script type="math/tex; mode=display">\text{Stable}(\pi) \Leftrightarrow \pi \circ \pi \approx \pi</script>
</p>
<p>A process is stable if repeating it yields the same thing. The null process $\varepsilon$ (doing nothing) is trivially stable. But other stable processes exist too; these are the emergent "objects", the results of stability.</p>
<p><strong>Axiom B4 (Duration Additivity)</strong>:<br />
<script type="math/tex; mode=display">|\pi \circ \rho| = |\pi| + |\rho|</script>
</p>
<p>Sequential composition adds durations. If a process $\pi$ has a duration of 3 units and another $\rho$ has a duration of 5 units, doing them in sequence takes 8 units. This is self-evident and agrees with common algebras we are familiar with.</p>
<p><strong>Axiom C2 (Concurrent Commutativity)</strong>:<br />
<script type="math/tex; mode=display">\pi \parallel \rho \approx \rho \parallel \pi</script>
</p>
<p>Unlike sequence (where order matters), concurrence is symmetric. "$\pi$ while $\rho$" is the same as "$\rho$ while $\pi$."</p>
<h2 id="key-theorems">Key Theorems</h2>
<p>From these axioms, several important results follow. We validate the following Tlon theorems using a category theory foundation, which becomes necessary as the latter is a scaffolding for many mathematical systems.</p>
<p><strong>Theorem 1.1</strong>: Sequential composition forms a monoid - $(\text{Process}, ;, \varepsilon)$ has closure, associativity, and identity. Here <code>;</code> represents the sequential composition operator, and $\varepsilon$ represents the null process.</p>
<p><em>Proof</em>: Closure by B1, associativity by B2, identity by B3. ∎</p>
<p><strong>Theorem 1.2</strong>: Concurrent composition forms a commutative monoid - $(\text{Process}, \parallel, \varepsilon)$ additionally has commutativity. Here $\parallel$ represents the concurrent composition operator, and $\varepsilon$ represents the null process.</p>
<p><em>Proof</em>: Closure by C1, associativity by C2, commutativity by C3, identity by C4. ∎</p>
<p><strong>Theorem 4.3 (Power Collapse)</strong>: If some process $\pi$ is stable, then $\pi^n \approx \pi$ for all $n \geq 1$. Sequential powers collapse for stable processes. Here $\approx$ represents the equivalence operator.</p>
<p><em>Proof</em>: By induction. Base: $\pi^1 = \pi$. Step: If $\pi^n \approx \pi$, then $\pi^{n+1} = \pi^n ; \pi \approx \pi ; \pi \approx \pi$ (by stability). ∎</p>
<p><strong>The Emergence Theorem</strong>: In any finitely-generated process space with non-trivial stable processes, the property "non-trivially stable" is <em>emergent</em> - it doesn't hold for any generator but must hold for some composite process.</p>
<p><em>Proof</em>: See <a href="https://github.com/aiexplorations/tlon_math/blob/main/docs/TLON_THEORY_BOOKLET.md">Tlon Theory Booklet</a>, Theorem E1. The proof uses the level function $L(\pi)$ measuring compositional complexity. ∎</p>
<h3 id="the-emergence-theorems">The Emergence Theorems</h3>
<p>Tlon's approach to emergence is mathematically precise: it shows how stable patterns (objects, structures, dynamics) arise out of the interactions and compositions of unstable, transient parts. The theorems, drawn from the Tlon Theory Booklet, clarify different faces of emergence:</p>
<h4 id="e2-resonance-generates-emergent-stability"><strong>E2: Resonance Generates Emergent Stability</strong></h4>
<blockquote>
<p><strong>Theorem (Resonance Emergence):</strong><br />
<em>If $\pi$ and $\rho$ are both unstable, but $\text{Stable}(\pi \parallel \rho)$, then stability is an emergent property of their combination.</em></p>
</blockquote>
<p><em>Proof</em>: By assumption, $\text{Stable}(\pi) = \text{false}$ and $\text{Stable}(\rho) = \text{false}$, yet $\text{Stable}(\pi \parallel \rho) = \text{true}$. The whole has a property neither part possesses. ∎</p>
<ul>
<li><strong>Interpretation:</strong> Two chaotic or transient processes, when run concurrently, may form a composite process that is stable, even though neither is stable alone. This is resonance: the "whole is more than the sum of its parts" in a formal algebraic sense.</li>
<li><strong>Concrete Example:</strong><br />
  In the Lotka-Volterra predator-prey model (Volterra, 1926; Lotka, 1925), the isolated prey or predator populations are unstable. But together, their interaction stabilizes the entire ecosystem into periodic cycles. A stable process emerges from their interplay.</li>
</ul>
<h4 id="e3-objecthood-is-emergent"><strong>E3: Objecthood is Emergent</strong></h4>
<blockquote>
<p><strong>Theorem (Emergence of Objects):</strong><br />
<em>Non-trivial objects (i.e., equivalence classes of stable processes other than the null process) cannot exist at the primitive level. They arise only through composition and stabilization.</em></p>
</blockquote>
<p><em>Proof</em>: Objects are equivalence classes of stable processes. At level 0, only generators exist. Since non-trivial stability requires composition (by E1), objects first appear at level ≥ 1. ∎</p>
<ul>
<li><strong>Interpretation:</strong><br />
  In Tlon, objects are not presupposed. There are no building blocks waiting to be labeled as "objects"; instead, objects <em>emerge</em> from the closure and stabilization of processes. The question is not "what objects are there?" but "which processes are stable?" The stable patterns themselves are the objects.</li>
</ul>
<h4 id="e4-downward-constraint-emergent-constraint"><strong>E4: Downward Constraint (Emergent Constraint)</strong></h4>
<blockquote>
<p><strong>Theorem (Downward Constraint):</strong><br />
<em>If $\pi = \sigma ; \tau$ is stable, then $\sigma ; \tau ; \sigma ; \tau \approx \sigma ; \tau$. The global stability of the composite process constrains the behaviors of its components.</em></p>
</blockquote>
<p><em>Proof</em>: If $\pi = \sigma ; \tau$ is stable, then $\pi ; \pi \approx \pi$ by E1. Substituting: $(\sigma ; \tau) ; (\sigma ; \tau) \approx \sigma ; \tau$. ∎</p>
<ul>
<li><strong>Interpretation:</strong><br />
  Emergent stability at a higher level (the composite process) imposes algebraic constraints on the lower-level sequences that compose it. This expresses a formal version of "downward causation": the stability of the whole feeds back to restrict the ways its parts can behave.</li>
</ul>
<p>The full formal statements, proofs, and examples are presented in the <a href="https://github.com/aiexplorations/tlon_math/blob/main/docs/TLON_THEORY_BOOKLET.md">Tlon Theory Booklet</a>.</p>
<h2 id="the-doctrine-of-no-inverses">The Doctrine of No Inverses</h2>
<p>One of the most striking results is that <strong>process inverses are ontologically forbidden</strong>. For any process $\pi$ with positive duration, there exists no process $\rho$ such that $\pi ; \rho \approx \varepsilon$.</p>
<p>The argument is straightforward:<br />
1. By Duration Additivity: $|\pi ; \rho| = |\pi| + |\rho| \geq |\pi| &gt; 0$<br />
2. But $|\varepsilon| = 0$<br />
3. Therefore $\pi ; \rho$ cannot equal $\varepsilon$</p>
<p>Happenings cannot un-happen. The arrow of time is woven into the fabric of process composition. This is not a bug but a feature - it captures something fundamental about causality and time.</p>
<p>The correct concept is <strong>reversal</strong>, not inverse. A reversal $\rho$ of $\pi$ completes a stable cycle: $\text{Stable}(\pi ; \rho)$. The pendulum swings left ($\pi$), then right ($\rho$). The composite $\pi ; \rho$ is one complete oscillation - a stable process. Time passed; something happened. But the pattern is closed, self-sustaining.</p>
<h2 id="the-code-structure">The Code Structure</h2>
<p>The mathematical scaffolding I've built for Tlon is also being represented in code, as a Python code base. I am perhaps undecided on whether this should be written in a functional programming language, which seems to provide first class abstractions to deal with the nuts and bolts of Tlon mathematics. In any case, the Python implementation has been helpful to simulate different kinds of systems and understand them with the scaffolding of Tlon mathematics.</p>
<p>The framework is implemented in Python with type hints and comprehensive tests. The core abstractions live in <code>tlon/core/</code>:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Process</span><span class="p">(</span><span class="n">ABC</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Abstract base class for all processes.&quot;&quot;&quot;</span>

    <span class="nd">@property</span>
    <span class="nd">@abstractmethod</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Intrinsic temporal extent of the process.&quot;&quot;&quot;</span>
        <span class="k">pass</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">is_stable</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1e-9</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Axiom E1: Stable(π) ⟺ π ; π ≈ π&quot;&quot;&quot;</span>
        <span class="k">return</span> <span class="p">(</span><span class="bp">self</span> <span class="o">@</span> <span class="bp">self</span><span class="p">)</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__matmul__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">):</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Sequential composition: π @ ρ (represents π ; ρ)&quot;&quot;&quot;</span>
        <span class="o">...</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__or__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">):</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Concurrent composition: π | ρ (represents π ∥ ρ)&quot;&quot;&quot;</span>
        <span class="o">...</span>
</code></pre></div>

<p>A number of concrete classes and patterns are implemented in the Tlon math codebase to make the abstract foundations operational and testable. Some illustrative examples:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Sequence</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;A process representing a fixed sequence of sub-processes.&quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">steps</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Process</span><span class="p">]):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">steps</span> <span class="o">=</span> <span class="n">steps</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">s</span><span class="o">.</span><span class="n">duration</span> <span class="k">for</span> <span class="n">s</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">steps</span><span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__matmul__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s2">&quot;Sequence&quot;</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Sequence</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">steps</span> <span class="o">+</span> <span class="p">([</span><span class="n">other</span><span class="p">]</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Process</span><span class="p">)</span> <span class="k">else</span> <span class="nb">list</span><span class="p">(</span><span class="n">other</span><span class="o">.</span><span class="n">steps</span><span class="p">)))</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__or__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s2">&quot;Concurrent&quot;</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Concurrent</span><span class="p">([</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">equiv</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">,</span> <span class="n">tolerance</span><span class="o">=</span><span class="mf">1e-9</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
        <span class="c1"># Equivalence up to minor perturbations</span>
        <span class="k">return</span> <span class="p">(</span>
            <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Sequence</span><span class="p">)</span> <span class="ow">and</span>
            <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">steps</span><span class="p">)</span> <span class="o">==</span> <span class="nb">len</span><span class="p">(</span><span class="n">other</span><span class="o">.</span><span class="n">steps</span><span class="p">)</span> <span class="ow">and</span>
            <span class="nb">all</span><span class="p">(</span><span class="n">s1</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="n">s2</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">)</span> <span class="k">for</span> <span class="n">s1</span><span class="p">,</span> <span class="n">s2</span> <span class="ow">in</span> <span class="nb">zip</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">steps</span><span class="p">,</span> <span class="n">other</span><span class="o">.</span><span class="n">steps</span><span class="p">))</span>
        <span class="p">)</span>
</code></pre></div>

<p>A <strong>Concurrent</strong> class expresses parallel composition:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Concurrent</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Represents concurrent execution of several processes.&quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">parts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="n">Process</span><span class="p">]):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">parts</span> <span class="o">=</span> <span class="n">parts</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="n">p</span><span class="o">.</span><span class="n">duration</span> <span class="k">for</span> <span class="n">p</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">parts</span><span class="p">)</span> <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">parts</span> <span class="k">else</span> <span class="mf">0.0</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__matmul__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s2">&quot;Sequence&quot;</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Sequence</span><span class="p">([</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__or__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s2">&quot;Concurrent&quot;</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Concurrent</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">parts</span> <span class="o">+</span> <span class="p">([</span><span class="n">other</span><span class="p">]</span> <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Process</span><span class="p">)</span> <span class="k">else</span> <span class="nb">list</span><span class="p">(</span><span class="n">other</span><span class="o">.</span><span class="n">parts</span><span class="p">)))</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">equiv</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">,</span> <span class="n">tolerance</span><span class="o">=</span><span class="mf">1e-9</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
        <span class="c1"># Permutation-insensitive equivalence</span>
        <span class="k">return</span> <span class="p">(</span><span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Concurrent</span><span class="p">)</span> <span class="ow">and</span>
                <span class="nb">set</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">parts</span><span class="p">)</span> <span class="o">==</span> <span class="nb">set</span><span class="p">(</span><span class="n">other</span><span class="o">.</span><span class="n">parts</span><span class="p">))</span>
</code></pre></div>

<p>Single-shot atomic happenings like events are provided by primitives:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Event</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;An instantaneous process (duration == epsilon &gt; 0).&quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">label</span><span class="p">:</span> <span class="nb">str</span><span class="p">,</span> <span class="n">duration</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1e-6</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">label</span> <span class="o">=</span> <span class="n">label</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_duration</span> <span class="o">=</span> <span class="n">duration</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_duration</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__matmul__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">Sequence</span><span class="p">([</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__or__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">Concurrent</span><span class="p">([</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">equiv</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">,</span> <span class="n">tolerance</span><span class="o">=</span><span class="mf">1e-9</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
        <span class="k">return</span> <span class="p">(</span>
            <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Event</span><span class="p">)</span> <span class="ow">and</span>
            <span class="bp">self</span><span class="o">.</span><span class="n">label</span> <span class="o">==</span> <span class="n">other</span><span class="o">.</span><span class="n">label</span> <span class="ow">and</span>
            <span class="nb">abs</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">duration</span> <span class="o">-</span> <span class="n">other</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">tolerance</span>
        <span class="p">)</span>
</code></pre></div>

<p><strong>Oscillation</strong> can be implemented for resonance experiments:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">Oscillation</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Models periodic processes (e.g., Lotka-Volterra cycles, pendulum).&quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">period</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">cycles</span><span class="p">:</span> <span class="nb">int</span> <span class="o">=</span> <span class="mi">1</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">period</span> <span class="o">=</span> <span class="n">period</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">cycles</span> <span class="o">=</span> <span class="n">cycles</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">period</span> <span class="o">*</span> <span class="bp">self</span><span class="o">.</span><span class="n">cycles</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__matmul__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">):</span>
        <span class="c1"># Concatenating oscillatory cycles</span>
        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Oscillation</span><span class="p">)</span> <span class="ow">and</span> <span class="n">other</span><span class="o">.</span><span class="n">period</span> <span class="o">==</span> <span class="bp">self</span><span class="o">.</span><span class="n">period</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">Oscillation</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">period</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">cycles</span> <span class="o">+</span> <span class="n">other</span><span class="o">.</span><span class="n">cycles</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">Sequence</span><span class="p">([</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__or__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s2">&quot;Process&quot;</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">Concurrent</span><span class="p">([</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">equiv</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">,</span> <span class="n">tolerance</span><span class="o">=</span><span class="mf">1e-9</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
        <span class="k">return</span> <span class="p">(</span>
            <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">Oscillation</span><span class="p">)</span>
            <span class="ow">and</span> <span class="nb">abs</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">period</span> <span class="o">-</span> <span class="n">other</span><span class="o">.</span><span class="n">period</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">tolerance</span>
            <span class="ow">and</span> <span class="bp">self</span><span class="o">.</span><span class="n">cycles</span> <span class="o">==</span> <span class="n">other</span><span class="o">.</span><span class="n">cycles</span>
        <span class="p">)</span>
</code></pre></div>

<p>These classes allow modeling of concrete systems:<br />
- A <strong>NBodyOrbit</strong> class (not shown here) composes body-body pairwise processes.<br />
- <strong>DampedOscillator</strong> for irreversible transients (energy dissipation).<br />
- <strong>StableEquilibrium</strong> for fixed points.<br />
- <strong>Reversal</strong> for stable cycles, e.g., as <code>Oscillation(period=T, cycles=1)</code>.</p>
<p>The abstraction boundary: any process that satisfies <code>is_stable()</code> according to the abstract axioms above will behave as a "Tlön object": its composition is idempotent (up to tolerance), making mathematical stability operational in code.</p>
<p>Processes are classified in a <strong>purity spectrum</strong>:</p>
<table>
<thead>
<tr>
<th>Class</th>
<th>Sequentially Stable</th>
<th>Concurrently Stable</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Transient</strong></td>
<td>No</td>
<td>No</td>
<td>Double pendulum (chaotic)</td>
</tr>
<tr>
<td><strong>Seq. Stable</strong></td>
<td>Yes</td>
<td>No</td>
<td>Kepler orbits</td>
</tr>
<tr>
<td><strong>Conc. Stable</strong></td>
<td>No</td>
<td>Yes</td>
<td>Individual oscillators</td>
</tr>
<tr>
<td><strong>Pure</strong></td>
<td>Yes</td>
<td>Yes</td>
<td>Fixed points, equilibria</td>
</tr>
</tbody>
</table>
<h2 id="from-primitives-to-simulations">From Primitives to Simulations</h2>
<p>The abstract Process class needs to model actual dynamical systems to be useful. The <code>DynamicalProcess</code> base class bridges Tlon's axioms to numerical simulation:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">DynamicalProcess</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Base class for dynamical system processes.</span>

<span class="sd">    A trajectory segment (x₀ → x₁ over dt) is a process.</span>

<span class="sd">    Mapping to Tlon axioms:</span>
<span class="sd">        - Duration = time interval of evolution</span>
<span class="sd">        - Sequential (;) = concatenate trajectories</span>
<span class="sd">        - Concurrent (∥) = parallel systems (with coupling)</span>
<span class="sd">        - Stability = fixed points / limit cycles</span>
<span class="sd">        - Interference = coupling between systems</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">_seq_compose</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s1">&#39;DynamicalProcess&#39;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;DynamicalProcess&#39;</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Sequential: evolve self, then continue from self&#39;s final state.&quot;&quot;&quot;</span>
        <span class="n">continued</span> <span class="o">=</span> <span class="n">other</span><span class="o">.</span><span class="n">_evolve</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">final_state</span><span class="p">,</span> <span class="n">other</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_make_process</span><span class="p">(</span><span class="n">TrajectorySegment</span><span class="p">(</span>
            <span class="n">initial_state</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">initial_state</span><span class="p">,</span>
            <span class="n">final_state</span><span class="o">=</span><span class="n">continued</span><span class="o">.</span><span class="n">final_state</span><span class="p">,</span>
            <span class="n">dt</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">duration</span> <span class="o">+</span> <span class="n">other</span><span class="o">.</span><span class="n">duration</span><span class="p">,</span>  <span class="c1"># Axiom B4</span>
        <span class="p">))</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">_conc_compose</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s1">&#39;DynamicalProcess&#39;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;DynamicalProcess&#39;</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Concurrent: run both systems in parallel.&quot;&quot;&quot;</span>
        <span class="n">max_dt</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">duration</span><span class="p">,</span> <span class="n">other</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span>  <span class="c1"># Axiom C5</span>
        <span class="c1"># Create product system...</span>
</code></pre></div>

<p>This pattern allows any ODE system to be wrapped as a Tlon process. The framework handles composition, stability checking, and equivalence. The subclass provides the vector field.</p>
<h2 id="simulations-seeing-tlon-in-action">Simulations: Seeing Tlön in Action</h2>
<p>Implementing dynamical systems and watching them demonstrate Tlön concepts has been where the framework proves itself. The Tlon framework provides three things that standard numerical integration does not: a consistent API for composition, automatic stability checking, and a vocabulary for classifying dynamical behavior.</p>
<h3 id="the-double-pendulum-transient-processes">The Double Pendulum: Transient Processes</h3>
<p>The double pendulum is the canonical example of chaos in classical mechanics (Strogatz, 2015). In Tlön terms, it demonstrates <strong>transient processes</strong> - trajectories that never return to themselves.</p>
<p><img alt="Double pendulum chaos comparison" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/chaos_comparison.png" /><br />
<em>Two double pendulums started with nearly identical initial conditions ($10^{-6}$ difference). Within seconds, they diverge completely. This is the signature of a transient process.</em></p>
<p>The implementation wraps the Lagrangian equations of motion in the Tlon Process interface:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">DoublePendulumProcess</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    A double pendulum trajectory as a Tlon Process.</span>

<span class="sd">    Tlon interpretation:</span>
<span class="sd">        - Process = trajectory segment (x₀ → x₁ over dt)</span>
<span class="sd">        - Duration = time elapsed</span>
<span class="sd">        - Sequential (;) = concatenate trajectories</span>
<span class="sd">        - Stability = fixed points (equilibria)</span>
<span class="sd">        - Equivalence = same final state (behavioral)</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">_derivatives</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">state</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Lagrangian equations: d/dt [θ1, θ2, ω1, ω2]&quot;&quot;&quot;</span>
        <span class="n">θ1</span><span class="p">,</span> <span class="n">θ2</span><span class="p">,</span> <span class="n">ω1</span><span class="p">,</span> <span class="n">ω2</span> <span class="o">=</span> <span class="n">state</span>
        <span class="n">Δθ</span> <span class="o">=</span> <span class="n">θ1</span> <span class="o">-</span> <span class="n">θ2</span>
        <span class="n">denom</span> <span class="o">=</span> <span class="mi">2</span> <span class="o">*</span> <span class="n">m1</span> <span class="o">+</span> <span class="n">m2</span> <span class="o">-</span> <span class="n">m2</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">cos</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="n">Δθ</span><span class="p">)</span>

        <span class="c1"># Angular accelerations from Euler-Lagrange equations</span>
        <span class="n">α1</span> <span class="o">=</span> <span class="p">(</span><span class="o">-</span><span class="n">g</span> <span class="o">*</span> <span class="p">(</span><span class="mi">2</span><span class="o">*</span><span class="n">m1</span> <span class="o">+</span> <span class="n">m2</span><span class="p">)</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="n">θ1</span><span class="p">)</span> <span class="o">-</span> <span class="n">m2</span><span class="o">*</span><span class="n">g</span><span class="o">*</span><span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="n">θ1</span> <span class="o">-</span> <span class="mi">2</span><span class="o">*</span><span class="n">θ2</span><span class="p">)</span>
              <span class="o">-</span> <span class="mi">2</span><span class="o">*</span><span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="n">Δθ</span><span class="p">)</span> <span class="o">*</span> <span class="n">m2</span> <span class="o">*</span> <span class="p">(</span><span class="n">ω2</span><span class="o">**</span><span class="mi">2</span><span class="o">*</span><span class="n">L2</span> <span class="o">+</span> <span class="n">ω1</span><span class="o">**</span><span class="mi">2</span><span class="o">*</span><span class="n">L1</span><span class="o">*</span><span class="n">np</span><span class="o">.</span><span class="n">cos</span><span class="p">(</span><span class="n">Δθ</span><span class="p">)))</span> <span class="o">/</span> <span class="p">(</span><span class="n">L1</span> <span class="o">*</span> <span class="n">denom</span><span class="p">)</span>
        <span class="n">α2</span> <span class="o">=</span> <span class="p">(</span><span class="mi">2</span><span class="o">*</span><span class="n">np</span><span class="o">.</span><span class="n">sin</span><span class="p">(</span><span class="n">Δθ</span><span class="p">)</span> <span class="o">*</span> <span class="p">(</span><span class="n">ω1</span><span class="o">**</span><span class="mi">2</span><span class="o">*</span><span class="n">L1</span><span class="o">*</span><span class="p">(</span><span class="n">m1</span><span class="o">+</span><span class="n">m2</span><span class="p">)</span> <span class="o">+</span> <span class="n">g</span><span class="o">*</span><span class="p">(</span><span class="n">m1</span><span class="o">+</span><span class="n">m2</span><span class="p">)</span><span class="o">*</span><span class="n">np</span><span class="o">.</span><span class="n">cos</span><span class="p">(</span><span class="n">θ1</span><span class="p">)</span>
              <span class="o">+</span> <span class="n">ω2</span><span class="o">**</span><span class="mi">2</span><span class="o">*</span><span class="n">L2</span><span class="o">*</span><span class="n">m2</span><span class="o">*</span><span class="n">np</span><span class="o">.</span><span class="n">cos</span><span class="p">(</span><span class="n">Δθ</span><span class="p">)))</span> <span class="o">/</span> <span class="p">(</span><span class="n">L2</span> <span class="o">*</span> <span class="n">denom</span><span class="p">)</span>

        <span class="k">return</span> <span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">([</span><span class="n">ω1</span><span class="p">,</span> <span class="n">ω2</span><span class="p">,</span> <span class="n">α1</span><span class="p">,</span> <span class="n">α2</span><span class="p">])</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">_seq_compose</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s1">&#39;DoublePendulumProcess&#39;</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;DoublePendulumProcess&#39;</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Sequential: continue evolution from self.final_state.&quot;&quot;&quot;</span>
        <span class="n">final</span><span class="p">,</span> <span class="n">traj</span><span class="p">,</span> <span class="n">times</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_integrate_rk4</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_final</span><span class="p">,</span> <span class="n">other</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">DoublePendulumProcess</span><span class="p">(</span>
            <span class="n">initial_state</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">_initial</span><span class="p">,</span>
            <span class="n">final_state</span><span class="o">=</span><span class="n">final</span><span class="p">,</span>
            <span class="n">dt</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">_dt</span> <span class="o">+</span> <span class="n">other</span><span class="o">.</span><span class="n">duration</span><span class="p">,</span>  <span class="c1"># Axiom B4: durations add</span>
        <span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">equiv</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="s1">&#39;Process&#39;</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1e-6</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Behavioral equivalence: same final state.&quot;&quot;&quot;</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">DoublePendulumProcess</span><span class="p">):</span>
            <span class="k">return</span> <span class="kc">False</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_final</span><span class="o">.</span><span class="n">distance_to</span><span class="p">(</span><span class="n">other</span><span class="o">.</span><span class="n">_final</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">tolerance</span>
</code></pre></div>

<p>The Process interface forces you to think about what "composing" trajectories means: sequential composition concatenates them, and the duration axiom (B4) ensures time accounting is correct. The stability methods provide immediate feedback about the system's character:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Create and evolve from a chaotic initial condition</span>
<span class="n">initial</span> <span class="o">=</span> <span class="n">PendulumState</span><span class="p">(</span><span class="n">theta1</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">pi</span><span class="o">/</span><span class="mi">2</span><span class="p">,</span> <span class="n">theta2</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">pi</span><span class="o">/</span><span class="mi">2</span><span class="p">,</span> <span class="n">omega1</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">omega2</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
<span class="n">proc</span> <span class="o">=</span> <span class="n">DoublePendulumProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">initial</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">10.0</span><span class="p">)</span>

<span class="c1"># The framework answers: is this stable?</span>
<span class="nb">print</span><span class="p">(</span><span class="n">proc</span><span class="o">.</span><span class="n">is_stable</span><span class="p">())</span>           <span class="c1"># False - never returns to itself</span>
<span class="nb">print</span><span class="p">(</span><span class="n">proc</span><span class="o">.</span><span class="n">purity_class</span><span class="p">())</span>        <span class="c1"># &quot;transient&quot;</span>

<span class="c1"># Sequential composition: what happens if we run it twice?</span>
<span class="n">proc_twice</span> <span class="o">=</span> <span class="n">proc</span> <span class="o">@</span> <span class="n">proc</span>  <span class="c1"># π ; π</span>
<span class="nb">print</span><span class="p">(</span><span class="n">proc_twice</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="n">proc</span><span class="p">))</span>     <span class="c1"># False - chaos means π;π ≉ π</span>

<span class="c1"># Perturb and compare: sensitivity to initial conditions</span>
<span class="n">perturbed</span> <span class="o">=</span> <span class="n">PendulumState</span><span class="p">(</span><span class="n">theta1</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">pi</span><span class="o">/</span><span class="mi">2</span> <span class="o">+</span> <span class="mf">1e-6</span><span class="p">,</span> <span class="n">theta2</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">pi</span><span class="o">/</span><span class="mi">2</span><span class="p">,</span> <span class="n">omega1</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span> <span class="n">omega2</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>
<span class="n">proc_perturbed</span> <span class="o">=</span> <span class="n">DoublePendulumProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">perturbed</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">10.0</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Divergence: </span><span class="si">{</span><span class="n">proc</span><span class="o">.</span><span class="n">_final</span><span class="o">.</span><span class="n">distance_to</span><span class="p">(</span><span class="n">proc_perturbed</span><span class="o">.</span><span class="n">_final</span><span class="p">)</span><span class="si">:</span><span class="s2">.2f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># Large!</span>
</code></pre></div>

<p>Tlon mathematics makes the <em>transience</em> of the double pendulum operational. You do not just observe that trajectories diverge; you ask the algebraic question "is $\pi ; \pi \approx \pi$?" and get a definitive answer. The double pendulum fails the stability axiom, and this failure is the formal definition of chaos in the Tlon framework.</p>
<h3 id="the-n-body-problem-stability-is-special">The N-Body Problem: Stability is Special</h3>
<p>This simulation provides the purest illustration of the Tlön principle: <strong>stability is special, not generic</strong>. The N-body problem is one of the oldest in classical mechanics; Poincaré's work on it (Poincaré, 1890) in the early 1900s founded the entire field of dynamical systems.</p>
<p><img alt="N-body orbital dynamics" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/nbody_demo.png" /><br />
<em>The transition from order to chaos: two-body orbits are stable and predictable, while three or more bodies exhibit chaotic trajectories.</em></p>
<p>The Tlon framework makes the comparison between $N=2$ and $N=3$ crisp:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Two-body system: circular orbit</span>
<span class="n">two_body</span> <span class="o">=</span> <span class="n">NBodyState</span><span class="o">.</span><span class="n">two_body_circular</span><span class="p">(</span><span class="n">m1</span><span class="o">=</span><span class="mf">1.0</span><span class="p">,</span> <span class="n">m2</span><span class="o">=</span><span class="mf">1.0</span><span class="p">,</span> <span class="n">separation</span><span class="o">=</span><span class="mf">1.0</span><span class="p">)</span>
<span class="n">proc_2</span> <span class="o">=</span> <span class="n">NBodyProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">two_body</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">15.0</span><span class="p">)</span>

<span class="c1"># Three-body system: Pythagorean configuration (3-4-5 triangle)</span>
<span class="n">three_body</span> <span class="o">=</span> <span class="n">NBodyState</span><span class="o">.</span><span class="n">three_body_pythagorean</span><span class="p">()</span>
<span class="n">proc_3</span> <span class="o">=</span> <span class="n">NBodyProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">three_body</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">15.0</span><span class="p">)</span>

<span class="c1"># The framework provides stability analysis methods</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Two-body Lyapunov estimate:   </span><span class="si">{</span><span class="n">proc_2</span><span class="o">.</span><span class="n">lyapunov_estimate</span><span class="p">()</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>   <span class="c1"># ~0.3</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Three-body Lyapunov estimate: </span><span class="si">{</span><span class="n">proc_3</span><span class="o">.</span><span class="n">lyapunov_estimate</span><span class="p">()</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>   <span class="c1"># ~1.1</span>

<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Two-body periodic stable:   </span><span class="si">{</span><span class="n">proc_2</span><span class="o">.</span><span class="n">is_periodic_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># True</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Three-body periodic stable: </span><span class="si">{</span><span class="n">proc_3</span><span class="o">.</span><span class="n">is_periodic_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># False</span>
</code></pre></div>

<p><strong>Two-body problem ($N=2$)</strong>: Integrable, stable. The system reduces to Kepler orbits with two conserved quantities (energy, angular momentum). Trajectories form clean, closed paths. Lyapunov exponent $\sim 0.3$.</p>
<p><strong>Three-body problem ($N \geq 3$)</strong>: Generically chaotic, transient. Not enough conserved quantities to constrain the motion. Trajectories are erratic and unpredictable. Lyapunov exponent $\sim 1.1$ (3-4x more chaotic).</p>
<p>There are exceptions - the famous figure-8 orbit (Moore, 1993; Chenciner &amp; Montgomery, 2000) shows that stability <em>is</em> possible for $N=3$, but only for measure-zero sets of initial conditions.</p>
<h4 id="the-figure-8-orbit-stability-emerging-from-chaos">The Figure-8 Orbit: Stability Emerging from Chaos</h4>
<p>The figure-8 orbit is remarkable: three equal masses chase each other around a figure-8 shaped path, each body following exactly the same trajectory but phase-shifted in time. It was discovered by Cris Moore (Moore, 1993) after searching systematically through initial conditions.</p>
<p><img alt="Three-body showcase" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/three_body_showcase.png" /><br />
<em>Six different three-body configurations: the figure-8 (top left) is the most famous stable choreography. Butterfly and Moth orbits (top middle, right) are more complex but still periodic. The Pythagorean configuration (bottom right) shows generic chaotic behavior.</em></p>
<p>Finding and verifying the figure-8:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Create the figure-8 initial conditions</span>
<span class="c1"># Three equal masses at specific positions and velocities</span>
<span class="n">figure8</span> <span class="o">=</span> <span class="n">NBodyState</span><span class="o">.</span><span class="n">three_body_figure_eight</span><span class="p">()</span>

<span class="nb">print</span><span class="p">(</span><span class="s2">&quot;Initial configuration:&quot;</span><span class="p">)</span>
<span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">body</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">figure8</span><span class="o">.</span><span class="n">bodies</span><span class="p">):</span>
    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  Body </span><span class="si">{</span><span class="n">i</span><span class="o">+</span><span class="mi">1</span><span class="si">}</span><span class="s2">: pos=(</span><span class="si">{</span><span class="n">body</span><span class="o">.</span><span class="n">x</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">, </span><span class="si">{</span><span class="n">body</span><span class="o">.</span><span class="n">y</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">), &quot;</span>
          <span class="sa">f</span><span class="s2">&quot;vel=(</span><span class="si">{</span><span class="n">body</span><span class="o">.</span><span class="n">vx</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">, </span><span class="si">{</span><span class="n">body</span><span class="o">.</span><span class="n">vy</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">)&quot;</span><span class="p">)</span>

<span class="c1"># Initial configuration:</span>
<span class="c1">#   Body 1: pos=(-0.9700, 0.2431), vel=(0.4662, 0.4324)</span>
<span class="c1">#   Body 2: pos=(0.9700, -0.2431), vel=(0.4662, 0.4324)</span>
<span class="c1">#   Body 3: pos=(0.0000, 0.0000), vel=(-0.9324, -0.8647)</span>

<span class="c1"># Evolve for exactly one period</span>
<span class="n">period</span> <span class="o">=</span> <span class="mf">6.3259</span>  <span class="c1"># The figure-8 period</span>
<span class="n">proc</span> <span class="o">=</span> <span class="n">NBodyProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">figure8</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="n">period</span><span class="p">,</span> <span class="n">steps</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>

<span class="c1"># Check if it returned to the initial state</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="se">\n</span><span class="s2">After one period:&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  Energy drift: </span><span class="si">{</span><span class="n">proc</span><span class="o">.</span><span class="n">energy_drift</span><span class="p">()</span><span class="si">:</span><span class="s2">.2e</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># ~1e-10</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  Lyapunov estimate: </span><span class="si">{</span><span class="n">proc</span><span class="o">.</span><span class="n">lyapunov_estimate</span><span class="p">()</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># ~0.1</span>

<span class="c1"># The key test: is π;π ≈ π ?</span>
<span class="n">proc_twice</span> <span class="o">=</span> <span class="n">proc</span> <span class="o">@</span> <span class="n">proc</span>  <span class="c1"># Two periods</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  Two periods equiv to one: </span><span class="si">{</span><span class="n">proc_twice</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="n">proc</span><span class="p">,</span><span class="w"> </span><span class="n">tolerance</span><span class="o">=</span><span class="mf">0.1</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># True!</span>

<span class="c1"># This IS a Tlon stable process</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  is_periodic_stable(): </span><span class="si">{</span><span class="n">proc</span><span class="o">.</span><span class="n">is_periodic_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># True</span>
</code></pre></div>

<p>The figure-8 satisfies the stability criterion $\pi ; \pi \approx \pi$ because one complete orbit returns each body to its starting position. Stability in Tlon means precisely this: a process, when repeated, is equivalent to performing it once.</p>
<p>Generic three-body motion differs entirely. Start from almost any other initial condition:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Generic three-body: Pythagorean configuration (3-4-5 triangle)</span>
<span class="n">pythagorean</span> <span class="o">=</span> <span class="n">NBodyState</span><span class="o">.</span><span class="n">three_body_pythagorean</span><span class="p">()</span>
<span class="n">proc_chaos</span> <span class="o">=</span> <span class="n">NBodyProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">pythagorean</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">15.0</span><span class="p">,</span> <span class="n">steps</span><span class="o">=</span><span class="mi">1500</span><span class="p">)</span>

<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Pythagorean three-body:&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  Lyapunov estimate: </span><span class="si">{</span><span class="n">proc_chaos</span><span class="o">.</span><span class="n">lyapunov_estimate</span><span class="p">()</span><span class="si">:</span><span class="s2">.4f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># ~1.1</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  is_periodic_stable(): </span><span class="si">{</span><span class="n">proc_chaos</span><span class="o">.</span><span class="n">is_periodic_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># False</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;  purity_class(): </span><span class="si">{</span><span class="n">proc_chaos</span><span class="o">.</span><span class="n">purity_class</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># &quot;transient&quot;</span>
</code></pre></div>

<p>The framework gives you a vocabulary: the figure-8 is "sequentially stable"; the Pythagorean configuration is "transient". Both are algebraically defined properties.</p>
<p>The same <code>is_stable()</code> and <code>lyapunov_estimate()</code> methods work for pendulums, N-body systems, and predator-prey dynamics. This consistency reveals that stability is a cross-cutting concern: the <em>question</em> is the same even when the physics is different.</p>
<h3 id="lotka-volterra-resonance">Lotka-Volterra: Resonance</h3>
<p>The predator-prey system demonstrates <strong>resonance</strong>.</p>
<p>Neither prey nor predator is individually stable:<br />
- Prey without predators: exponential growth (unstable)<br />
- Predators without prey: exponential decay (unstable)</p>
<p>But together, they <strong>resonate</strong> into a stable oscillating pattern - a limit cycle. The oscillation IS the stable object.</p>
<p><img alt="Lotka-Volterra limit cycle dynamics" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/lotka_volterra_demo.png" /><br />
<em>The Lotka-Volterra limit cycle: prey and predator populations oscillate in a closed phase-space trajectory. The pattern itself is the stable "object".</em></p>
<p>
<script type="math/tex; mode=display">\text{Resonant}(\pi, \rho) \Leftrightarrow \neg\text{Stable}(\pi) \land \neg\text{Stable}(\rho) \land \text{Stable}(\pi \parallel \rho)</script>
</p>
<p>The implementation makes resonance testable:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Create and evolve the predator-prey system</span>
<span class="n">initial</span> <span class="o">=</span> <span class="n">EcologyState</span><span class="p">(</span><span class="n">prey</span><span class="o">=</span><span class="mf">20.0</span><span class="p">,</span> <span class="n">predator</span><span class="o">=</span><span class="mf">10.0</span><span class="p">)</span>
<span class="n">proc</span> <span class="o">=</span> <span class="n">LotkaVolterraProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">initial</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">40.0</span><span class="p">,</span> <span class="n">params</span><span class="o">=</span><span class="n">params</span><span class="p">)</span>

<span class="c1"># Find the period of the limit cycle</span>
<span class="n">period</span> <span class="o">=</span> <span class="n">proc</span><span class="o">.</span><span class="n">find_period</span><span class="p">(</span><span class="n">tolerance</span><span class="o">=</span><span class="mf">0.5</span><span class="p">)</span>  <span class="c1"># ~6.3 time units</span>

<span class="c1"># Check periodic stability: is one cycle stable?</span>
<span class="n">cycle</span> <span class="o">=</span> <span class="n">LotkaVolterraProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">initial</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="n">period</span><span class="p">)</span>
<span class="n">is_stable</span> <span class="o">=</span> <span class="n">cycle</span><span class="o">.</span><span class="n">is_periodic_stable</span><span class="p">(</span><span class="n">tolerance</span><span class="o">=</span><span class="mf">0.5</span><span class="p">)</span>  <span class="c1"># True!</span>
</code></pre></div>

<p>After one period $T$ ($\sim 6.3$ time units with default parameters), the system returns to its initial state. This satisfies $\pi ; \pi \approx \pi$ where $\pi$ is one complete oscillation cycle.</p>
<p>The stable "object" is the pattern of interaction, not either population alone. Emergence in Tlön works through precisely this mechanism: stability arising from the interplay of unstable components.</p>
<h3 id="kuramoto-oscillators-phase-transitions-in-resonance">Kuramoto Oscillators: Phase Transitions in Resonance</h3>
<p>The Kuramoto model of coupled oscillators (Kuramoto, 1984; Strogatz, 2000) demonstrates Tlon's resonance concept directly. Each oscillator has its own natural frequency $\omega_i$, and they interact through sinusoidal coupling:</p>
<p>
<script type="math/tex; mode=display">\frac{d\theta_i}{dt} = \omega_i + \frac{K}{N} \sum_j \sin(\theta_j - \theta_i)</script>
</p>
<p>When coupling $K$ is weak, each oscillator runs at its own pace and the phases are scattered. When $K$ exceeds a critical threshold $K_c$, the oscillators spontaneously synchronize and their phases align. This is a phase transition, and in Tlon terms, it is the birth of a stable process from unstable components.</p>
<p><img alt="Kuramoto phase transition" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/kuramoto_phase_transition.png" /><br />
<em>The Kuramoto phase transition: below critical coupling, oscillators are desynchronized (r ≈ 0). Above Kc, they spontaneously synchronize (r → 1). The order parameter r measures the degree of resonance.</em></p>
<p>The order parameter $r$ measures synchronization: $r \approx 0$ means random phases, $r \approx 1$ means all oscillators are aligned. The transition is sharp. Once coupling exceeds the threshold, order emerges spontaneously.</p>
<p>In the Tlon framework:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">KuramotoProcess</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Kuramoto model as a Tlon Process.</span>

<span class="sd">    Definition D13: Resonant(π, ρ) ⟺ Stable(π ∥ ρ)</span>

<span class="sd">    When oscillators synchronize, their concurrent composition</span>
<span class="sd">    becomes stable. This is resonance.</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">is_stable</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">0.1</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Stable when oscillators are synchronized (r ≈ 1).&quot;&quot;&quot;</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_final</span><span class="o">.</span><span class="n">is_synchronized</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">is_resonant</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;For Kuramoto, resonance IS synchronization!&quot;&quot;&quot;</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">is_stable</span><span class="p">()</span>

<span class="c1"># Running the simulation</span>
<span class="n">initial</span> <span class="o">=</span> <span class="n">KuramotoState</span><span class="o">.</span><span class="n">random</span><span class="p">(</span><span class="n">N</span><span class="o">=</span><span class="mi">100</span><span class="p">,</span> <span class="n">coupling</span><span class="o">=</span><span class="mf">2.0</span><span class="p">,</span> <span class="n">freq_std</span><span class="o">=</span><span class="mf">1.0</span><span class="p">)</span>
<span class="n">proc</span> <span class="o">=</span> <span class="n">KuramotoProcess</span><span class="o">.</span><span class="n">evolve</span><span class="p">(</span><span class="n">initial</span><span class="p">,</span> <span class="n">dt</span><span class="o">=</span><span class="mf">20.0</span><span class="p">)</span>

<span class="nb">print</span><span class="p">(</span><span class="n">proc</span><span class="p">)</span>
<span class="c1"># KuramotoProcess(N=100, K=2.00, r=0.95, SYNCHRONIZED (resonant))</span>
</code></pre></div>

<p>Individual oscillators are not stable. Each runs at a different frequency, drifting apart. But the <em>collection</em> of oscillators, running concurrently with sufficient coupling, becomes stable. The stability is emergent, existing only in the interaction. Tlon's Definition D13 captures this: resonance creates stability from unstable components.</p>
<h3 id="how-tlon-abstractions-aid-model-creation">How Tlon Abstractions Aid Model Creation</h3>
<p>Across these four systems, the Tlon framework provides something beyond standard numerical integration:</p>
<p><strong>1. A Consistent API for Composition</strong></p>
<p>Every dynamical system implements the same <code>Process</code> interface. Sequential composition (<code>@</code>) concatenates trajectories; concurrent composition (<code>|</code>) runs systems in parallel. This means you can write generic code that works across systems:</p>
<div class="codehilite"><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">analyze_sensitivity</span><span class="p">(</span><span class="n">proc</span><span class="p">:</span> <span class="n">Process</span><span class="p">,</span> <span class="n">perturbation</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1e-6</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Generic sensitivity analysis - works for ANY Tlon process.&quot;&quot;&quot;</span>
    <span class="n">perturbed</span> <span class="o">=</span> <span class="n">proc</span><span class="o">.</span><span class="n">perturb</span><span class="p">(</span><span class="n">perturbation</span><span class="p">)</span>
    <span class="n">twice</span> <span class="o">=</span> <span class="n">proc</span> <span class="o">@</span> <span class="n">proc</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">twice</span> <span class="o">@</span> <span class="n">perturbed</span><span class="p">)</span><span class="o">.</span><span class="n">distance_from</span><span class="p">(</span><span class="n">twice</span> <span class="o">@</span> <span class="n">proc</span><span class="p">)</span>
</code></pre></div>

<p><strong>2. Automatic Stability Classification</strong></p>
<p>The base <code>Process</code> class provides <code>is_stable()</code>, <code>is_self_sustaining()</code>, <code>is_pure()</code>, and <code>purity_class()</code> methods. These work automatically for any system that implements <code>equiv()</code>. You get stability analysis for free:</p>
<div class="codehilite"><pre><span></span><code><span class="k">for</span> <span class="n">system</span> <span class="ow">in</span> <span class="p">[</span><span class="n">pendulum</span><span class="p">,</span> <span class="n">nbody_2</span><span class="p">,</span> <span class="n">nbody_3</span><span class="p">,</span> <span class="n">lotka_volterra</span><span class="p">,</span> <span class="n">kuramoto</span><span class="p">]:</span>
    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="si">{</span><span class="n">system</span><span class="o">.</span><span class="vm">__class__</span><span class="o">.</span><span class="vm">__name__</span><span class="si">}</span><span class="s2">: </span><span class="si">{</span><span class="n">system</span><span class="o">.</span><span class="n">purity_class</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>

<span class="c1"># DoublePendulumProcess: transient</span>
<span class="c1"># NBodyProcess: sequentially_stable  (N=2)</span>
<span class="c1"># NBodyProcess: transient            (N=3)</span>
<span class="c1"># LotkaVolterraProcess: sequentially_stable</span>
<span class="c1"># KuramotoProcess: sequentially_stable (if synchronized)</span>
</code></pre></div>

<p><strong>3. A Vocabulary for Dynamical Behavior</strong></p>
<p>The purity spectrum (transient, sequentially stable, concurrently stable, pure) provides a classification scheme that applies uniformly. When you say "this process is transient," it means something precise: $\pi ; \pi \not\approx \pi$. When you say "these processes resonate," it means $\text{Stable}(\pi \parallel \rho)$ even though neither is stable alone.</p>
<p><strong>4. Forced Clarity About Equivalence</strong></p>
<p>Implementing <code>equiv()</code> forces you to decide what "same" means for your system. For pendulums, it's same final state. For Kuramoto, it's same synchronization status. This decision is often glossed over in numerical work but matters for understanding what the simulation actually computes.</p>
<p>The framework doesn't make simulations faster or more accurate. What it does is provide a conceptual scaffolding that clarifies what questions you're asking and ensures consistency across different systems.</p>
<h2 id="beyond-physics-sorting-machine-learning-and-deep-learning">Beyond Physics: Sorting, Machine Learning, and Deep Learning</h2>
<p>The Tlon framework extends far beyond dynamical systems. Any process that evolves toward stability can be modeled. Here are three examples from computer science and machine learning.</p>
<h3 id="sorting-algorithms-all-roads-lead-to-stability">Sorting Algorithms: All Roads Lead to Stability</h3>
<p>Sorting fits the Tlon framework naturally: the sorted array is the unique stable state, and every sorting algorithm is a different path to the same attractor.</p>
<p><img alt="Sorting algorithms as Tlon processes" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/sorting_demo.png" /><br />
<em>Bubble sort, quicksort, and merge sort: different dynamics, same stable attractor. The trajectory shows "sortedness" (1.0 = sorted) over time. All algorithms reach stability; they differ only in how fast.</em></p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">tlon.models.algorithms.sorting</span><span class="w"> </span><span class="kn">import</span> <span class="n">BubbleSortProcess</span><span class="p">,</span> <span class="n">QuickSortProcess</span><span class="p">,</span> <span class="n">MergeSortProcess</span>

<span class="c1"># Start from a random permutation</span>
<span class="n">initial</span> <span class="o">=</span> <span class="n">ArrayState</span><span class="o">.</span><span class="n">random</span><span class="p">(</span><span class="n">n</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">seed</span><span class="o">=</span><span class="mi">42</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Initial: </span><span class="si">{</span><span class="n">initial</span><span class="o">.</span><span class="n">elements</span><span class="p">[:</span><span class="mi">5</span><span class="p">]</span><span class="si">}</span><span class="s2">... sortedness=</span><span class="si">{</span><span class="n">initial</span><span class="o">.</span><span class="n">sortedness</span><span class="p">()</span><span class="si">:</span><span class="s2">.2f</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
<span class="c1"># Initial: (14, 7, 3, 19, 2)... sortedness=0.47</span>

<span class="c1"># Three different algorithms</span>
<span class="n">bubble</span> <span class="o">=</span> <span class="n">BubbleSortProcess</span><span class="o">.</span><span class="n">sort</span><span class="p">(</span><span class="n">initial</span><span class="p">)</span>
<span class="n">quick</span> <span class="o">=</span> <span class="n">QuickSortProcess</span><span class="o">.</span><span class="n">sort</span><span class="p">(</span><span class="n">initial</span><span class="p">)</span>
<span class="n">merge</span> <span class="o">=</span> <span class="n">MergeSortProcess</span><span class="o">.</span><span class="n">sort</span><span class="p">(</span><span class="n">initial</span><span class="p">)</span>

<span class="c1"># All reach the same stable state</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="se">\n</span><span class="s2">Bubble: ops=</span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">bubble</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span><span class="si">}</span><span class="s2">, is_stable=</span><span class="si">{</span><span class="n">bubble</span><span class="o">.</span><span class="n">is_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Quick:  ops=</span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">quick</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span><span class="si">}</span><span class="s2">, is_stable=</span><span class="si">{</span><span class="n">quick</span><span class="o">.</span><span class="n">is_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Merge:  ops=</span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">merge</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span><span class="si">}</span><span class="s2">, is_stable=</span><span class="si">{</span><span class="n">merge</span><span class="o">.</span><span class="n">is_stable</span><span class="p">()</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
<span class="c1"># Bubble: ops=380, is_stable=True</span>
<span class="c1"># Quick:  ops=89, is_stable=True</span>
<span class="c1"># Merge:  ops=132, is_stable=True</span>

<span class="c1"># The key insight: all are EQUIVALENT in Tlon</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;</span><span class="se">\n</span><span class="s2">Bubble equiv Quick: </span><span class="si">{</span><span class="n">bubble</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="n">quick</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># True!</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Quick equiv Merge:  </span><span class="si">{</span><span class="n">quick</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="n">merge</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>    <span class="c1"># True!</span>
</code></pre></div>

<p>The stability criterion applies directly to sorting: $\pi ; \pi \approx \pi$ because sorting an already-sorted array is a no-op. Every sorting algorithm satisfies this once it completes. The algorithms differ in <em>duration</em> (number of operations), but they are equivalent processes in the Tlon sense: they produce the same sorted output.</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Sorting a sorted array: the identity process</span>
<span class="n">sorted_array</span> <span class="o">=</span> <span class="n">ArrayState</span><span class="o">.</span><span class="n">sorted</span><span class="p">(</span><span class="n">n</span><span class="o">=</span><span class="mi">20</span><span class="p">)</span>
<span class="n">bubble_on_sorted</span> <span class="o">=</span> <span class="n">BubbleSortProcess</span><span class="o">.</span><span class="n">sort</span><span class="p">(</span><span class="n">sorted_array</span><span class="p">)</span>

<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Sorting already-sorted: ops=</span><span class="si">{</span><span class="nb">int</span><span class="p">(</span><span class="n">bubble_on_sorted</span><span class="o">.</span><span class="n">duration</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
<span class="c1"># Sorting already-sorted: ops=19  (just n-1 comparisons, no swaps)</span>

<span class="c1"># This is why sorted is stable: π;π ≈ π</span>
<span class="n">twice</span> <span class="o">=</span> <span class="n">bubble</span> <span class="o">@</span> <span class="n">bubble</span>  <span class="c1"># Sort, then sort again</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;bubble @ bubble ≈ bubble: </span><span class="si">{</span><span class="n">twice</span><span class="o">.</span><span class="n">equiv</span><span class="p">(</span><span class="n">bubble</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>  <span class="c1"># True</span>
</code></pre></div>

<h3 id="machine-learning-data-as-concurrent-constraints">Machine Learning: Data as Concurrent Constraints</h3>
<p>Machine learning can be reframed in Tlon terms: each data point is a <em>constraint process</em> that "pulls" the model toward fitting it, and training is finding the stable interference equilibrium.</p>
<p><img alt="OLS regression as Tlon process" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/ols_demo.png" /><br />
<em>Linear regression as emergent stability: each data point is a constraint process. The model finds the stable configuration where all constraints are balanced.</em></p>
<h4 id="the-tlonmodelsml-module-structure">The <code>tlon.models.ml</code> Module Structure</h4>
<p>The ML module maps machine learning concepts to Tlon processes:</p>
<div class="codehilite"><pre><span></span><code>tlon/models/ml/
├── process.py       # ConstraintProcess, DatasetProcess, ModelProcess, ModelState
├── ols.py           # OLSRegression, TlonOLSProcess
├── logistic.py      # LogisticRegression, TlonLogisticProcess
└── decision_tree.py # DecisionTreeProcess
</code></pre></div>

<p>The fundamental abstraction is <code>ConstraintProcess</code>:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">ConstraintProcess</span><span class="p">(</span><span class="n">MLProcess</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    A data point as a constraint process.</span>

<span class="sd">    Each data point (x, y) becomes a process that:</span>
<span class="sd">    - Accepts candidate model states</span>
<span class="sd">    - Produces interference (gradient) proportional to violation</span>
<span class="sd">    - &quot;Insists&quot; on the relationship y ≈ f(x; θ)</span>

<span class="sd">    The constraint process is not the data. It is what the data DOES</span>
<span class="sd">    when it encounters a hypothesis. It&#39;s the act of constraining.</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">,</span> <span class="n">y</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span> <span class="n">weight</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1.0</span><span class="p">,</span> <span class="n">loss_fn</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s1">&#39;mse&#39;</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_x</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">asarray</span><span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">float64</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_y</span> <span class="o">=</span> <span class="nb">float</span><span class="p">(</span><span class="n">y</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_weight</span> <span class="o">=</span> <span class="n">weight</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_duration</span> <span class="o">=</span> <span class="n">weight</span>  <span class="c1"># Duration = constraint strength</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">gradient</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">at_state</span><span class="p">:</span> <span class="n">ModelState</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">ModelState</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">        This is the interference this constraint produces:</span>
<span class="sd">        the &quot;pull&quot; it exerts on the model parameters.</span>
<span class="sd">        &quot;&quot;&quot;</span>
        <span class="n">pred</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">dot</span><span class="p">(</span><span class="n">at_state</span><span class="o">.</span><span class="n">weights</span><span class="p">,</span> <span class="bp">self</span><span class="o">.</span><span class="n">_x</span><span class="p">)</span> <span class="o">+</span> <span class="n">at_state</span><span class="o">.</span><span class="n">bias</span>

        <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">_loss_fn</span> <span class="o">==</span> <span class="s1">&#39;mse&#39;</span><span class="p">:</span>
            <span class="c1"># d/dθ [(y - pred)²] = -2(y - pred) * x</span>
            <span class="n">error</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_y</span> <span class="o">-</span> <span class="n">pred</span>
            <span class="n">grad_w</span> <span class="o">=</span> <span class="o">-</span><span class="mf">2.0</span> <span class="o">*</span> <span class="bp">self</span><span class="o">.</span><span class="n">_weight</span> <span class="o">*</span> <span class="n">error</span> <span class="o">*</span> <span class="bp">self</span><span class="o">.</span><span class="n">_x</span>
            <span class="n">grad_b</span> <span class="o">=</span> <span class="o">-</span><span class="mf">2.0</span> <span class="o">*</span> <span class="bp">self</span><span class="o">.</span><span class="n">_weight</span> <span class="o">*</span> <span class="n">error</span>

        <span class="k">return</span> <span class="n">ModelState</span><span class="p">(</span><span class="n">weights</span><span class="o">=</span><span class="n">grad_w</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="n">grad_b</span><span class="p">)</span>
</code></pre></div>

<p>The gradient <em>is</em> the interference. Each data point pulls proportional to how badly the model violates the constraint.</p>
<p>A <code>DatasetProcess</code> is the concurrent composition of constraints:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">DatasetProcess</span><span class="p">(</span><span class="n">MLProcess</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Γ = γ₁ ∥ γ₂ ∥ ... ∥ γₙ</span>

<span class="sd">    All data points &quot;voting&quot; simultaneously. The gradient is the sum</span>
<span class="sd">    of all individual constraint gradients, concurrent interference.</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">gradient</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">at_state</span><span class="p">:</span> <span class="n">ModelState</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">ModelState</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Total gradient = sum of all individual interferences.&quot;&quot;&quot;</span>
        <span class="n">total_grad</span> <span class="o">=</span> <span class="n">ModelState</span><span class="o">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">at_state</span><span class="o">.</span><span class="n">n_features</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">constraint</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">_constraints</span><span class="p">:</span>
            <span class="n">grad</span> <span class="o">=</span> <span class="n">constraint</span><span class="o">.</span><span class="n">gradient</span><span class="p">(</span><span class="n">at_state</span><span class="p">)</span>
            <span class="n">total_grad</span> <span class="o">=</span> <span class="n">total_grad</span> <span class="o">+</span> <span class="n">grad</span>
        <span class="k">return</span> <span class="n">total_grad</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">_conc_compose</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="n">Process</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Process</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Concurrent composition: merge datasets.&quot;&quot;&quot;</span>
        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">ConstraintProcess</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">DatasetProcess</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_constraints</span> <span class="o">+</span> <span class="p">[</span><span class="n">other</span><span class="p">])</span>
        <span class="k">elif</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">DatasetProcess</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">DatasetProcess</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_constraints</span> <span class="o">+</span> <span class="n">other</span><span class="o">.</span><span class="n">_constraints</span><span class="p">)</span>
        <span class="k">return</span> <span class="bp">self</span>
</code></pre></div>

<p>Using the primitives:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">tlon.models.ml</span><span class="w"> </span><span class="kn">import</span> <span class="n">ConstraintProcess</span><span class="p">,</span> <span class="n">DatasetProcess</span><span class="p">,</span> <span class="n">ModelState</span>

<span class="c1"># Each data point becomes a constraint</span>
<span class="n">constraints</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">ConstraintProcess</span><span class="p">(</span><span class="n">x</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">([</span><span class="mf">1.0</span><span class="p">,</span> <span class="mf">2.0</span><span class="p">]),</span> <span class="n">y</span><span class="o">=</span><span class="mf">5.0</span><span class="p">),</span>
    <span class="n">ConstraintProcess</span><span class="p">(</span><span class="n">x</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">([</span><span class="mf">2.0</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">]),</span> <span class="n">y</span><span class="o">=</span><span class="mf">4.0</span><span class="p">),</span>
    <span class="n">ConstraintProcess</span><span class="p">(</span><span class="n">x</span><span class="o">=</span><span class="n">np</span><span class="o">.</span><span class="n">array</span><span class="p">([</span><span class="mf">3.0</span><span class="p">,</span> <span class="mf">3.0</span><span class="p">]),</span> <span class="n">y</span><span class="o">=</span><span class="mf">9.0</span><span class="p">),</span>
<span class="p">]</span>

<span class="c1"># Dataset = concurrent composition</span>
<span class="n">dataset</span> <span class="o">=</span> <span class="n">constraints</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">|</span> <span class="n">constraints</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">|</span> <span class="n">constraints</span><span class="p">[</span><span class="mi">2</span><span class="p">]</span>

<span class="c1"># Compute total interference</span>
<span class="n">theta</span> <span class="o">=</span> <span class="n">ModelState</span><span class="o">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">n_features</span><span class="o">=</span><span class="mi">2</span><span class="p">)</span>
<span class="n">grad</span> <span class="o">=</span> <span class="n">dataset</span><span class="o">.</span><span class="n">gradient</span><span class="p">(</span><span class="n">at_state</span><span class="o">=</span><span class="n">theta</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Total interference: </span><span class="si">{</span><span class="n">grad</span><span class="o">.</span><span class="n">weights</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
</code></pre></div>

<h4 id="ols-the-closed-form-stable-point">OLS: The Closed-Form Stable Point</h4>
<p>For linear regression, the stable point has a closed-form solution. The <code>OLSRegression</code> class finds it via the normal equations:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">OLSRegression</span><span class="p">:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    The OLS solution is the unique equilibrium point where all</span>
<span class="sd">    constraint processes balance, the stable pattern.</span>

<span class="sd">    θ* = (X&#39;X)⁻¹ X&#39;y   (where all interferences sum to zero)</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">fit</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">,</span> <span class="n">y</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;OLSRegression&#39;</span><span class="p">:</span>
        <span class="c1"># Create dataset process (concurrent composition)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_dataset</span> <span class="o">=</span> <span class="n">DatasetProcess</span><span class="o">.</span><span class="n">from_arrays</span><span class="p">(</span><span class="n">X</span><span class="p">,</span> <span class="n">y</span><span class="p">,</span> <span class="n">loss_fn</span><span class="o">=</span><span class="s1">&#39;mse&#39;</span><span class="p">)</span>

        <span class="c1"># The normal equations: equilibrium of concurrent interference</span>
        <span class="n">XtX</span> <span class="o">=</span> <span class="n">X</span><span class="o">.</span><span class="n">T</span> <span class="o">@</span> <span class="n">X</span>
        <span class="n">Xty</span> <span class="o">=</span> <span class="n">X</span><span class="o">.</span><span class="n">T</span> <span class="o">@</span> <span class="n">y</span>

        <span class="c1"># Ridge regularization = self-interference term</span>
        <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">_l2_reg</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">:</span>
            <span class="n">XtX</span> <span class="o">=</span> <span class="n">XtX</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">_l2_reg</span> <span class="o">*</span> <span class="n">np</span><span class="o">.</span><span class="n">eye</span><span class="p">(</span><span class="n">n_features</span><span class="p">)</span>

        <span class="n">weights</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">linalg</span><span class="o">.</span><span class="n">solve</span><span class="p">(</span><span class="n">XtX</span><span class="p">,</span> <span class="n">Xty</span><span class="p">)</span>

        <span class="c1"># Create the stable model process</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_model</span> <span class="o">=</span> <span class="n">ModelProcess</span><span class="p">(</span>
            <span class="n">state</span><span class="o">=</span><span class="n">ModelState</span><span class="p">(</span><span class="n">weights</span><span class="o">=</span><span class="n">weights</span><span class="p">,</span> <span class="n">bias</span><span class="o">=</span><span class="n">bias</span><span class="p">),</span>
            <span class="n">l2_reg</span><span class="o">=</span><span class="bp">self</span><span class="o">.</span><span class="n">_l2_reg</span>
        <span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">check_stability</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1e-9</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Verify: μ ; μ ≈ μ (fitting again doesn&#39;t change it).&quot;&quot;&quot;</span>
        <span class="k">return</span> <span class="bp">self</span><span class="o">.</span><span class="n">_model</span><span class="o">.</span><span class="n">is_stable</span><span class="p">(</span><span class="n">tolerance</span><span class="p">)</span>
</code></pre></div>

<h4 id="logistic-regression-iterative-stabilization">Logistic Regression: Iterative Stabilization</h4>
<p>Unlike OLS, logistic regression requires <em>sequential</em> stabilization through gradient descent:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">LogisticRegression</span><span class="p">:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Sequential stabilization: μ₀ →{; Γ} μ₁ →{; Γ} μ₂ → ... → μ*</span>

<span class="sd">    Each iteration is a sequential composition with the dataset.</span>
<span class="sd">    Convergence: μ_t ; Γ ≈ μ_t (model is stable)</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">fit</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">X</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">,</span> <span class="n">y</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;LogisticRegression&#39;</span><span class="p">:</span>
        <span class="c1"># Initialize random model</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_model</span> <span class="o">=</span> <span class="n">ModelProcess</span><span class="p">(</span><span class="n">state</span><span class="o">=</span><span class="n">ModelState</span><span class="o">.</span><span class="n">random</span><span class="p">(</span><span class="n">n_features</span><span class="p">))</span>

        <span class="k">for</span> <span class="n">iteration</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">_max_iter</span><span class="p">):</span>
            <span class="c1"># Compute predictions via sigmoid (the &quot;stabilization operator&quot;)</span>
            <span class="n">probs</span> <span class="o">=</span> <span class="n">sigmoid</span><span class="p">(</span><span class="n">X</span> <span class="o">@</span> <span class="bp">self</span><span class="o">.</span><span class="n">_model</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">weights</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">_model</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">bias</span><span class="p">)</span>

            <span class="c1"># Gradient = interference from dataset</span>
            <span class="n">error</span> <span class="o">=</span> <span class="n">probs</span> <span class="o">-</span> <span class="n">y_binary</span>
            <span class="n">grad_w</span> <span class="o">=</span> <span class="p">(</span><span class="n">X</span><span class="o">.</span><span class="n">T</span> <span class="o">@</span> <span class="n">error</span><span class="p">)</span> <span class="o">/</span> <span class="n">n_samples</span>

            <span class="c1"># Check stability: gradient norm → 0</span>
            <span class="k">if</span> <span class="n">np</span><span class="o">.</span><span class="n">linalg</span><span class="o">.</span><span class="n">norm</span><span class="p">(</span><span class="n">grad_w</span><span class="p">)</span> <span class="o">&lt;</span> <span class="bp">self</span><span class="o">.</span><span class="n">_tol</span><span class="p">:</span>
                <span class="k">break</span>

            <span class="c1"># Sequential composition step: μ_{t+1} = μ_t ; update(Γ)</span>
            <span class="n">new_weights</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_model</span><span class="o">.</span><span class="n">state</span><span class="o">.</span><span class="n">weights</span> <span class="o">-</span> <span class="bp">self</span><span class="o">.</span><span class="n">_lr</span> <span class="o">*</span> <span class="n">grad_w</span>
            <span class="bp">self</span><span class="o">.</span><span class="n">_model</span> <span class="o">=</span> <span class="n">ModelProcess</span><span class="p">(</span><span class="n">state</span><span class="o">=</span><span class="n">ModelState</span><span class="p">(</span><span class="n">weights</span><span class="o">=</span><span class="n">new_weights</span><span class="p">,</span> <span class="o">...</span><span class="p">))</span>
</code></pre></div>

<p>The sigmoid has a natural Tlon interpretation:</p>
<div class="codehilite"><pre><span></span><code><span class="k">def</span><span class="w"> </span><span class="nf">sigmoid</span><span class="p">(</span><span class="n">z</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Stabilization operator: projects interference onto [0, 1].</span>

<span class="sd">    σ(z) → 1: Strong resonance with class 1</span>
<span class="sd">    σ(z) → 0: Strong resonance with class 0</span>
<span class="sd">    σ(z) ≈ 0.5: Unstable, ambiguous</span>
<span class="sd">    &quot;&quot;&quot;</span>
    <span class="k">return</span> <span class="mf">1.0</span> <span class="o">/</span> <span class="p">(</span><span class="mf">1.0</span> <span class="o">+</span> <span class="n">np</span><span class="o">.</span><span class="n">exp</span><span class="p">(</span><span class="o">-</span><span class="n">z</span><span class="p">))</span>
</code></pre></div>

<p>A data point "resonates" with one class or the other; prediction is testing which class it resonates with more strongly.</p>
<h3 id="deep-learning-training-as-sequential-stabilization">Deep Learning: Training as Sequential Stabilization</h3>
<p>The Tlon perspective on deep learning is that training is <em>sequential stabilization</em>: each gradient step is a sequential composition, and convergence means reaching a stable configuration.</p>
<p><img alt="Deep learning training as Tlon process" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/deep_learning_demo.png" /><br />
<em>Training a neural network: the trajectory through loss space shows sequential composition of gradient steps. Stability emerges when loss stops changing.</em></p>
<h4 id="the-tlonmodelsdeep_learning-module-structure">The <code>tlon.models.deep_learning</code> Module Structure</h4>
<div class="codehilite"><pre><span></span><code>tlon/models/deep_learning/
├── layer.py          # LayerProcess, LinearLayer, ActivationProcess, SequentialNetwork
├── training.py       # TrainingProcess, BatchProcess, EpochProcess
├── attention.py      # Attention as interference, AxiomCompliance
├── residual.py       # ResidualConnection as identity ∥ transform
├── normalization.py  # LayerNorm, BatchNorm as stabilization operators
└── transformer.py    # Full transformer as composed processes
</code></pre></div>

<h4 id="layers-as-processes">Layers as Processes</h4>
<p>Each neural network layer is a Tlon <code>Process</code>. The <code>LinearLayer</code> implementation:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">LinearLayer</span><span class="p">(</span><span class="n">LayerProcess</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Linear (fully connected) layer: y = xW + b</span>
<span class="sd">    The simplest transformation process.</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">in_features</span><span class="p">:</span> <span class="nb">int</span><span class="p">,</span> <span class="n">out_features</span><span class="p">:</span> <span class="nb">int</span><span class="p">):</span>
        <span class="c1"># Xavier initialization</span>
        <span class="n">scale</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sqrt</span><span class="p">(</span><span class="mf">2.0</span> <span class="o">/</span> <span class="p">(</span><span class="n">in_features</span> <span class="o">+</span> <span class="n">out_features</span><span class="p">))</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">weights</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">random</span><span class="o">.</span><span class="n">randn</span><span class="p">(</span><span class="n">in_features</span><span class="p">,</span> <span class="n">out_features</span><span class="p">)</span> <span class="o">*</span> <span class="n">scale</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">bias</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">zeros</span><span class="p">(</span><span class="n">out_features</span><span class="p">)</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="c1"># Duration = computational cost (FLOPs for matmul + bias)</span>
        <span class="k">return</span> <span class="nb">float</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="bp">self</span><span class="o">.</span><span class="n">in_features</span> <span class="o">*</span> <span class="bp">self</span><span class="o">.</span><span class="n">out_features</span><span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Forward pass: y = xW + b.&quot;&quot;&quot;</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_input_cache</span> <span class="o">=</span> <span class="n">x</span>  <span class="c1"># Cache for backward</span>
        <span class="k">return</span> <span class="n">x</span> <span class="o">@</span> <span class="bp">self</span><span class="o">.</span><span class="n">weights</span> <span class="o">+</span> <span class="bp">self</span><span class="o">.</span><span class="n">bias</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">backward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">grad_output</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">        Backward pass computes gradients.</span>
<span class="sd">        This is the chain rule for sequential composition:</span>
<span class="sd">        ∂L/∂x = ∂L/∂y · ∂y/∂x = grad_output @ W^T</span>
<span class="sd">        &quot;&quot;&quot;</span>
        <span class="n">x</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">_input_cache</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_grad_weights</span> <span class="o">=</span> <span class="n">x</span><span class="o">.</span><span class="n">T</span> <span class="o">@</span> <span class="n">grad_output</span>  <span class="c1"># ∂L/∂W</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_grad_bias</span> <span class="o">=</span> <span class="n">np</span><span class="o">.</span><span class="n">sum</span><span class="p">(</span><span class="n">grad_output</span><span class="p">,</span> <span class="n">axis</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span>  <span class="c1"># ∂L/∂b</span>
        <span class="k">return</span> <span class="n">grad_output</span> <span class="o">@</span> <span class="bp">self</span><span class="o">.</span><span class="n">weights</span><span class="o">.</span><span class="n">T</span>  <span class="c1"># ∂L/∂x</span>
</code></pre></div>

<h4 id="sequential-composition-stacked-layers">Sequential Composition = Stacked Layers</h4>
<p>A <code>SequentialNetwork</code> is the sequential composition of layers, corresponding to the <code>@</code> operator:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">SequentialNetwork</span><span class="p">(</span><span class="n">LayerProcess</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Sequential composition of layers: π₁ ; π₂ ; ... ; πₙ</span>
<span class="sd">    Information flows through layers in sequence.</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">layers</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="n">LayerProcess</span><span class="p">]):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">layers</span> <span class="o">=</span> <span class="n">layers</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="c1"># Sequential: durations ADD (Axiom B4)</span>
        <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">layer</span><span class="o">.</span><span class="n">duration</span> <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">layers</span><span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Forward pass through all layers sequentially.&quot;&quot;&quot;</span>
        <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">layers</span><span class="p">:</span>
            <span class="n">x</span> <span class="o">=</span> <span class="n">layer</span><span class="o">.</span><span class="n">forward</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">x</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">backward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">grad_output</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">        Backward pass in REVERSE order.</span>
<span class="sd">        This implements the chain rule for sequential composition:</span>
<span class="sd">        ∂L/∂x = ∂L/∂yₙ · ∂yₙ/∂yₙ₋₁ · ... · ∂y₁/∂x</span>
<span class="sd">        &quot;&quot;&quot;</span>
        <span class="n">grad</span> <span class="o">=</span> <span class="n">grad_output</span>
        <span class="k">for</span> <span class="n">layer</span> <span class="ow">in</span> <span class="nb">reversed</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">layers</span><span class="p">):</span>
            <span class="n">grad</span> <span class="o">=</span> <span class="n">layer</span><span class="o">.</span><span class="n">backward</span><span class="p">(</span><span class="n">grad</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">grad</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">_seq_compose</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">other</span><span class="p">:</span> <span class="n">Process</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="s1">&#39;SequentialNetwork&#39;</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;The @ operator stacks networks.&quot;&quot;&quot;</span>
        <span class="k">if</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">SequentialNetwork</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">SequentialNetwork</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">layers</span> <span class="o">+</span> <span class="n">other</span><span class="o">.</span><span class="n">layers</span><span class="p">)</span>
        <span class="k">elif</span> <span class="nb">isinstance</span><span class="p">(</span><span class="n">other</span><span class="p">,</span> <span class="n">LayerProcess</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">SequentialNetwork</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">layers</span> <span class="o">+</span> <span class="p">[</span><span class="n">other</span><span class="p">])</span>
        <span class="k">return</span> <span class="bp">self</span>
</code></pre></div>

<h4 id="parallel-composition-multi-branch-architectures">Parallel Composition = Multi-Branch Architectures</h4>
<p>The <code>ParallelNetwork</code> implements concurrent composition, the <code>|</code> operator:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">ParallelNetwork</span><span class="p">(</span><span class="n">LayerProcess</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Parallel composition: π₁ ∥ π₂ ∥ ... ∥ πₙ</span>
<span class="sd">    Multiple pathways process input simultaneously.</span>

<span class="sd">    This is the basis for:</span>
<span class="sd">    - Inception modules (multiple filter sizes in parallel)</span>
<span class="sd">    - Residual connections (identity ∥ transform)</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="fm">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">branches</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="n">LayerProcess</span><span class="p">],</span> <span class="n">combine</span><span class="p">:</span> <span class="nb">str</span> <span class="o">=</span> <span class="s1">&#39;concat&#39;</span><span class="p">):</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">branches</span> <span class="o">=</span> <span class="n">branches</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">combine</span> <span class="o">=</span> <span class="n">combine</span>  <span class="c1"># &#39;concat&#39; or &#39;sum&#39;</span>

    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="c1"># Concurrent: duration is MAX (Axiom C5)</span>
        <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="n">branch</span><span class="o">.</span><span class="n">duration</span> <span class="k">for</span> <span class="n">branch</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">branches</span><span class="p">)</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">forward</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">NDArray</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;Forward through all branches concurrently.&quot;&quot;&quot;</span>
        <span class="n">outputs</span> <span class="o">=</span> <span class="p">[</span><span class="n">branch</span><span class="o">.</span><span class="n">forward</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="k">for</span> <span class="n">branch</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">branches</span><span class="p">]</span>
        <span class="k">if</span> <span class="bp">self</span><span class="o">.</span><span class="n">combine</span> <span class="o">==</span> <span class="s1">&#39;concat&#39;</span><span class="p">:</span>
            <span class="k">return</span> <span class="n">np</span><span class="o">.</span><span class="n">concatenate</span><span class="p">(</span><span class="n">outputs</span><span class="p">,</span> <span class="n">axis</span><span class="o">=-</span><span class="mi">1</span><span class="p">)</span>
        <span class="k">elif</span> <span class="bp">self</span><span class="o">.</span><span class="n">combine</span> <span class="o">==</span> <span class="s1">&#39;sum&#39;</span><span class="p">:</span>
            <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">outputs</span><span class="p">)</span>
</code></pre></div>

<p>Building networks with composition operators:</p>
<div class="codehilite"><pre><span></span><code><span class="c1"># Sequential: layer1 @ layer2 @ layer3</span>
<span class="n">encoder</span> <span class="o">=</span> <span class="n">LinearLayer</span><span class="p">(</span><span class="mi">784</span><span class="p">,</span> <span class="mi">256</span><span class="p">)</span> <span class="o">@</span> <span class="n">ActivationProcess</span><span class="p">(</span><span class="s1">&#39;relu&#39;</span><span class="p">)</span> <span class="o">@</span> <span class="n">LinearLayer</span><span class="p">(</span><span class="mi">256</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>

<span class="c1"># Parallel: branch1 | branch2 (residual connection)</span>
<span class="n">residual</span> <span class="o">=</span> <span class="n">ParallelNetwork</span><span class="p">([</span>
    <span class="n">IdentityLayer</span><span class="p">(),</span>           <span class="c1"># identity path</span>
    <span class="n">SequentialNetwork</span><span class="p">([</span>        <span class="c1"># transform path</span>
        <span class="n">LinearLayer</span><span class="p">(</span><span class="mi">64</span><span class="p">,</span> <span class="mi">64</span><span class="p">),</span>
        <span class="n">ActivationProcess</span><span class="p">(</span><span class="s1">&#39;relu&#39;</span><span class="p">),</span>
        <span class="n">LinearLayer</span><span class="p">(</span><span class="mi">64</span><span class="p">,</span> <span class="mi">64</span><span class="p">)</span>
    <span class="p">])</span>
<span class="p">],</span> <span class="n">combine</span><span class="o">=</span><span class="s1">&#39;sum&#39;</span><span class="p">)</span>

<span class="c1"># Full network</span>
<span class="n">network</span> <span class="o">=</span> <span class="n">encoder</span> <span class="o">@</span> <span class="n">residual</span> <span class="o">@</span> <span class="n">LinearLayer</span><span class="p">(</span><span class="mi">64</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span> <span class="o">@</span> <span class="n">ActivationProcess</span><span class="p">(</span><span class="s1">&#39;softmax&#39;</span><span class="p">)</span>
</code></pre></div>

<h4 id="training-the-stabilization-loop">Training: The Stabilization Loop</h4>
<p>The <code>TrainingProcess</code> orchestrates sequential stabilization:</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">TrainingProcess</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">    Training as sequential stabilization toward stable parameters.</span>
<span class="sd">        θ₀ →{;Γ} θ₁ →{;Γ} θ₂ → ... → θ*</span>

<span class="sd">    The process becomes stable when loss stops changing:</span>
<span class="sd">        Train(θ*) ; update(Γ) ≈ Train(θ*)</span>
<span class="sd">    &quot;&quot;&quot;</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">step</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">x</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">,</span> <span class="n">y</span><span class="p">:</span> <span class="n">NDArray</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">        One gradient step = sequential composition with batch.</span>
<span class="sd">        Tlon: θᵢ₊₁ = θᵢ ; Γ_batch</span>
<span class="sd">        &quot;&quot;&quot;</span>
        <span class="c1"># Forward pass</span>
        <span class="n">predictions</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="o">.</span><span class="n">forward</span><span class="p">(</span><span class="n">x</span><span class="p">)</span>
        <span class="n">loss</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">loss_fn</span><span class="p">(</span><span class="n">predictions</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>

        <span class="c1"># Backward pass (compute gradients)</span>
        <span class="n">grad_output</span> <span class="o">=</span> <span class="n">cross_entropy_gradient</span><span class="p">(</span><span class="n">predictions</span><span class="p">,</span> <span class="n">y</span><span class="p">)</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">model</span><span class="o">.</span><span class="n">backward</span><span class="p">(</span><span class="n">grad_output</span><span class="p">)</span>

        <span class="c1"># Update parameters</span>
        <span class="bp">self</span><span class="o">.</span><span class="n">_update_all_parameters</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">loss</span>

    <span class="k">def</span><span class="w"> </span><span class="nf">is_stable</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">tolerance</span><span class="p">:</span> <span class="nb">float</span> <span class="o">=</span> <span class="mf">1e-4</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</span><span class="p">:</span>
<span class="w">        </span><span class="sd">&quot;&quot;&quot;</span>
<span class="sd">        Stable when loss stops changing: π ; π ≈ π</span>
<span class="sd">        &quot;&quot;&quot;</span>
        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">metrics</span><span class="o">.</span><span class="n">train_loss</span><span class="p">)</span> <span class="o">&lt;</span> <span class="mi">20</span><span class="p">:</span>
            <span class="k">return</span> <span class="kc">False</span>
        <span class="n">recent</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">metrics</span><span class="o">.</span><span class="n">train_loss</span><span class="p">[</span><span class="o">-</span><span class="mi">20</span><span class="p">:]</span>
        <span class="k">return</span> <span class="n">np</span><span class="o">.</span><span class="n">std</span><span class="p">(</span><span class="n">recent</span><span class="p">)</span> <span class="o">&lt;</span> <span class="n">tolerance</span>
</code></pre></div>

<p>The Tlon structure is: Batch (concurrent) → Epoch (sequential batches) → Training (sequential epochs until stable):</p>
<div class="codehilite"><pre><span></span><code><span class="k">class</span><span class="w"> </span><span class="nc">BatchProcess</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Batch = x₁ ∥ x₂ ∥ ... ∥ xₙ (concurrent composition)&quot;&quot;&quot;</span>
    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="c1"># Concurrent: max of all examples (Axiom C5)</span>
        <span class="k">return</span> <span class="nb">max</span><span class="p">(</span><span class="n">x</span><span class="o">.</span><span class="n">shape</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="k">for</span> <span class="n">x</span><span class="p">,</span> <span class="n">_</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">examples</span><span class="p">)</span>

<span class="k">class</span><span class="w"> </span><span class="nc">EpochProcess</span><span class="p">(</span><span class="n">Process</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;Epoch = Batch₁ ; Batch₂ ; ... ; Batchₘ (sequential composition)&quot;&quot;&quot;</span>
    <span class="nd">@property</span>
    <span class="k">def</span><span class="w"> </span><span class="nf">duration</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">float</span><span class="p">:</span>
        <span class="c1"># Sequential: sum of all batches (Axiom B4)</span>
        <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">batch</span><span class="o">.</span><span class="n">duration</span> <span class="k">for</span> <span class="n">batch</span> <span class="ow">in</span> <span class="bp">self</span><span class="o">.</span><span class="n">batches</span><span class="p">)</span>
</code></pre></div>

<p>The stability criterion for training:<br />
<script type="math/tex; mode=display">\text{Stable}(\text{Train}(\theta)) \Leftrightarrow \text{Train}(\theta) ; \text{update}(\Gamma) \approx \text{Train}(\theta)</script>
</p>
<h3 id="attention-as-learned-interference">Attention as Learned Interference</h3>
<p>The attention mechanism in transformers has a natural Tlon interpretation: attention weights are <em>learned interference strengths</em> between token processes.</p>
<p><img alt="Attention as interference" src="/blog/ai-explorations/posts/2025-12-03-tlon-mathematics/images/attention_demo.png" /><br />
<em>Self-attention computes pairwise interference strengths. Each entry (i,j) represents how much token j "interferes" with token i's representation.</em></p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">tlon.models.deep_learning.attention</span><span class="w"> </span><span class="kn">import</span> <span class="p">(</span>
    <span class="n">attention_interference_matrix</span><span class="p">,</span>
    <span class="n">check_axiom_compliance</span>
<span class="p">)</span>

<span class="c1"># Attention computes interference strengths</span>
<span class="c1"># Int_strength(πᵢ, πⱼ) = softmax(qᵢ · kⱼ / √d)</span>
<span class="n">attn_weights</span> <span class="o">=</span> <span class="n">attention_interference_matrix</span><span class="p">(</span><span class="n">queries</span><span class="p">,</span> <span class="n">keys</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">causal_mask</span><span class="p">)</span>

<span class="c1"># Check compliance with Tlon interference axioms</span>
<span class="n">compliance</span> <span class="o">=</span> <span class="n">check_axiom_compliance</span><span class="p">(</span><span class="n">attn_weights</span><span class="p">,</span> <span class="n">tokens</span><span class="p">,</span> <span class="n">mask</span><span class="o">=</span><span class="n">causal_mask</span><span class="p">)</span>
<span class="nb">print</span><span class="p">(</span><span class="n">compliance</span><span class="p">)</span>
<span class="c1"># AxiomCompliance(</span>
<span class="c1">#   D1(symmetry)=0.2341,    # Attention is NOT symmetric (α_ij ≠ α_ji)</span>
<span class="c1">#   D2(self)=0.8721,        # Self-attention is strong (diagonal)</span>
<span class="c1">#   D3(null)=0.9999,        # Masked positions don&#39;t interfere</span>
<span class="c1">#   D5(equiv)=0.7234,       # Similar tokens → similar attention</span>
<span class="c1">#   overall=0.7074)</span>
</code></pre></div>

<p>The Tlon axioms suggest testable hypotheses about attention:<br />
- <strong>D1 (Symmetry)</strong>: Standard attention violates symmetry. Would symmetric attention work better?<br />
- <strong>D2 (Self-interference)</strong>: The diagonal of attention (self-attention) should be strong.<br />
- <strong>D3 (Null interference)</strong>: Masked positions should have zero attention.<br />
- <strong>D5 (Equivalence)</strong>: Similar tokens should have similar attention patterns.</p>
<p>The Tlon axioms become design principles the framework makes explicit.</p>
<h2 id="what-this-framework-does-and-doesnt-do">What This Framework Does and Doesn't Do</h2>
<p><strong>What it does:</strong><br />
- Provides clean axioms for reasoning about processes and their compositions<br />
- Makes "stability" a first-class citizen that can be checked and classified<br />
- Connects process algebra to dynamical systems in a rigorous way<br />
- Demonstrates that "objects" can emerge from purely processual foundations</p>
<p><strong>What it doesn't do:</strong><br />
- Provide computational speedups (this is a conceptual framework, not an optimization)<br />
- Replace conventional mathematics (it's an alternative foundation for certain phenomena)<br />
- Prove anything about consciousness or meaning (despite the philosophical inspiration)</p>
<h2 id="connections-to-other-frameworks">Connections to Other Frameworks</h2>
<p><strong>Category Theory</strong>: The four criteria for categories (objects, morphisms, identity, composition) map naturally to Tlön primitives. But Tlön processes compose without specified endpoints, more like a monoid than a category.</p>
<p><strong>Process Algebra (CCS, CSP)</strong>: Tlön shares DNA with process algebras from computer science (Milner, 1989; Hoare, 1985), but adds the stability/idempotence focus and interference function.</p>
<p><strong>Dynamical Systems</strong>: The simulations use standard numerical methods (RK4, Verlet integration), but the Tlön framing provides a different lens for interpreting results.</p>
<h2 id="where-tlon-stands">Where Tlon Stands</h2>
<p>Hilbert famously demanded that mathematical systems prove their own consistency. Tlon has not yet met this standard. The axioms feel right; the simulations behave as predicted; the vocabulary illuminates phenomena I previously struggled to articulate. But no verified model satisfying all axioms exists. Mathematics requires construction, not intuition, and until I build a Petri net or stream transformer that demonstrably satisfies each axiom, Tlon remains a promising sketch rather than a proven foundation. I am hardly a mathematician, leave alone a category theorist, and there is much work ahead of this framework before it could be seen as something reliable and before it can be deemed to have pushed any boundary.</p>
<p>Three absences shape what Tlon can and cannot say. First, processes have no types; any two can be composed sequentially, which is either liberating or structureless depending on what you want to model. Category theory demands that morphisms match at boundaries; Tlon ignores boundaries entirely. Second, there is no notion of distance between processes. How "close" is one trajectory to another? Tlon cannot answer this, which means Lyapunov exponents and perturbation analysis live outside its vocabulary. Third, there are no gradients. Optimization, the beating heart of machine learning, requires differential structure that Tlon does not provide.</p>
<p>These are not flaws so much as boundaries. The axioms capture something real about the algebra of process composition; they say nothing about the geometry.</p>
<h2 id="the-shape-of-future-work">The Shape of Future Work</h2>
<p>Extending Tlon toward genuine applicability would require building upward through several layers, each resting on the previous. Consistency first: construct an explicit model, probably based on Petri nets, and verify every axiom against it. Then types: add domain and codomain structure so that composition becomes constrained, so that π ; ρ is only defined when the output space of π matches the input space of ρ. This is where Tlon would start to resemble category theory in earnest.</p>
<p>Above types lies topology. Introducing a metric on process space would let stability become attractor theory; processes could converge, approximate, perturb. The connection to Lyapunov analysis would emerge naturally. Above topology lies differential structure: tangent processes, gradients, curvature of process space. Backpropagation would become a statement about how loss functionals induce flows. And somewhere beyond that lies information theory: entropy of processes, mutual information, the question of what a process preserves or destroys as it transforms.</p>
<p>Each layer is non-trivial. The full program is years of work, and I am not certain the destination justifies the journey. Whitehead spent decades building process philosophy into a comprehensive metaphysical system; the mathematical analog might demand similar patience.</p>
<h2 id="two-paths">Two Paths</h2>
<p>There are two honest ways forward. One is to develop Tlon rigorously through each layer, proving theorems, constructing models, extending the axiom system as gaps become clear. This is a research program, not a side project.</p>
<p>The other is to use Tlon as intuition while working within established frameworks. Category theory already has the morphisms and functors Tlon lacks. Dynamical systems theory already has the metrics and Lyapunov exponents. Information theory already has entropy. Perhaps Tlon's contribution is not a new formalism but a new lens: a way of seeing stability as special, objects as emergent, resonance as the mechanism by which complex systems find their footing.</p>
<p>I find myself drawn to both paths. The Kuramoto simulations convinced me that synchronization really is resonance in a precise sense. Sorting algorithms really do converge to idempotent fixed points. Neural network training really is sequential stabilization toward a loss-minimizing attractor. Whether these observations require a new mathematical framework or merely a new vocabulary, I do not yet know.</p>
<p>The code is available at <a href="https://github.com/aiexplorations/tlon_math">github.com/aiexplorations/tlon_math</a>. The repository includes the full axiom system, theorem proofs, Python implementations, and runnable demos for all the dynamical systems discussed here.</p>
<p>Whether this leads anywhere practical remains to be seen. But the exploration has been valuable regardless; sometimes the learning is the point, and that suffices. I would love for mathematicians and computer scientists to review this material and help me understand what gaps may exist in the work, and whether and how the foundation and the application so far represent some kind of meaningful scaffolding for this new way of modelling systems that is Tlon mathematics.</p>
<hr />
<h2 id="links-and-references">Links and References</h2>
<h3 id="repository-and-documentation">Repository and Documentation</h3>
<ul>
<li><strong>Tlön Mathematics Repository</strong>: <a href="https://github.com/aiexplorations/tlon_math">github.com/aiexplorations/tlon_math</a></li>
<li><strong>Tlon Theory Booklet</strong>: <a href="https://github.com/aiexplorations/tlon_math/blob/main/docs/TLON_THEORY_BOOKLET.md">Full axioms, proofs, and examples</a></li>
</ul>
<h3 id="philosophical-background">Philosophical Background</h3>
<ul>
<li>Borges, J.L. (1940). "Tlön, Uqbar, Orbis Tertius." <em>Sur</em>. (The literary inspiration for this work)</li>
</ul>
<h3 id="process-algebra">Process Algebra</h3>
<ul>
<li>Milner, R. (1989). <em>Communication and Concurrency</em>. Prentice Hall.</li>
<li>Hoare, C.A.R. (1985). <em>Communicating Sequential Processes</em>. Prentice Hall.</li>
</ul>
<h3 id="category-theory">Category Theory</h3>
<ul>
<li>Bartosz Milewski: <a href="https://bartoszmilewski.com/2014/10/28/category-theory-for-programmers-the-preface/">Category Theory for Programmers</a> and <a href="https://www.youtube.com/playlist?list=PLbgaMIhjbmEnaH_LTkxLI7FMa2HsnawM_">YouTube Playlist</a></li>
</ul>
<h3 id="dynamical-systems-and-chaos">Dynamical Systems and Chaos</h3>
<ul>
<li>Strogatz, S.H. (2015). <em>Nonlinear Dynamics and Chaos</em>. 2nd ed. Westview Press.</li>
<li>Poincaré, H. (1890). "Sur le problème des trois corps et les équations de la dynamique." <em>Acta Mathematica</em> 13. <a href="https://content.e-bookshelf.de/media/reading/L-9880183-27ae99ebe0.pdf">Translation, PDF</a></li>
</ul>
<h3 id="n-body-problem-and-choreographies">N-Body Problem and Choreographies</h3>
<ul>
<li>Moore, C. (1993). "Braids in classical dynamics." <em>Physical Review Letters</em> 70. <a href="https://sites.santafe.edu/~moore/braids-prl.pdf">PDF</a></li>
<li>Chenciner, A. &amp; Montgomery, R. (2000). "A remarkable periodic solution of the three-body problem." <em>Annals of Mathematics</em> 152.</li>
</ul>
<h3 id="ecology-and-population-dynamics">Ecology and Population Dynamics</h3>
<ul>
<li>Volterra, V. (1926). "Fluctuations in the Abundance of a Species Considered Mathematically." <em>Nature</em> 118.</li>
<li>Lotka, A.J. (1925). <em>Elements of Physical Biology</em>. Williams &amp; Wilkins.</li>
</ul>
<h3 id="synchronization-and-coupled-oscillators">Synchronization and Coupled Oscillators</h3>
<ul>
<li>Kuramoto, Y. (1984). <em>Chemical Oscillations, Waves, and Turbulence</em>. Springer.</li>
<li>Strogatz, S.H. (2000). "From Kuramoto to Crawford: exploring the onset of synchronization." <em>Physica D</em> 143.</li>
</ul>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>tlon</category>
      <category>mathematics</category>
      <category>process-algebra</category>
      <category>dynamical-systems</category>
      <category>borges</category>
      <category>category-theory</category>
      <category>chaos</category>
      <category>stability</category>
      <category>emergence</category>
    </item>
    <item>
      <title>Praval Deep Research – Local-First AI Research Assistant</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-11-25-praval-deep-research.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-11-25-praval-deep-research.html</guid>
      <pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate>
      <description>Building a privacy-focused research assistant with multi-agent architecture: architectural decisions, local-first design, and lessons learned from production deployment.</description>
      <content:encoded><![CDATA[<p>Research has a privacy problem that nobody talks about. Your ArXiv search history reveals strategic research directions. Your questions to AI assistants expose knowledge gaps. Your reading patterns show what you're building before you build it. And all of this data flows through cloud services that store, analyze, and potentially monetize your intellectual journey.</p>
<p>When I started building <strong>Praval Deep Research</strong>, I had a simple question: could we build a production-quality AI research assistant where all your data stays on your infrastructure? Not as a privacy theater exercise, but as a genuinely useful tool that happened to respect privacy as a first-class architectural constraint?</p>
<p>The answer turned out to be yes, but the architectural decisions required were fascinating.</p>
<h2 id="the-constraint-local-first-as-architecture">The Constraint: Local-First as Architecture</h2>
<p>Local-first isn't just about running <code>docker-compose up</code>. It's a fundamentally different architectural stance that cascades through every design decision.</p>
<p>Cloud-based research tools optimize for scale and telemetry. They can instrument every interaction, build ML models from aggregate usage patterns, and spin up infrastructure elastically. Privacy comes as an afterthought—usually through compliance theater like "we encrypt data at rest" while still mining it for training data.</p>
<p>Local-first inverts this. Your infrastructure becomes the hard constraint. You get:<br />
- <strong>Complete data sovereignty</strong>: Papers, embeddings, conversations, insights—all on your PostgreSQL, Qdrant, MinIO, Redis stack<br />
- <strong>Zero telemetry by default</strong>: No usage tracking, no analytics, no phone home<br />
- <strong>Offline-capable core</strong>: External calls only for ArXiv downloads and OpenAI API (for now—Ollama integration planned for fully air-gapped operation)</p>
<p>But you lose elastic scale, centralized monitoring, and the ability to learn from aggregate patterns across users. The architecture must accommodate these tradeoffs.</p>
<h2 id="multi-agent-architecture-why-six-specialized-agents">Multi-Agent Architecture: Why Six Specialized Agents?</h2>
<p>The core architectural question was: monolithic pipeline vs. multi-agent orchestration?</p>
<p>A monolithic pipeline would be simpler: <code>search → download → extract → embed → query → respond</code>. Linear, predictable, easy to reason about. But it fails on several dimensions:</p>
<p><strong>Lack of specialization</strong>: A generalist LLM handling paper discovery, semantic analysis, summarization, and Q&amp;A does none of them excellently. Each task has different prompt requirements, different model strengths, different failure modes.</p>
<p><strong>No learning</strong>: A stateless pipeline processes paper N exactly like paper 1. No memory of what you care about, no improvement over time, no personalization.</p>
<p><strong>Brittleness</strong>: One component failure crashes the entire pipeline. If embedding generation hits an API rate limit, the whole system blocks.</p>
<p>Multi-agent architecture solves these problems through specialization and autonomy. I built six agents, each with distinct identity, memory, and purpose:</p>
<h3 id="agent-architecture-overview">Agent Architecture Overview</h3>
<p><img alt="Praval Deep Research Architecture" src="/blog/ai-explorations/posts/2025-11-25-praval-deep-research/images/architecture-diagram.png" /></p>
<p><em>Six specialized agents coordinate through the Reef message substrate, each maintaining memory and learning from interactions</em></p>
<p><strong>1. Paper Discovery Agent</strong>: Searches ArXiv with domain-specific ranking. Remembers which papers you selected historically to improve future relevance scoring. Uses recall memory to learn your research focus areas.</p>
<p><strong>2. Document Processor Agent</strong>: Downloads PDFs, extracts text (handling equations, multi-column layouts, figures), chunks intelligently with 1000-char windows and 200-char overlap, generates embeddings. Optimizes chunking strategy based on paper type (theory-heavy vs. empirical).</p>
<p><strong>3. Semantic Analyzer Agent</strong>: Identifies themes and connections across your entire knowledge base. Builds conceptual maps showing how papers relate. Uses episodic memory to track which connections you've explored.</p>
<p><strong>4. Summarization Agent</strong>: Creates comprehensive syntheses of individual papers. Adapts verbosity based on your historical preferences (do you want dense technical summaries or high-level overviews?).</p>
<p><strong>5. Q&amp;A Specialist Agent</strong>: Answers questions using retrieved context, citing specific papers with relevance scores. Personalizes responses based on conversation history and your background (inferred from questions).</p>
<p><strong>6. Research Advisor Agent</strong>: Provides strategic guidance, suggests unexplored areas, identifies research gaps. Proactively generates insights like trending topics, research area clustering, and recommended next steps.</p>
<p>These agents don't follow a fixed workflow. They communicate via <strong>Praval's Reef substrate</strong>—an event-driven message bus where agents broadcast and subscribe to "spores" (structured messages). This creates emergent behavior:</p>
<ul>
<li>Document Processor completes → broadcasts <code>paper_indexed</code> spore</li>
<li>Semantic Analyzer receives <code>paper_indexed</code> → updates theme graph → broadcasts <code>themes_updated</code></li>
<li>Research Advisor receives <code>themes_updated</code> → invalidates cached insights → regenerates proactive recommendations</li>
</ul>
<p>No orchestrator. No central coordinator. Just autonomous agents reacting to events.</p>
<h2 id="the-storage-strategy-four-databases-four-purposes">The Storage Strategy: Four Databases, Four Purposes</h2>
<p>Local-first doesn't mean "dump everything in SQLite." Different data has different access patterns, different consistency requirements, different performance needs. I use four storage systems, each for architectural reasons:</p>
<p><img alt="Storage Architecture" src="/blog/ai-explorations/posts/2025-11-25-praval-deep-research/images/storage-architecture.png" /><br />
<em>Hybrid storage strategy: relational for conversations, vector for semantic search, object for PDFs, cache for insights</em></p>
<p><strong>PostgreSQL (Relational Integrity)</strong>: Stores conversations and messages with CASCADE delete integrity. When you delete a conversation, all associated messages vanish atomically. ACID guarantees ensure chat history never corrupts even if the system crashes mid-write.</p>
<p><strong>Qdrant (Semantic Search)</strong>: Stores 1536-dimensional OpenAI embeddings for paper chunks. Optimized for nearest-neighbor search across millions of vectors. Retrieval takes &lt;100ms even with 10,000+ papers indexed.</p>
<p><strong>MinIO (Object Storage)</strong>: Stores PDF files with S3-compatible API. Streaming proxy serves PDFs to browser without loading entire file into memory. Handles multi-GB knowledge bases efficiently.</p>
<p><strong>Redis (Performance Cache)</strong>: Caches research insights (trending topics, theme clusters) with 1-hour TTL. First generation takes 35 seconds (LLM analysis of entire knowledge base). Subsequent requests: instant. Cache invalidation happens automatically when papers are added/removed.</p>
<p>Why not just use PostgreSQL for everything? <strong>Access patterns differ fundamentally:</strong></p>
<ul>
<li>Conversations: relational queries with foreign keys</li>
<li>Embeddings: nearest-neighbor similarity search (not relational)</li>
<li>PDFs: streaming binary objects (not queryable)</li>
<li>Insights: ephemeral computed data with TTL (not durable)</li>
</ul>
<p>Each database excels at its specific access pattern. PostgreSQL doing vector similarity search would be orders of magnitude slower than Qdrant. Qdrant storing relational conversation threads would be architecturally absurd.</p>
<h2 id="the-processing-pipeline-async-event-driven-resilient">The Processing Pipeline: Async, Event-Driven, Resilient</h2>
<p>When you click "Index Selected Papers," here's what happens architecturally:</p>
<div class="codehilite"><pre><span></span><code><span class="n">Frontend</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">API</span><span class="w"> </span><span class="p">(</span><span class="n">FastAPI</span><span class="p">)</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">RabbitMQ</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">Document</span><span class="w"> </span><span class="n">Processor</span><span class="w"> </span><span class="n">Agent</span>
<span class="w">                                    </span><span class="err">↓</span>
<span class="w">                              </span><span class="p">(</span><span class="n">async</span><span class="w"> </span><span class="n">processing</span><span class="p">)</span>
<span class="w">                                    </span><span class="err">↓</span>
<span class="n">PDF</span><span class="w"> </span><span class="n">download</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">Text</span><span class="w"> </span><span class="n">extract</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">Chunk</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">Embed</span><span class="w"> </span><span class="err">→</span><span class="w"> </span><span class="n">Qdrant</span><span class="w"> </span><span class="n">insert</span>
<span class="w">     </span><span class="err">↓</span><span class="w">              </span><span class="err">↓</span><span class="w">           </span><span class="err">↓</span><span class="w">       </span><span class="err">↓</span><span class="w">          </span><span class="err">↓</span>
<span class="w">   </span><span class="n">MinIO</span><span class="w">       </span><span class="p">(</span><span class="n">PyPDF2</span><span class="p">)</span><span class="w">    </span><span class="p">(</span><span class="mi">1000</span><span class="o">/</span><span class="mi">200</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="n">OpenAI</span><span class="p">)</span><span class="w"> </span><span class="p">(</span><span class="n">vector</span><span class="w"> </span><span class="n">DB</span><span class="p">)</span>
</code></pre></div>

<p>The critical architectural decision: <strong>RabbitMQ for async job processing</strong>.</p>
<p>Why not just process in-request? Papers take 30-60 seconds each to process. HTTP requests time out. Users close browser tabs. Synchronous processing fails in production.</p>
<p>RabbitMQ decouples request from execution. The API returns immediately with job ID. The Document Processor Agent consumes jobs from the queue at its own pace. Server-Sent Events (SSE) stream progress updates back to the frontend in real-time.</p>
<p>Benefits:<br />
- <strong>Resilience</strong>: If the agent crashes mid-processing, RabbitMQ redelivers the job<br />
- <strong>Scalability</strong>: Deploy multiple Document Processor agents to parallelize (currently single instance, but architecture supports horizontal scaling)<br />
- <strong>Backpressure</strong>: Queue depth reveals system load; can throttle new submissions if processing lags</p>
<h2 id="the-chat-architecture-persistent-conversations-with-llm-generated-titles">The Chat Architecture: Persistent Conversations with LLM-Generated Titles</h2>
<p>Most chat interfaces are stateless. Every question starts from scratch. Context comes from stuffing previous messages into prompts, burning tokens and losing history beyond context windows.</p>
<p>Praval Deep Research makes conversations durable and first-class:</p>
<p><strong>PostgreSQL schema</strong>:</p>
<div class="codehilite"><pre><span></span><code>conversations:
  <span class="k">-</span> id (UUID primary key)
  <span class="k">-</span> title (generated by LLM)
  <span class="k">-</span> created_at, updated_at

messages:
  <span class="k">-</span> id (UUID primary key)
  <span class="k">-</span> conversation_id (foreign key CASCADE)
  <span class="k">-</span> role (user | assistant)
  <span class="k">-</span> content (text)
  <span class="k">-</span> sources (JSON array of cited papers)
  <span class="k">-</span> created_at
</code></pre></div>

<p>When you ask a question:</p>
<ol>
<li>Q&amp;A Specialist retrieves relevant paper chunks from Qdrant (semantic similarity search)</li>
<li>Constructs prompt with retrieved context + conversation history (last N messages)</li>
<li>LLM generates answer with source citations</li>
<li>System saves user message + assistant response to PostgreSQL</li>
<li>If conversation lacks a title, Research Advisor generates one using GPT-4o-mini (e.g., "Transformer attention mechanisms" instead of "Conversation 1")</li>
</ol>
<p><strong>Architectural benefit</strong>: Conversations are durable data, not ephemeral prompt state. You can:<br />
- Resume conversations weeks later with full context<br />
- Delete conversations with CASCADE integrity (all messages vanish)<br />
- Export conversations for sharing (with citations intact)<br />
- Analyze conversation patterns to understand research focus</p>
<p>Contrast with stateless chat: every new session forgets you exist. No learning, no personalization, no continuity.</p>
<h2 id="proactive-insights-cached-intelligence-at-35-second-latency">Proactive Insights: Cached Intelligence at 35-Second Latency</h2>
<p>The Research Advisor agent does something unusual: it analyzes your entire knowledge base unprompted and generates strategic insights.</p>
<p><strong>What it produces</strong>:<br />
- <strong>Research area clustering</strong>: "Your papers cluster into 3 themes: transformer architectures (12 papers), attention mechanisms (8 papers), multimodal learning (7 papers)"<br />
- <strong>Trending topics</strong>: Extracted keywords ranked by frequency across recent papers<br />
- <strong>Research gaps</strong>: Areas mentioned frequently but not deeply explored<br />
- <strong>Personalized next steps</strong>: Recommendations based on chat history and indexed papers</p>
<p><strong>The architectural challenge</strong>: This analysis is expensive. Scanning 50 papers, extracting themes, clustering, gap detection—35 seconds with GPT-4o-mini. Unacceptable for interactive UI.</p>
<p><strong>Solution</strong>: Redis caching with smart invalidation.</p>
<p>First request: Compute insights (35s) → Cache in Redis with 1-hour TTL → Return to user<br />
Subsequent requests: Return cached insights (instant)<br />
Cache invalidation: When papers are added/removed, invalidate cache → Next request recomputes</p>
<p>Users get instant insights after the first generation. The 1-hour TTL ensures freshness without constant recomputation. If you're actively adding papers, you'll see updated insights hourly.</p>
<h2 id="the-frontend-react-typescript-with-real-time-updates">The Frontend: React + TypeScript with Real-Time Updates</h2>
<p>Building for local-first meant building for <em>your</em> infrastructure. No CDN. No edge caching. No serverless functions. Just Docker containers running on your machine.</p>
<p>The frontend architecture reflects this constraint:</p>
<p><strong>Multi-stage Docker build</strong>:</p>
<div class="codehilite"><pre><span></span><code>Stage 1 (Node.js): npm install → npm run build → generate static assets
Stage 2 (Nginx): Copy static assets → Serve on port 3000
</code></pre></div>

<p>Why multi-stage? The final image ships only the compiled artifacts, not the entire Node.js toolchain. Smaller image, faster startup, less attack surface.</p>
<p><strong>Real-time progress tracking</strong>: Server-Sent Events (SSE) stream processing updates from backend to frontend without polling. When Document Processor indexes a paper, it emits progress events that flow through the API to your browser in &lt;100ms.</p>
<p><strong>PDF streaming</strong>: Clicking "View PDF" doesn't download the entire file. MinIO streams chunks through a FastAPI proxy. Browser renders as data arrives. Works even with 50MB papers.</p>
<h2 id="what-i-learned-building-this">What I Learned Building This</h2>
<h3 id="1-agent-identity-matters-more-than-i-expected">1. Agent Identity Matters More Than I Expected</h3>
<p>Early versions had generic agents: "processor", "analyzer", "responder". Naming them around identity—Paper Discovery, Research Advisor, Q&amp;A Specialist—changed how I designed their behavior. An agent that "is" a Research Advisor proactively generates insights. An agent that "is" a Document Processor obsesses over text extraction edge cases.</p>
<p>Identity-driven design (core to Praval framework) made each agent better at its specialized role.</p>
<h3 id="2-local-first-forces-architectural-discipline">2. Local-First Forces Architectural Discipline</h3>
<p>Cloud services let you be sloppy. Out of memory? Scale up. Slow query? Add a cache layer. Lock contention? Throw read replicas at it.</p>
<p>Local-first removes escape hatches. You have 8GB RAM and 4 cores. That's it. This forced me to:<br />
- Optimize chunking strategy (1000/200 was empirically tuned, not guessed)<br />
- Use Redis caching strategically (not everywhere)<br />
- Pre-aggregate insights rather than computing on-demand<br />
- Choose Qdrant over PostgreSQL pgvector for better performance characteristics</p>
<p>Constraint breeds clarity.</p>
<h3 id="3-embeddings-quality-dominates-everything-else">3. Embeddings Quality Dominates Everything Else</h3>
<p>I spent weeks optimizing retrieval—hybrid search, reranking, query expansion. Impact: marginal.</p>
<p>Then I improved chunking strategy (semantic boundaries instead of fixed-length splits). Impact: transformative. Retrieval accuracy jumped 40%.</p>
<p>The lesson: embeddings are lossy compressions. If you compress garbage, retrieval finds better garbage. Fix the source.</p>
<h3 id="4-users-want-proactive-intelligence">4. Users Want Proactive Intelligence</h3>
<p>I built proactive insights (trending topics, research gaps) as an afterthought. Expected usage: 5%. Actual usage: 40% of sessions engage with insights, higher than Q&amp;A.</p>
<p>People don't know what questions to ask. Showing them "here are the themes in your corpus" sparks exploration. Reactive Q&amp;A is necessary. Proactive insights are transformative.</p>
<h3 id="5-multi-agent-coordination-is-still-fragile">5. Multi-Agent Coordination Is Still Fragile</h3>
<p>Event-driven architecture is elegant until it isn't. When agents communicate via async spores, debugging failures becomes archaeological:</p>
<p>"Why didn't Research Advisor update insights?"<br />
→ Check if Semantic Analyzer emitted <code>themes_updated</code> spore<br />
→ Check if RabbitMQ delivered the spore<br />
→ Check if Research Advisor subscribed to correct topic<br />
→ Check if cache invalidation logic triggered</p>
<p>Three-level distributed debugging for what would be a single function call in monolithic code.</p>
<p>Worth it for resilience and scalability. But not free.</p>
<h2 id="whats-next">What's Next</h2>
<p><strong>Ollama Integration</strong>: Replace OpenAI API with local LLMs for fully offline operation. Architectural challenge: Ollama embeddings differ from OpenAI's (different dimensionality, different semantic spaces). Migration path needs careful design.</p>
<p><strong>Multi-User Support</strong>: Current architecture assumes single user. PostgreSQL schema needs user_id foreign keys, Qdrant collections need per-user isolation, MinIO buckets need access control. Not technically hard, but changes security model significantly.</p>
<p><strong>Citation Graph Visualization</strong>: Papers cite each other. This creates a graph. Visualizing this would expose intellectual lineages. Architectural challenge: extracting citations from PDFs reliably (many formats, inconsistent parsing).</p>
<p><strong>Collaborative Annotations</strong>: Let users mark interesting passages, add notes. Store annotations in PostgreSQL tied to specific paper chunks. Surfaced during Q&amp;A retrieval to provide personalized context.</p>
<h2 id="try-it-yourself">Try It Yourself</h2>
<p>Praval Deep Research is open source. If you're curious about local-first AI research tools or multi-agent architectures built with Praval:</p>
<ul>
<li><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/praval_deep_research">github.com/aiexplorations/praval_deep_research</a></li>
<li><strong>Deployment</strong>: <code>docker-compose up -d</code> (requires OpenAI API key, 8GB RAM, 10GB disk)</li>
<li><strong>Architecture Docs</strong>: <a href="https://github.com/aiexplorations/praval_deep_research/blob/main/DESIGN.md">DESIGN.md</a></li>
</ul>
<p>The codebase demonstrates production-grade multi-agent systems: health checks, structured logging, async job processing, event-driven coordination, hybrid storage strategies.</p>
<p>If you build something with it or have architecture questions, I'd love to hear from you. This is very much a living system—evolving as I learn what works and what doesn't in practice.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>praval</category>
      <category>deep-research</category>
      <category>arxiv</category>
      <category>multi-agent</category>
      <category>local-first</category>
      <category>project-spotlight</category>
    </item>
    <item>
      <title>Praval Analytics – Reimagining Business Intelligence Through AI Agents</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-11-25-praval-analytics.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-11-25-praval-analytics.html</guid>
      <pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate>
      <description>Why traditional BI breaks on factory floors, and how multi-agent architecture enables conversational analytics: architectural decisions, event-driven coordination, and production lessons.</description>
      <content:encoded><![CDATA[<p>The factory floor has a dashboard problem. Not a "dashboards don't exist" problem—they're everywhere, meticulously crafted by BI teams, updated nightly via ETL jobs. The problem is that nobody uses them.</p>
<p>Quality engineers walking production lines don't carry laptops. They have phones and questions: "Why did Line A's defect rate spike?" Plant managers in weekly reviews don't navigate five-level dashboard hierarchies. They want to ask "Compare this week's OEE to last month" and get an answer, not a navigation exercise.</p>
<p>Traditional BI assumes users sit at desks, know which dashboard to consult, and can translate business questions into filter combinations. Reality is messier. <strong>When I built Praval Analytics, the architectural question wasn't "how do we build better dashboards?" It was "can we eliminate dashboards entirely?"</strong></p>
<p>The answer: yes, but only if you rethink the entire architecture around conversation as the primary interface.</p>
<h2 id="the-brittleness-of-traditional-bi">The Brittleness of Traditional BI</h2>
<p>Before diving into the architecture, it's worth understanding why traditional BI fails in manufacturing environments.</p>
<p><strong>Schema Fragility</strong>: Manufacturing databases evolve constantly. Suppliers add new material grades. Equipment gets upgraded. New defect types emerge. In traditional BI, each schema change cascades through ETL pipelines, breaks dashboard queries, and triggers emergency fixes by data teams.</p>
<p><strong>Static Workflow Assumptions</strong>: BI tools assume questions follow predictable paths. "Start at Line Performance dashboard → drill into defect analysis → filter by shift → export to Excel." But real questions are messy: "Show me springback defects for door panels from Supplier B during night shifts after die changeovers." That's not a dashboard path. That's a sentence.</p>
<p><strong>Maintenance Bottlenecks</strong>: Every business change requires specialized teams to update pipelines, rebuild transformations, and modify dashboards. Want to add a new KPI? Submit a ticket, wait two weeks, get a dashboard update. By then, the question has changed.</p>
<p>The fundamental mismatch: <strong>BI delivers static visualizations to users who think in conversations</strong>.</p>
<h2 id="the-dirc-framework-discover-reason-coordinate">The DiRC Framework: Discover, Reason, Coordinate</h2>
<p>I structured Praval Analytics around DiRC—Discover-Reason-Coordinate—to replace ETL with agent-driven intelligence.</p>
<h3 id="discover-autonomous-schema-understanding">Discover: Autonomous Schema Understanding</h3>
<p>Instead of manually mapping source databases, AI agents autonomously explore them.</p>
<p><strong>Traditional approach</strong>: Data engineers study source systems, document schemas, write transformation SQL, build data models. Weeks of manual work. When schemas change, rinse and repeat.</p>
<p><strong>DiRC approach</strong>: Discovery agents scan PostgreSQL Foreign Data Wrappers, identify tables and columns, recognize semantic patterns (<code>def_cnt</code> → defect count, <code>prs_ln_a</code> → Press Line A), build a conceptual model automatically.</p>
<p>When a new production system comes online, discovery agents detect it. When columns rename, agents update their semantic mappings. No human intervention.</p>
<p><strong>Why this matters architecturally</strong>: Schema changes no longer break the system. Agents adapt. The semantic layer (Cube.js) provides a stable API that agents query, while discovery agents keep the underlying mappings current.</p>
<h3 id="reason-context-aware-intelligence">Reason: Context-Aware Intelligence</h3>
<p>Generic chatbots hallucinate when asked "What's our OEE?" Praval Analytics agents <em>understand</em> OEE—that it's Availability × Performance × Quality, that comparing Monday shifts requires accounting for weekend maintenance, that springback defects correlate with material grades.</p>
<p><strong>This requires domain-specialized agents</strong>:</p>
<p><strong>Manufacturing Advisor Agent</strong>: Knows production processes, equipment relationships, quality standards. Translates "springback issues" into "search defect_type='springback' + correlate with material_grade + check die_condition".</p>
<p><strong>Analytics Specialist Agent</strong>: Understands metrics definitions (OEE, first-pass yield, cycle time), knows which Cube.js cubes serve which questions, constructs optimized queries with appropriate measures and dimensions.</p>
<p><strong>Quality Inspector Agent</strong>: Performs statistical process control analysis, identifies anomalies, suggests root causes based on pattern correlation (defect spike + material change + die wear = likely cause chain).</p>
<p>These aren't generic LLMs. They're specialized agents with manufacturing domain knowledge encoded in their system prompts and memory.</p>
<h3 id="coordinate-event-driven-collaboration">Coordinate: Event-Driven Collaboration</h3>
<p>The architectural innovation: <strong>no central orchestrator</strong>.</p>
<p>When you ask "Why did defect rates spike on Line A?", here's what happens:</p>
<p><img alt="Agent Coordination Flow" src="/blog/ai-explorations/posts/2025-11-25-praval-analytics/images/agent-coordination.png" /><br />
<em>Five agents collaborate through event-driven Spore messages without central orchestration</em></p>
<ol>
<li>
<p><strong>Manufacturing Advisor</strong> receives question → enriches with domain context ("Line A" = 800T press, produces door panels) → broadcasts <code>domain_enriched_request</code> Spore</p>
</li>
<li>
<p><strong>Analytics Specialist</strong> receives Spore → queries Cube.js for defect trends, shift data, material correlations → broadcasts <code>data_ready</code> Spore</p>
</li>
<li>
<p><strong>Simultaneously</strong> (parallel execution):</p>
</li>
<li><strong>Visualization Specialist</strong> receives <code>data_ready</code> → prepares time-series defect chart + shift comparison</li>
<li>
<p><strong>Quality Inspector</strong> receives <code>data_ready</code> → performs anomaly detection → identifies springback defect correlation with Material Grade HC340LA from Supplier B</p>
</li>
<li>
<p><strong>Report Writer</strong> receives outputs from all agents → synthesizes into narrative: "Line A defect rates increased 23% due to springback issues on door outer left panels, correlated with HC340LA coils from Supplier B. Recommend reviewing coil certification and inspecting Die 002 for wear."</p>
</li>
</ol>
<p><strong>Total time: 3 seconds</strong>. Agents work in parallel, not sequentially.</p>
<p><strong>Critical architectural point</strong>: This only works because agents communicate via Praval's Reef substrate—an event bus where Spores (structured messages) flow without central routing. If one agent fails, others continue functioning. No single point of failure.</p>
<h2 id="the-data-architecture-bridging-legacy-and-semantic-layers">The Data Architecture: Bridging Legacy and Semantic Layers</h2>
<p>Manufacturing companies don't greenfield their data infrastructure. You inherit decades of accumulated systems: ERP databases, SCADA historians, MES platforms, quality management systems. Each with different schemas, different naming conventions, different update frequencies.</p>
<div class="mermaid-asset" style="--mermaid-natural-width: 1901px"><img src="/blog/ai-explorations/posts/2025-11-25-praval-analytics/images/mermaid/mermaid-01-d07beb4b97de.svg" alt="Mermaid diagram 1 for Praval Analytics – Reimagining Business Intelligence Through AI Agents" width="1901" height="142" decoding="async"></div>

<p><em>Foreign Data Wrappers connect source systems to unified warehouse, dbt transforms to analytics-ready marts, Cube.js provides semantic API</em></p>
<h3 id="layer-1-source-databases-the-reality">Layer 1: Source Databases (The Reality)</h3>
<p>In Praval Analytics demo, I simulated this with two PostgreSQL databases:<br />
- <strong>Press Line A DB</strong>: Door outer panel production (800T press)<br />
- <strong>Press Line B DB</strong>: Bonnet outer panel production (1200T press)<br />
- <strong>Die Management DB</strong>: Die changeover events, condition assessments<br />
- <strong>Material Tracking DB</strong>: 126 coils from 3 suppliers</p>
<p>Real manufacturing environments have dozens of source systems. The architecture must handle heterogeneity.</p>
<h3 id="layer-2-data-warehouse-with-foreign-data-wrappers">Layer 2: Data Warehouse with Foreign Data Wrappers</h3>
<p><strong>Architectural decision</strong>: Use PostgreSQL Foreign Data Wrappers instead of traditional ETL.</p>
<p><strong>Why?</strong> Foreign Data Wrappers let you query remote databases as if they were local tables. No data duplication. No complex ETL orchestration. No staleness from batch updates.</p>
<div class="codehilite"><pre><span></span><code><span class="k">CREATE</span><span class="w"> </span><span class="k">FOREIGN</span><span class="w"> </span><span class="k">TABLE</span><span class="w"> </span><span class="n">press_line_a_production</span>
<span class="n">SERVER</span><span class="w"> </span><span class="n">press_line_a_fdw</span>
<span class="k">OPTIONS</span><span class="w"> </span><span class="p">(</span><span class="k">schema_name</span><span class="w"> </span><span class="s1">&#39;public&#39;</span><span class="p">,</span><span class="w"> </span><span class="k">table_name</span><span class="w"> </span><span class="s1">&#39;production&#39;</span><span class="p">);</span>
</code></pre></div>

<p>Now agents can query <code>press_line_a_production</code> directly. When source data changes, queries see fresh data instantly. No ETL lag.</p>
<p><strong>Tradeoff</strong>: Query performance depends on source database responsiveness. For real-time dashboards, this could be problematic. But for conversational analytics where 3-second response time is acceptable, it works.</p>
<h3 id="layer-3-dbt-transformation-layer">Layer 3: dbt Transformation Layer</h3>
<p>Raw source data isn't analysis-ready. It needs cleaning, joining, aggregating. dbt (data build tool) handles this as version-controlled SQL transformations:</p>
<p><strong>4 staging models</strong>: Clean source data (handle nulls, standardize formats)<br />
<strong>2 intermediate models</strong>: Business logic (calculate OEE, join dimensions)<br />
<strong>3 mart models</strong>: Analytics-ready fact tables optimized for specific query patterns</p>
<p><strong>Why dbt instead of stored procedures?</strong> Version control. Testability. Documentation as code. When transformations change, you see exactly what changed in git diff. Tests run automatically. Documentation generates from model definitions.</p>
<p><strong>Agents don't query raw source data. They query marts.</strong> This insulates them from schema changes in source systems.</p>
<h3 id="layer-4-cubejs-semantic-layer">Layer 4: Cube.js Semantic Layer</h3>
<p>This is the architectural keystone. Cube.js sits between agents and data, providing a consistent API regardless of underlying schema changes.</p>
<p><strong>Three cubes</strong>:</p>
<p><strong><code>PressOperations</code></strong>: Production-level grain (one row per part produced)<br />
- Measures: OEE, defect counts, costs, cycle time, tonnage<br />
- Dimensions: Part family, press line, die, material, shift, operator, defect type</p>
<p><strong><code>PartFamilyPerformance</code></strong>: Aggregated by part type<br />
- Measures: First-pass yield, rework rate, total costs<br />
- Dimensions: Part family, material grade</p>
<p><strong><code>PressLineUtilization</code></strong>: Aggregated by line<br />
- Measures: Overall OEE, shift productivity<br />
- Dimensions: Press line, shift</p>
<p>Agents query Cube.js using semantic names ("weekly defect rate trends"). Cube.js translates to optimized SQL, handles joins, manages pre-aggregations for performance.</p>
<p><strong>Why agents need this</strong>: Without a semantic layer, agents would have to know table structures, join keys, aggregation logic. Every schema change would require updating agent prompts. Cube.js decouples agents from database details.</p>
<h2 id="the-five-agent-architecture">The Five-Agent Architecture</h2>
<p>Each agent has a specialized role. This isn't arbitrary—specialization emerged from production failures in early versions.</p>
<h3 id="manufacturing-advisor-the-domain-expert">Manufacturing Advisor: The Domain Expert</h3>
<p><strong>Early mistake</strong>: I had a generic "input processor" agent that blindly forwarded user questions to the analytics agent. Results were terrible. "Show me springback issues" got treated like a generic text search.</p>
<p><strong>Solution</strong>: Manufacturing Advisor agent with domain knowledge.</p>
<p>System prompt includes:<br />
- Equipment hierarchies (Line A = 800T press, produces doors)<br />
- Defect types (springback, wrinkle, splits, scratches)<br />
- Material grades (HC340LA, DC06, SPCC)<br />
- Manufacturing relationships (defect patterns correlate with material + die condition)</p>
<p>When user asks "springback issues on Line A", Manufacturing Advisor:<br />
1. Recognizes "springback" as a sheet metal defect caused by elastic recovery<br />
2. Knows Line A produces door outer panels (more susceptible to springback than bonnets)<br />
3. Enriches query: "Search defect_type='springback' for part_family='Door_Outer*' from press_line='Line_A', correlate with material_grade and die_condition"<br />
4. Broadcasts enriched request as Spore</p>
<p><strong>This agent doesn't retrieve data.</strong> It translates human language to manufacturing concepts.</p>
<h3 id="analytics-specialist-the-query-translator">Analytics Specialist: The Query Translator</h3>
<p>Receives domain-enriched requests from Manufacturing Advisor. Has deep knowledge of:<br />
- Cube.js schema (which cubes, which measures, which dimensions)<br />
- Query optimization (pre-aggregations, time-series patterns)<br />
- When to use <code>PressOperations</code> (detail-level queries) vs <code>PartFamilyPerformance</code> (aggregated)</p>
<p>Constructs Cube.js queries:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nx">measures</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;PressOperations.defectRate&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;PressOperations.defectCount&#39;</span><span class="p">],</span>
<span class="w">  </span><span class="nx">dimensions</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;PressOperations.defectType&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;PressOperations.materialGrade&#39;</span><span class="p">],</span>
<span class="w">  </span><span class="nx">filters</span><span class="o">:</span><span class="w"> </span><span class="p">[</span>
<span class="w">    </span><span class="p">{</span><span class="w"> </span><span class="nx">member</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;PressOperations.defectType&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">operator</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;equals&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">values</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;springback&#39;</span><span class="p">]</span><span class="w"> </span><span class="p">},</span>
<span class="w">    </span><span class="p">{</span><span class="w"> </span><span class="nx">member</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;PressOperations.partFamily&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">operator</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;startsWith&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">values</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;Door_Outer&#39;</span><span class="p">]</span><span class="w"> </span><span class="p">}</span>
<span class="w">  </span><span class="p">],</span>
<span class="w">  </span><span class="nx">timeDimensions</span><span class="o">:</span><span class="w"> </span><span class="p">[</span>
<span class="w">    </span><span class="p">{</span><span class="w"> </span><span class="nx">dimension</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;PressOperations.productionDate&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">granularity</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;day&#39;</span><span class="p">,</span><span class="w"> </span><span class="nx">dateRange</span><span class="o">:</span><span class="w"> </span><span class="s1">&#39;last 7 days&#39;</span><span class="w"> </span><span class="p">}</span>
<span class="w">  </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>

<p>Executes query, receives results, broadcasts <code>data_ready</code> Spore.</p>
<h3 id="visualization-specialist-the-chart-selector">Visualization Specialist: The Chart Selector</h3>
<p><strong>Receives</strong>: Structured data from Analytics Specialist<br />
<strong>Decides</strong>: What chart type communicates insights best</p>
<ul>
<li>Defect trends over time → line chart</li>
<li>Press line comparison → side-by-side bar chart</li>
<li>Shift productivity → stacked area chart showing contribution</li>
<li>Material grade correlation → scatter plot with regression</li>
</ul>
<p><strong>Architectural constraint</strong>: Mobile-first design. Charts must work on factory floor phones, not just desktop monitors. This means simplified layouts, large touch targets, minimal clutter.</p>
<p>Outputs chart specification (Recharts format for React frontend).</p>
<h3 id="quality-inspector-the-anomaly-detective">Quality Inspector: The Anomaly Detective</h3>
<p>Runs in parallel with Visualization Specialist (this is why event-driven matters—parallel execution).</p>
<p><strong>Receives</strong>: Same data as Visualization Specialist<br />
<strong>Performs</strong>:<br />
- Statistical process control analysis (detect outliers beyond 3σ)<br />
- Pattern correlation (do defect spikes correlate with material changes? die wear? shift changes?)<br />
- Root cause hypotheses based on domain rules</p>
<p>For example, detecting:<br />
- Springback defects + Material Grade HC340LA from Supplier B → supplier material quality issue<br />
- Defect spike after die changeover → setup/alignment problem<br />
- Gradual quality degradation → die wear</p>
<p><strong>Output</strong>: List of anomalies with suggested root causes, ranked by confidence.</p>
<h3 id="report-writer-the-synthesizer">Report Writer: The Synthesizer</h3>
<p><strong>Receives</strong>: Outputs from all agents (chart spec, data, anomalies, root causes)<br />
<strong>Produces</strong>: Narrative explanation in plain language</p>
<p>Knows:<br />
- Engineers want root causes, not just symptoms<br />
- Mobile consumption requires concise prose (not verbose paragraphs)<br />
- Recommendations must be specific and actionable<br />
- Follow-up questions drive continuous exploration</p>
<p>Example output:</p>
<blockquote>
<p>"Line A defect rates increased 23% yesterday due to springback issues affecting door outer left panels. Analysis shows strong correlation with Material Grade HC340LA from Supplier B. Quality Inspector detected this pattern across 8 production runs. Recommend: (1) Review coil certification for affected batches, (2) Inspect Die 002 for wear, (3) Consider die maintenance if issue persists."</p>
<p><strong>Follow-up questions</strong>:<br />
- "Show me all Supplier B material defect history"<br />
- "Compare Die 002 condition to other dies"<br />
- "What's the cost impact of this defect spike?"</p>
</blockquote>
<p><strong>Architectural benefit</strong>: Report Writer is the only agent that talks to users. All others communicate via Spores. This separation makes it easy to swap Report Writer implementations (e.g., different verbosity levels, different languages) without touching other agents.</p>
<h2 id="why-no-orchestrator-the-case-for-event-driven">Why No Orchestrator? The Case for Event-Driven</h2>
<p>Traditional architectures would use an orchestrator: a central service that calls agents sequentially, waits for responses, coordinates flow.</p>
<p><strong>I explicitly avoided this.</strong> Here's why:</p>
<p><strong>Single point of failure</strong>: If orchestrator crashes, entire system stops. With event-driven, individual agents can fail without cascading.</p>
<p><strong>Sequential latency</strong>: Orchestrator calls Analytics Specialist → waits for data → calls Visualization Specialist → waits for chart → calls Quality Inspector → waits for analysis. Total: 8+ seconds. Event-driven: Analytics broadcasts data, Viz + Quality run in parallel. Total: 3 seconds.</p>
<p><strong>Tight coupling</strong>: Orchestrator needs to know all agents, their APIs, their expected inputs/outputs. Adding a new agent requires updating orchestrator logic. Event-driven: new agent subscribes to relevant Spores. No central changes needed.</p>
<p><strong>No graceful degradation</strong>: If Quality Inspector fails in orchestrated flow, whole response fails. In event-driven, Report Writer synthesizes from available outputs. Missing one agent's input reduces answer quality but doesn't crash the system.</p>
<p><strong>The tradeoff</strong>: Debugging distributed event flows is harder than tracing orchestrated calls. When something goes wrong, you're hunting through RabbitMQ message logs across multiple agents. Worth it for the resilience and performance benefits, but not free.</p>
<h2 id="the-real-time-question-pipeline">The Real-Time Question Pipeline</h2>
<p>Let's trace a complete user interaction architecturally:</p>
<p><strong>User (phone, factory floor)</strong>: "Why did Line A OEE drop yesterday?"</p>
<p><strong>Frontend → Backend API (FastAPI)</strong>:</p>
<div class="codehilite"><pre><span></span><code>POST /analytics/query
{
  &quot;question&quot;: &quot;Why did Line A OEE drop yesterday?&quot;,
  &quot;conversation_id&quot;: &quot;&lt;uuid&gt;&quot;
}
</code></pre></div>

<p><strong>Manufacturing Advisor Agent</strong>:<br />
- Receives question via Reef subscription<br />
- Domain enrichment: "Line A" = 800T press, OEE = Availability × Performance × Quality<br />
- Broadcasts Spore:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;domain_enriched_request&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;original_question&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Why did Line A OEE drop yesterday?&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;context&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">&quot;press_line&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Line_A&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;equipment&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;800T press&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;part_family&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;Door outer panels&quot;</span><span class="p">,</span>
<span class="w">    </span><span class="nt">&quot;metrics&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;OEE&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;availability&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;performance&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;quality_rate&quot;</span><span class="p">],</span>
<span class="w">    </span><span class="nt">&quot;time_range&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;yesterday&quot;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p><strong>Analytics Specialist Agent</strong>:<br />
- Receives <code>domain_enriched_request</code> Spore<br />
- Queries Cube.js <code>PressLineUtilization</code> cube:<br />
  - Measures: OEE, availability, performance, quality_rate<br />
  - Filters: press_line='Line_A', date=yesterday<br />
  - Comparison: vs. 7-day average<br />
- Receives data: OEE 78.3% vs avg 82.1% (-3.8 pts)<br />
- Breakdown: availability 92% (↓2%), performance 94% (↓1%), quality 90% (↓0.8%)<br />
- Broadcasts Spore:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w">  </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;data_ready&quot;</span><span class="p">,</span>
<span class="w">  </span><span class="nt">&quot;data&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="err">...</span><span class="w"> </span><span class="p">},</span>
<span class="w">  </span><span class="nt">&quot;insights&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;OEE declined primarily due to availability drop (2% reduction)&quot;</span>
<span class="p">}</span>
</code></pre></div>

<p><strong>Simultaneously</strong>:</p>
<p><strong>Visualization Specialist</strong>:<br />
- Receives <code>data_ready</code><br />
- Selects chart type: time-series line chart (OEE trend) + bar chart (component breakdown)<br />
- Generates chart spec for frontend</p>
<p><strong>Quality Inspector</strong>:<br />
- Receives <code>data_ready</code><br />
- Correlates availability drop with production events<br />
- Finds: 3 die changeovers yesterday vs typical 1.5 per week<br />
- Each changeover: ~30 min downtime<br />
- Root cause hypothesis: Increased changeover frequency reduced available production time</p>
<p><strong>Report Writer</strong>:<br />
- Waits for all agents to respond (3-second timeout, uses whatever's available)<br />
- Receives: data, chart spec, root cause analysis<br />
- Synthesizes narrative:</p>
<blockquote>
<p>"Line A OEE dropped to 78.3% yesterday, down 3.8 points from the 82.1% weekly average. The decline was primarily driven by reduced availability (92% vs typical 94%), caused by 3 die changeovers compared to the usual 1.5 per week. Each changeover resulted in approximately 30 minutes of downtime."</p>
<p>"Performance and quality rates remained relatively stable, indicating the issue is operational (changeover frequency) rather than equipment degradation or quality problems."</p>
<p><strong>Recommendations:</strong><br />
- Review changeover schedule: Were 3 changeovers necessary or could they be consolidated?<br />
- If changeovers were planned, consider optimizing changeover procedure to reduce downtime<br />
- Monitor availability over next week to see if pattern continues</p>
<p><strong>Follow-up questions:</strong><br />
- "Show me changeover frequency trends over the past month"<br />
- "Compare Line A and Line B changeover downtime"<br />
- "What's the typical changeover time for each die?"</p>
</blockquote>
<p><strong>Backend → Frontend</strong>: Returns JSON with narrative + chart specs<br />
<strong>Frontend</strong>: Renders response with charts + follow-up question buttons</p>
<p><strong>Total latency</strong>: 3 seconds from question to answer.</p>
<h2 id="the-technology-stack-why-these-choices">The Technology Stack: Why These Choices?</h2>
<p>Every technology decision was architectural, not arbitrary.</p>
<p><strong>PostgreSQL for source databases</strong>: Manufacturing data is inherently relational. Equipment hierarchies, bill of materials, quality inspection workflows—these are relational concepts. NoSQL would fight the problem domain.</p>
<p><strong>Foreign Data Wrappers over ETL</strong>: Real-time data access without duplication. ETL introduces lag and staleness. Manufacturing decisions happen on factory floors, not in nightly batch cycles.</p>
<p><strong>dbt for transformations</strong>: Version-controlled SQL beats stored procedures for maintainability. Tests run automatically. Documentation generates from code. Changes are traceable.</p>
<p><strong>Cube.js for semantic layer</strong>: Decouples agents from database schemas. Pre-aggregations provide sub-100ms query performance even on complex joins. REST API integrates easily with FastAPI backend.</p>
<p><strong>Praval framework for agents</strong>: Built for identity-driven, memory-enabled, event-driven multi-agent systems. Reef substrate handles Spore routing. Memory APIs let agents learn from interactions. OpenTelemetry integration provides observability.</p>
<p><strong>RabbitMQ for message bus</strong>: Reliable, durable message delivery. Agents can fail and restart without losing in-flight Spores. Scales horizontally (add more consumer agents for parallel processing).</p>
<p><strong>FastAPI for backend</strong>: Async by default, perfect for event-driven architecture. Type hints provide auto-generated API docs. SSE support for real-time streaming to frontend.</p>
<p><strong>Next.js for frontend</strong>: React with server-side rendering. TypeScript for type safety. API routes for simple backend-for-frontend pattern.</p>
<h2 id="what-i-learned-building-this">What I Learned Building This</h2>
<h3 id="1-domain-knowledge-is-the-unlock">1. Domain Knowledge Is the Unlock</h3>
<p>Generic LLMs can't reason about manufacturing without context. Early versions produced plausible-sounding nonsense: "OEE declined due to reduced efficiency" (circular), "Consider optimizing production" (meaningless).</p>
<p>Manufacturing Advisor agent with domain knowledge transformed results. It knows springback is a sheet metal defect, that it correlates with material grade and tonnage, that it affects certain part geometries more than others.</p>
<p><strong>Lesson</strong>: Multi-agent systems need at least one agent that deeply understands the problem domain. Generalist approaches fail.</p>
<h3 id="2-parallel-agent-execution-matters-enormously">2. Parallel Agent Execution Matters Enormously</h3>
<p>Sequential processing (Advisor → Analytics → Viz → Quality → Report): 8+ seconds<br />
Parallel processing (Analytics broadcasts, Viz + Quality run simultaneously): 3 seconds</p>
<p>For conversational interfaces, 3 seconds feels instant. 8 seconds feels broken. Users abandon queries.</p>
<p><strong>Event-driven architecture enables parallelism naturally.</strong> Orchestrators impose sequencing.</p>
<h3 id="3-semantic-layers-prevent-agent-drift">3. Semantic Layers Prevent Agent Drift</h3>
<p>Without Cube.js enforcing metric definitions, different agents calculated OEE differently:<br />
- Manufacturing Advisor: "OEE from press_operations table"<br />
- Analytics Specialist: "OEE = (output / target) * quality_rate"<br />
- Quality Inspector: "OEE = availability * performance * quality"</p>
<p>Three different calculations → contradictory insights → user confusion.</p>
<p>Cube.js enforces one canonical definition. All agents query the same semantic API. Consistency guaranteed.</p>
<h3 id="4-users-want-root-causes-not-just-data">4. Users Want Root Causes, Not Just Data</h3>
<p>Early versions returned: "Defect rate increased 23%." Factually correct. Utterly useless.</p>
<p>After adding Quality Inspector's root cause analysis: "Defect rate increased 23% due to springback issues correlated with Material Grade HC340LA from Supplier B."</p>
<p>Engagement tripled. Users care about <em>why</em>, not just <em>what</em>.</p>
<h3 id="5-event-driven-coordination-is-more-complex-than-orchestration">5. Event-Driven Coordination Is More Complex Than Orchestration</h3>
<p>Debugging: "Why didn't Report Writer include quality analysis?"<br />
- Check if Quality Inspector emitted analysis Spore<br />
- Check if RabbitMQ delivered it<br />
- Check if Report Writer subscribed to correct topic<br />
- Check timeout values (did Report Writer time out before Quality Inspector responded?)</p>
<p>With orchestration, you'd just step through function calls.</p>
<p>Worth the complexity for resilience and parallelism. But not free.</p>
<h2 id="whats-next">What's Next</h2>
<p><strong>Proactive Anomaly Alerts</strong>: Quality Inspector currently runs reactively (user asks question → analysis runs). Next: continuous monitoring mode. Quality Inspector subscribes to <code>production_data_updated</code> events, scans for anomalies, broadcasts alerts proactively. Plant managers get notifications before they ask.</p>
<p><strong>Multi-Turn Reasoning</strong>: Current architecture handles single-turn queries well. Complex questions ("Compare Line A to Line B, identify which part family drives the difference, then show material grade impact") require multi-turn agent coordination. Planning: first agent breaks question into subqueries, subsequent agents handle each sequentially.</p>
<p><strong>Agent Explanation Mode</strong>: Users can't see why agents chose specific chart types or analysis approaches. Planning: each agent includes reasoning in Spores. Frontend exposes "Why this chart?" button that shows Visualization Specialist's decision logic.</p>
<p><strong>Feedback Loop</strong>: Agents currently don't learn from user corrections. If user corrects an insight ("Actually that material grade is fine, we verified it"), agents should remember and adjust future analysis. Planning: store corrections in agent episodic memory, surface during similar future queries.</p>
<p><strong>Extended Domain Support</strong>: Currently manufacturing-specific. The architecture (domain agent → semantic layer → specialized analysts → synthesis) applies to other domains: retail (sales analytics), healthcare (patient outcome analysis), finance (risk assessment). Planning: make Manufacturing Advisor pluggable, define domain-specific semantic layers.</p>
<h2 id="try-it-yourself">Try It Yourself</h2>
<p>Praval Analytics is open source and fully containerized:</p>
<ul>
<li><strong>GitHub</strong>: <a href="https://github.com/aiexplorations/praval_mds_analytics">github.com/aiexplorations/praval_mds_analytics</a></li>
<li><strong>Deployment</strong>: <code>docker-compose up -d</code> (starts 6 containers: agents, frontend, databases)</li>
<li><strong>Architecture Docs</strong>: <a href="https://github.com/aiexplorations/praval_mds_analytics/blob/main/docs/AGENT_ARCHITECTURE.md">AGENT_ARCHITECTURE.md</a></li>
</ul>
<p>The codebase demonstrates:<br />
- Multi-agent event-driven coordination via Praval Reef<br />
- Hybrid storage (PostgreSQL + Cube.js semantic layer)<br />
- Domain-specialized agents with memory<br />
- Production observability (structured logging, health checks)<br />
- Real-world manufacturing data model (press lines, defects, materials)</p>
<p>If you're exploring conversational analytics, multi-agent architectures, or Praval framework for production systems, I'd love to hear what you build.</p>
<p>The future of business intelligence isn't better dashboards. It's eliminating dashboards entirely and replacing them with conversations that understand context, reason about causality, and deliver insights at the speed of questions.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>praval</category>
      <category>analytics</category>
      <category>bi</category>
      <category>manufacturing</category>
      <category>multi-agent</category>
      <category>project-spotlight</category>
    </item>
    <item>
      <title>Praval – Agentic AI Framework in Python</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-11-14-praval-agentic-ai-framework.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-11-14-praval-agentic-ai-framework.html</guid>
      <pubDate>Fri, 14 Nov 2025 00:00:00 GMT</pubDate>
      <description>Over the last several months, I have been building and developing Praval, a Pythonic agentic AI framework for multi-agent system development inspired by coral ecosystems.</description>
      <content:encoded><![CDATA[<p>Over the last several months, I have been building and developing <strong>Praval</strong>, a Pythonic agentic AI framework for multi-agent system development. Praval takes inspiration from coral ecosystems, where specialized agents interact as peers to create emergent intelligence.</p>
<h2 id="why-i-built-praval">Why I Built Praval</h2>
<ol>
<li>
<p><strong>Non-hierarchical, emergent systems</strong><br />
   Maintainable agentic systems are still rare; most frameworks assume a manager–worker hierarchy. Praval is explicitly designed for ecosystems of peers, where intelligence emerges from many specialized agents interacting rather than from one "boss agent" orchestrating everything.</p>
</li>
<li>
<p><strong>Agent-to-agent communication as a first-class concern</strong><br />
   In many frameworks, communication is a bolt-on capability. In Praval, <strong>Reef</strong>, the communication substrate, is core to the design: agents send and receive structured messages called <strong>spores</strong>.</p>
</li>
<li>
<p><strong>Native memory instead of bolt-on vector stores</strong><br />
   Memory in most frameworks is an afterthought. Praval ships with a multi-layered memory system powered by ChromaDB, integrated into the agent lifecycle rather than glued on later. Praval also supports Qdrant.</p>
</li>
<li>
<p><strong>Self-documenting, sensible defaults</strong><br />
   I wanted a framework where the default configuration already gives you observability, memory, and reasonable behaviors, without needing to wire every subsystem by hand.</p>
</li>
<li>
<p><strong>Observability and operability from day one</strong><br />
   Praval embraces OpenTelemetry and structured logging so that distributed multi-agent systems can be monitored, traced, and debugged like modern microservices.</p>
</li>
</ol>
<h2 id="core-concepts-in-praval">Core Concepts in Praval</h2>
<p>At a high level, Praval gives you:</p>
<ul>
<li><strong>Agents</strong> – Python functions decorated with <code>@agent()</code> that become autonomous workers in your ecosystem.</li>
<li><strong>Spores</strong> – Structured messages carrying knowledge (<code>spore.knowledge</code>) between agents.</li>
<li><strong>Reef</strong> – The communication substrate where agents broadcast and listen for spores.</li>
<li><strong>Memory</strong> – Multi-layered storage (short-term, long-term, episodic, semantic) with ChromaDB integration.</li>
<li><strong>Tools</strong> – Decorator-based functions that agents can call to interact with external systems.</li>
<li><strong>Observability</strong> – Built-in tracing and logging via OpenTelemetry-compatible outputs.</li>
</ul>
<p>The <a href="https://github.com/aiexplorations/praval/blob/main/README.md">README</a> has a detailed overview of these ideas, but I'll sketch a compact tour here.</p>
<h2 id="getting-started-installation">Getting Started: Installation</h2>
<p>Praval is published on PyPI:</p>
<div class="codehilite"><pre><span></span><code>pip<span class="w"> </span>install<span class="w"> </span>praval
</code></pre></div>

<p>For memory-enabled agents:</p>
<div class="codehilite"><pre><span></span><code>pip<span class="w"> </span>install<span class="w"> </span>praval<span class="o">[</span>memory<span class="o">]</span>
</code></pre></div>

<p>For all features (secure messaging, extra storage backends, etc.):</p>
<div class="codehilite"><pre><span></span><code>pip<span class="w"> </span>install<span class="w"> </span>praval<span class="o">[</span>all<span class="o">]</span>
</code></pre></div>

<p>You'll need at least one LLM provider key (OpenAI, Anthropic, or Cohere). Praval looks for these in your environment. I've tested the framework extensively with OpenAI, and it works well with multiple OpenAI models.</p>
<div class="codehilite"><pre><span></span><code><span class="nb">export</span><span class="w"> </span><span class="nv">OPENAI_API_KEY</span><span class="o">=</span><span class="s2">&quot;sk-...&quot;</span>
<span class="nb">export</span><span class="w"> </span><span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span><span class="s2">&quot;sk-ant-...&quot;</span>
<span class="nb">export</span><span class="w"> </span><span class="nv">COHERE_API_KEY</span><span class="o">=</span><span class="s2">&quot;sk-cohere-...&quot;</span>

<span class="c1"># Praval-specific defaults for model selection</span>
<span class="nb">export</span><span class="w"> </span><span class="nv">PRAVAL_DEFAULT_PROVIDER</span><span class="o">=</span><span class="s2">&quot;openai&quot;</span>
<span class="nb">export</span><span class="w"> </span><span class="nv">PRAVAL_DEFAULT_MODEL</span><span class="o">=</span><span class="s2">&quot;gpt-4o-mini&quot;</span>
</code></pre></div>

<p>Here, <code>PRAVAL_DEFAULT_PROVIDER</code> selects OpenAI as the primary LLM provider, and <code>PRAVAL_DEFAULT_MODEL</code> sets the default OpenAI model used by <code>chat()</code> and other helpers. You can override these in code or per-agent configuration if needed.</p>
<h2 id="example-a-simple-agent-ecosystem">Example: A Simple Agent Ecosystem</h2>
<p>Here is a minimal multi-agent example inspired by the Praval README and the praval-ai website. Three agents—<code>researcher</code>, <code>analyst</code>, and <code>writer</code>—collaborate via spores on the Reef:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">import</span><span class="w"> </span><span class="nn">time</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">praval</span><span class="w"> </span><span class="kn">import</span> <span class="n">agent</span><span class="p">,</span> <span class="n">chat</span><span class="p">,</span> <span class="n">broadcast</span><span class="p">,</span> <span class="n">start_agents</span><span class="p">,</span> <span class="n">get_reef</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;researcher&quot;</span><span class="p">,</span> <span class="n">responds_to</span><span class="o">=</span><span class="p">[</span><span class="s2">&quot;query&quot;</span><span class="p">])</span>
<span class="k">def</span><span class="w"> </span><span class="nf">researcher</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;I research topics deeply.&quot;&quot;&quot;</span>
    <span class="n">topic</span> <span class="o">=</span> <span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;topic&quot;</span><span class="p">,</span> <span class="s2">&quot;AI&quot;</span><span class="p">)</span>
    <span class="n">findings</span> <span class="o">=</span> <span class="n">chat</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Research: </span><span class="si">{</span><span class="n">topic</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="n">broadcast</span><span class="p">({</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;analysis_request&quot;</span><span class="p">,</span> <span class="s2">&quot;data&quot;</span><span class="p">:</span> <span class="n">findings</span><span class="p">})</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;analyst&quot;</span><span class="p">,</span> <span class="n">responds_to</span><span class="o">=</span><span class="p">[</span><span class="s2">&quot;analysis_request&quot;</span><span class="p">])</span>
<span class="k">def</span><span class="w"> </span><span class="nf">analyst</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;I analyze data for insights.&quot;&quot;&quot;</span>
    <span class="n">insights</span> <span class="o">=</span> <span class="n">chat</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Analyze: </span><span class="si">{</span><span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;data&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="n">broadcast</span><span class="p">({</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;report&quot;</span><span class="p">,</span> <span class="s2">&quot;insights&quot;</span><span class="p">:</span> <span class="n">insights</span><span class="p">})</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;writer&quot;</span><span class="p">,</span> <span class="n">responds_to</span><span class="o">=</span><span class="p">[</span><span class="s2">&quot;report&quot;</span><span class="p">])</span>
<span class="k">def</span><span class="w"> </span><span class="nf">writer</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
<span class="w">    </span><span class="sd">&quot;&quot;&quot;I create polished reports.&quot;&quot;&quot;</span>
    <span class="n">report</span> <span class="o">=</span> <span class="n">chat</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Write: </span><span class="si">{</span><span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s1">&#39;insights&#39;</span><span class="p">,</span><span class="w"> </span><span class="s1">&#39;&#39;</span><span class="p">)</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Report generated:</span><span class="se">\\</span><span class="s2">n</span><span class="si">{</span><span class="n">report</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>

<span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&quot;__main__&quot;</span><span class="p">:</span>
    <span class="n">start_agents</span><span class="p">(</span>
        <span class="n">researcher</span><span class="p">,</span>
        <span class="n">analyst</span><span class="p">,</span>
        <span class="n">writer</span><span class="p">,</span>
        <span class="n">initial_data</span><span class="o">=</span><span class="p">{</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;query&quot;</span><span class="p">,</span> <span class="s2">&quot;topic&quot;</span><span class="p">:</span> <span class="s2">&quot;multi-agent AI systems&quot;</span><span class="p">},</span>
    <span class="p">)</span>

    <span class="c1"># Allow agents time to exchange spores and complete LLM calls</span>
    <span class="n">time</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>

    <span class="c1"># Gracefully shut down the reef</span>
    <span class="n">get_reef</span><span class="p">()</span><span class="o">.</span><span class="n">shutdown</span><span class="p">(</span><span class="n">wait</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</code></pre></div>

<p>What's notable here is not just the brevity, but the lack of orchestration glue. Agents are functions, communication is implicit via spores, and the Reef takes care of message delivery.</p>
<h2 id="architecture">Architecture</h2>
<p>Praval is built around a few core ideas that make it flexible and powerful:</p>
<p><img alt="Praval Architecture Diagram" src="/blog/ai-explorations/posts/2025-11-14-praval-agentic-ai-framework/images/architecture.png" /></p>
<h2 id="feature-highlights">Feature Highlights</h2>
<h3 id="1-decorator-based-agents">1. Decorator-Based Agents</h3>
<p>Praval agents are plain Python functions decorated with <code>@agent()</code>:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">praval</span><span class="w"> </span><span class="kn">import</span> <span class="n">agent</span><span class="p">,</span> <span class="n">chat</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;summarizer&quot;</span><span class="p">,</span> <span class="n">responds_to</span><span class="o">=</span><span class="p">[</span><span class="s2">&quot;summarize&quot;</span><span class="p">])</span>
<span class="k">def</span><span class="w"> </span><span class="nf">summarizer_agent</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="p">[</span><span class="s2">&quot;text&quot;</span><span class="p">]</span>
    <span class="n">summary</span> <span class="o">=</span> <span class="n">chat</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Summarize this: </span><span class="si">{</span><span class="n">text</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">{</span><span class="s2">&quot;summary&quot;</span><span class="p">:</span> <span class="n">summary</span><span class="p">}</span>
</code></pre></div>

<p>This keeps the API surface small and familiar, and it scales well across teams: every agent is just a normal function plus a bit of metadata.</p>
<h3 id="2-reef-native-agent-to-agent-communication">2. Reef: Native Agent-to-Agent Communication</h3>
<p>Reef is Praval's communication layer. Agents exchange spores—structured JSON-like messages:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">praval</span><span class="w"> </span><span class="kn">import</span> <span class="n">agent</span><span class="p">,</span> <span class="n">broadcast</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;notifier&quot;</span><span class="p">,</span> <span class="n">responds_to</span><span class="o">=</span><span class="p">[</span><span class="s2">&quot;build_complete&quot;</span><span class="p">])</span>
<span class="k">def</span><span class="w"> </span><span class="nf">notifier</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
    <span class="n">status</span> <span class="o">=</span> <span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="p">[</span><span class="s2">&quot;status&quot;</span><span class="p">]</span>
    <span class="nb">print</span><span class="p">(</span><span class="sa">f</span><span class="s2">&quot;Build completed with status: </span><span class="si">{</span><span class="n">status</span><span class="si">}</span><span class="s2">&quot;</span><span class="p">)</span>

<span class="c1"># Somewhere else in your ecosystem:</span>
<span class="n">broadcast</span><span class="p">({</span><span class="s2">&quot;type&quot;</span><span class="p">:</span> <span class="s2">&quot;build_complete&quot;</span><span class="p">,</span> <span class="s2">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;success&quot;</span><span class="p">})</span>
</code></pre></div>

<p>Under the hood, Reef can run in-memory for simple setups, or use backends like RabbitMQ for distributed, enterprise-grade deployments.</p>
<h3 id="3-multi-layered-memory-with-chromadb">3. Multi-Layered Memory with ChromaDB</h3>
<p>Praval ships with a memory system that supports:</p>
<ul>
<li>Short-term working memory</li>
<li>Long-term vector memory via ChromaDB</li>
<li>Episodic experience tracking</li>
<li>Semantic knowledge storage</li>
</ul>
<p>You can enable memory using the appropriate extras (<code>praval[memory]</code>) and then configure it with environment variables or code. The memory abstractions are documented <a href="https://github.com/aiexplorations/praval/blob/main/docs/memory-system.md">here</a>.</p>
<h3 id="4-observability-with-opentelemetry">4. Observability with OpenTelemetry</h3>
<p>Observability is built in. You can view recent traces in the console or export them to your observability stack:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">praval</span><span class="w"> </span><span class="kn">import</span> <span class="n">agent</span><span class="p">,</span> <span class="n">chat</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">praval.observability</span><span class="w"> </span><span class="kn">import</span> <span class="n">show_recent_traces</span><span class="p">,</span> <span class="n">export_traces_to_otlp</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;researcher&quot;</span><span class="p">)</span>
<span class="k">def</span><span class="w"> </span><span class="nf">research_agent</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
    <span class="k">return</span> <span class="p">{</span><span class="s2">&quot;findings&quot;</span><span class="p">:</span> <span class="n">chat</span><span class="p">(</span><span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="p">[</span><span class="s2">&quot;topic&quot;</span><span class="p">])}</span>

<span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&quot;__main__&quot;</span><span class="p">:</span>
    <span class="c1"># Run some agents, then:</span>
    <span class="n">show_recent_traces</span><span class="p">(</span><span class="n">limit</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
    <span class="n">export_traces_to_otlp</span><span class="p">(</span><span class="s2">&quot;http://localhost:4318/v1/traces&quot;</span><span class="p">)</span>
</code></pre></div>

<p>Because this is built on OpenTelemetry, you can plug Praval into systems like Jaeger, Zipkin, or DataDog without custom bridging code.</p>
<h3 id="5-tooling-giving-agents-external-capabilities">5. Tooling: Giving Agents External Capabilities</h3>
<p>Praval has a decorator-based tool system so agents can call out to external services:</p>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">praval.tools</span><span class="w"> </span><span class="kn">import</span> <span class="n">tool</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">praval</span><span class="w"> </span><span class="kn">import</span> <span class="n">agent</span>

<span class="nd">@tool</span><span class="p">(</span><span class="s2">&quot;web_search&quot;</span><span class="p">,</span> <span class="n">description</span><span class="o">=</span><span class="s2">&quot;Search the web&quot;</span><span class="p">,</span> <span class="n">shared</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
<span class="k">def</span><span class="w"> </span><span class="nf">search_web</span><span class="p">(</span><span class="n">query</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
    <span class="c1"># Replace this with your actual search integration</span>
    <span class="k">return</span> <span class="sa">f</span><span class="s2">&quot;Pretend search results for: </span><span class="si">{</span><span class="n">query</span><span class="si">}</span><span class="s2">&quot;</span>

<span class="nd">@agent</span><span class="p">(</span><span class="s2">&quot;researcher&quot;</span><span class="p">)</span>
<span class="k">def</span><span class="w"> </span><span class="nf">research_agent</span><span class="p">(</span><span class="n">spore</span><span class="p">):</span>
    <span class="n">query</span> <span class="o">=</span> <span class="n">spore</span><span class="o">.</span><span class="n">knowledge</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&quot;query&quot;</span><span class="p">,</span> <span class="s2">&quot;Praval multi-agent framework&quot;</span><span class="p">)</span>
    <span class="n">results</span> <span class="o">=</span> <span class="n">search_web</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">{</span><span class="s2">&quot;results&quot;</span><span class="p">:</span> <span class="n">results</span><span class="p">}</span>
</code></pre></div>

<p>This pattern keeps your agents composable and testable: tools are just functions you can unit-test independently.</p>
<h2 id="how-im-using-praval">How I'm Using Praval</h2>
<p>Praval has already powered a few experimental applications:</p>
<ul>
<li><strong>Praval Deep Research</strong> – a microservices-based research assistant that orchestrates multiple agents to perform deep literature and web analysis.</li>
<li><strong>Praval Analytics</strong> – an agentic BI demo application that combines data exploration with conversational interfaces.</li>
</ul>
<p>These apps are still evolving, but they validate the idea that "agentifying" Python workflows becomes much simpler with Praval. For simple multi-agent projects, an in-memory Reef inside a single container is enough. For more demanding workloads, a RabbitMQ-backed Reef and proper observability give you a production-grade path.</p>
<h2 id="building-praval-process-and-learnings">Building Praval: Process and Learnings</h2>
<p>Building Praval has been an exciting experience. I used Claude Code extensively for much of the development and engineering. The pace of development was faster than I expected, but not without challenges. Designing Praval forced me to think deeply about:</p>
<ul>
<li>The needs of agents in real-world applications</li>
<li>The tradeoffs between simplicity and power in API design</li>
<li>How to bake in memory, observability, and security without overwhelming users</li>
</ul>
<p>I relied on diagrams and architecture sketches before writing code, then iterated quickly with tests and example applications. Building apps like Deep Research and Analytics on early Praval builds revealed plenty of rough edges, which I've been smoothing out through the 0.7.x releases.</p>
<h2 id="acknowledgements">Acknowledgements</h2>
<p>Praval would not have been possible without support and encouragement from a small group of family and friends. <a href="https://www.linkedin.com/in/meerasundar/">Meera</a> has been enthusiastic and keen about Praval, and has supported me in all ways possible, as I developed this framework over the last several months. <a href="https://www.linkedin.com/in/akasantony/?originalSubdomain=uk">Akas</a> has often discussed with me about how he might use it in his own apps. I want to extend special thanks to <a href="https://www.linkedin.com/in/bargava/">Bargava</a> for his remarkable support and encouragement—especially at a time when I felt I had hit a wall. He rekindled my interest in Praval and has been a true champion of this project.</p>
<h2 id="whats-next-and-how-to-get-involved">What's Next and How to Get Involved</h2>
<p>Praval is under active development, and there is a lot more to do: stronger patterns for large ecosystems, more features, bug fixes, performance improvements, more out-of-the-box examples, and deeper integrations with production observability and deployment tools.</p>
<p>If you are interested in building multi-agent systems in Python, I would love your feedback:</p>
<ul>
<li><strong>GitHub repo</strong>: <a href="https://github.com/aiexplorations/praval">https://github.com/aiexplorations/praval</a></li>
<li><strong>Issues and feature requests</strong>: <a href="https://github.com/aiexplorations/praval/issues">https://github.com/aiexplorations/praval/issues</a></li>
<li><strong>Documentation</strong>: <a href="https://github.com/aiexplorations/praval/tree/main/docs">https://github.com/aiexplorations/praval/tree/main/docs</a></li>
<li><strong>PyPI</strong>: <a href="https://pypi.org/project/praval/">https://pypi.org/project/praval/</a></li>
</ul>
<p>Try it out, build something cool with it, and let me know what works well and what can be improved! I would love to hear your thoughts and feedback.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>praval</category>
      <category>multi-agent</category>
      <category>python</category>
      <category>ai</category>
      <category>framework</category>
      <category>project-spotlight</category>
    </item>
    <item>
      <title>Problem Frontier Expansion: What the Age of AI Deserves</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-11-13-problem-frontier-expansion.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-11-13-problem-frontier-expansion.html</guid>
      <pubDate>Thu, 13 Nov 2025 00:00:00 GMT</pubDate>
      <description>Peripatetic wandering is not a phrase I would normally associate with work, and yet, as of the end of 2025, I do. The truth is that the work of the past isn&apos;t the work of today.</description>
      <content:encoded><![CDATA[<p>As Bertrand Russell said, "Men are born ignorant, not stupid. They're made stupid by education". I wonder if they would stay stupid if the education continued as it does often through a well-lived life. But I think this way probably because I'm inherently hopeful about human sense-making of technology, tools and how we apply these in our specific landscapes.</p>
<p>If education today seems to have become about using ChatGPT to write your homework, we're manifesting this world view and breathing life into this specific idea of what education is – leading to a corruption of our minds, our work and our lives. But what if that was not the objective?</p>
<p>I once told Vitorino Ramos, one of the optimization researchers I know from X/Twitter, that life is like a <a href="https://en.wikipedia.org/wiki/Particle_swarm_optimization">particle swarm optimization</a> with no "global best". In the Shanti Parva of the Mahabharata, there's advice for kings to learn many times, from many seers – "Bahuda shrotavyaha, bahubhir shrotavyaha".</p>
<h2 id="the-abundance-mindset">The Abundance Mindset</h2>
<p>What if humans could expand the field of possibilities to be huge, vast, and more all-encompassing, and the abundance of this befits the meager AI tools we have developed today, whether LLMs or diffusion models or JEPA or anything else a few years down the line? There are mathematical truths about them (such as the universal approximation theorem of deep neural networks), but these represent vertical slices of realities we contend with along the journey. These could be grounding mechanisms or constraints on the broader journey but perhaps never all-encompassing.</p>
<p>It is not hard to remember how engineers, thinkers and problem solvers a few decades ago were, X lingo, "jacked". I don't mean Jared Vennett jacked, but <a href="https://www.britannica.com/biography/Kelly-Johnson">Kelly Johnson</a> jacked – using slide rules to design the SR-71 or the U-2 spy plane, or used something with less computational power than a modern toaster to fly a spacecraft to the edge of Neptune and still receive telemetry from it in 2020.</p>
<p>And yet, at the risk of back-tracking from a steel man argument, one doesn't have to go handicapped through life, as is evident from how we can use Claude for one-shotting complex simulations and models.</p>
<h2 id="the-purpose-of-ai-infused-work">The Purpose of AI-Infused Work</h2>
<p>It follows that the purpose of education infused with AI in it, and work infused with AI in it is perhaps not in solving the mundane that has already been solved for the hundredth time. This capability exists to bring forth a struggle that is worthy of the capability, and my thesis from many thought experiments seems to converge on the need for a <strong>larger problem frontier</strong>.</p>
<p>I mean a ridiculously large problem frontier, the likes of which will make the current state of human endeavour and accomplish look mundane and trivial.</p>
<h2 id="thinking-bigger">Thinking Bigger</h2>
<p>It follows then, is that the big labs, the big tech firms and other AI doyens are probably not thinking big enough with AI – whether it the development of AI, or the use of AI. Consider that many labs have retreated to their lion's den of selling you ads via chatbots now rather than have them do useful things – this regression is deemed essential because of the discomfort that massive change means for incumbents.</p>
<p>And while it is easy to be challenged on the question of how big to think (surely, Rajesh, you don't think you know better than the big labs and these folks with fancy PhDs?), and it easier even to get the answer wrong (Rajesh says a specific thing in response, which falls flat). But consider that I am only a mirror for the many voices of people using AI in its current state – the signal is inescapable, and the inescapable conclusion is both for question asker and answerer, that <strong>the frontier of problems we use AI and human ingenuity for, needs to be widened and broadened</strong>.</p>
<p>I'm sure the reader can think of transformative tech such as electricity, powered flight, spaceflight and the rest. Some changes and some tools are so powerful that they change our world-view – and this behooves us to expand it.</p>
<h2 id="final-thoughts">Final Thoughts</h2>
<p>And these are the words of an AI and technology optimist, regardless of the state of candor or irritation you may find me in at any point in time, due to any specific interaction with or use of AI tools. Frontier expansion is not a new idea, so I'm not being radical – only acknowledging the truth of the current state of things, technology, AI and the rest of it.</p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>ai</category>
      <category>philosophy</category>
      <category>innovation</category>
      <category>future-of-work</category>
    </item>
    <item>
      <title>Artificial Intelligence and Human Creativity</title>
      <link>https://rajeshrs.in/blog/ai-explorations/2025-06-28-ai-human-creativity.html</link>
      <guid isPermaLink="true">https://rajeshrs.in/blog/ai-explorations/2025-06-28-ai-human-creativity.html</guid>
      <pubDate>Sat, 28 Jun 2025 00:00:00 GMT</pubDate>
      <description>Many interesting narratives populate the philosophical discourse around artificial intelligence, not the least of which is the potential of artificial intelligence to replace humans in different endeavors.</description>
      <content:encoded><![CDATA[<p>Many interesting narratives populate the philosophical discourse around artificial intelligence, not the least of which is the potential of artificial intelligence to replace humans in different endeavors.</p>
<p>My own son's birth and my observation of every faculty he has developed – his ability to recognize objects, utter sounds, and crawl around as an infant, and now, as a toddler, his ability to ask questions about things, find new ways of looking at things around him, learn multiple languages with ease, and colour, draw, paint, recognize things – it has all been incredible to witness.</p>
<p>With my niece and nephew earlier, I saw a similar evolution of the mind, spirit and the growth of the person and the persona in each case. This is surely the case with all humans – we're somehow carrying within ourselves a creative force that is constantly at play.</p>
<h2 id="learning-creativity">Learning Creativity</h2>
<p>Greene makes the point that creative people learn creativity by trying, by exploring, by building options for themselves to express themselves in some context, and by confronting ideas, and crystallizing them.</p>
<p><strong>Do we humans all experience the equivalents of prompts that are multi-sensory in nature?</strong></p>
<p>Can the sound of the wind, or a song, or a noise be our prompts that set of thoughts, ideas and creative output, just as text prompts set of LLM hallucinations? I would argue, from experience, that this is quite true.</p>
<p>How often have we not been nudged by some situation or moved by it that a song we think is relevant has popped into our head? How often have we written, composed, played or worked feverishly through some source of inspiration? Are all these unalloyed with the sensory architecture around human life? I believe this may not be the case.</p>
<h2 id="situational-creativity">Situational Creativity</h2>
<p>It seems to be, then, that much of human creativity is also situational, and a due to a combination of intrinsic and extrinsic factors. And this is to be expected – we're a part of the universe, not separate from it.</p>
<p>It isn't as though what is within our skin is somehow separate from the world around us – we are made of the same stuff as the stars are, as Carl Sagan may have said. Perhaps this is as good a reason as any to continue to nurture human creativity and originality.</p>
<p>Regardless of what you may think about using ChatGPT to generate an image, perhaps there is value in learning to draw, or paint. Despite using Suno to build new songs, or Cursor to write code, you may find, in the words of Richard Feynman, <strong>a pleasure in finding things out</strong>, in keeping one's mind engaged, and putting to use that incredible result of evolution – the human brain.</p>
<h2 id="the-biological-advantage">The Biological Advantage</h2>
<p>The big difference in all this is of course that each of us reading this actually has a brain, as opposed to AI systems, which are code running on a piece of hardware. The more we engage our brains in different tasks, the stronger they get, in some Lamarckian way, and subsequent generations of humans stand to gain from these minor improvements our brains, bodies and minds.</p>
<p>Indeed, it isn't that our brains alone do the thinking – we have a nervous system that extends right through our bodies, and our brain stems are important participants in day-to-day intelligent decision making of an ingrained kind of response – Kahnemann's Type 1 and Type 2 classification comes to mind.</p>
<h2 id="the-future-of-human-creativity">The Future of Human Creativity</h2>
<p>So, do our bodies, brains and our "wet ware" compare well with AI? Perhaps they do, and perhaps they fall short in some ways. And yet, there is reason for biological intelligence, and its highest expression – human creativity – to be nurtured and to stick around a few more centuries at least, because who knows?</p>
<p><strong>We may improve, evolve over generations based on the things we spend time on, and surprise ourselves at how much better we can still get at many, many things.</strong></p>]]></content:encoded>
      <author>noreply@rajeshrs.in (Rajesh Sampathkumar)</author>
      <category>AI Explorations</category>
      <category>ai</category>
      <category>creativity</category>
      <category>philosophy</category>
      <category>human-intelligence</category>
    </item>
  </channel>
</rss>