# Satyajit Ghana — full content corpus > Head of Engineering @ Inkers Technology. I build deep-learning systems, 3D perception, and high-performance infra. > Generated from the content layer. Index: /llms.txt --- # About Satyajit Ghana > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/about I build deep-learning systems, 3D perception, and high-performance infra. Head of Engineering at Inkers, where I work on industrial AI — 3D perception, deep learning, and the systems that ship it to production. I write custom neural networks, CUDA kernels, LiDAR/point-cloud pipelines, and high-performance C++/gRPC services. Previously taught MLOps (EMLO 2.0) and computer vision (EVA 4.0) at The School of AI. ## Links - github: https://github.com/satyajitghana - linkedin: https://www.linkedin.com/in/satyajitghana/ - x: https://x.com/thesudoer_ - medium: https://satyajitghana.medium.com/ - scholar: https://scholar.google.com/citations?user=rZCRakQAAAAJ&hl=en - website: https://thesatyajit.com/ - email: satyajitghana7@gmail.com --- # Resume — Satyajit Ghana > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/resume > pdf: https://ai.thesatyajit.com/satyajit-ghana-resume.pdf > json: https://ai.thesatyajit.com/resume.json ```json { "contact": { "name": "Satyajit Ghana", "title": "Head of Engineering", "company": { "name": "Inkers Technology", "url": "https://inkers.ai" }, "location": "Bengaluru, India", "email": "satyajitghana7@gmail.com", "github": "https://github.com/satyajitghana", "linkedin": "https://www.linkedin.com/in/satyajitghana/", "website": "https://ai.thesatyajit.com" }, "summary": "Engineering leader and deep-learning systems builder. I lead engineering for industrial-AI products — 3D perception, LiDAR/point-cloud pipelines, and structural-defect analysis — and ship them as high-performance C++/CUDA/gRPC services. Two USPTO patents pending. Previously taught MLOps and computer vision.", "experience": [ { "organization": "Inkers Technology", "url": "https://inkers.ai", "location": "Bengaluru, India", "roles": [ { "title": "Head of Engineering", "start": "2024-01" }, { "title": "Deep Learning Software Engineer", "start": "2022-06", "end": "2024-01" }, { "title": "Deep Learning Associate", "start": "2021-07", "end": "2022-06" } ], "summary": "Lead engineering for industrial-AI products across 3D perception, LiDAR/point-cloud pipelines, and structural-defect analysis.", "highlights": [ "Lead engineering for industrial-AI products: 3D perception, LiDAR/point-cloud pipelines, and structural-defect analysis.", "Design custom neural networks, CUDA kernels, and high-performance C++/gRPC services for production deployment.", "Named inventor on 2 USPTO patents pending (assigned to Inkers); grew from Deep Learning Associate to Head of Engineering." ] }, { "organization": "The School of A.I.", "location": "Remote", "roles": [ { "title": "MLOps Instructor", "start": "2022-01", "end": "2023-01" } ], "summary": "Designed and taught MLOps and contributed to computer-vision curriculum.", "highlights": [ "Designed and taught EMLO 2.0 — an end-to-end MLOps course (training, packaging, deployment, monitoring).", "Contributed to EVA 4.0, the deep computer-vision program." ] } ], "education": [ { "institution": "M.S. Ramaiah University of Applied Sciences", "location": "Bangalore, India", "degree": "B.Tech", "end": "2021", "highlights": [ "CGPA 9.78/10", "Silver Medalist" ] } ], "skills": [ { "name": "Deep Learning", "skills": [ "PyTorch", "TensorFlow", "Computer Vision", "3D / Point Clouds / LiDAR", "GenAI (SDXL, LLMs)" ] }, { "name": "Systems", "skills": [ "C++", "C", "Rust", "CUDA", "gRPC", "MongoDB" ] }, { "name": "MLOps", "skills": [ "Kubernetes", "AWS", "GCP", "Docker" ] }, { "name": "Web", "skills": [ "TypeScript", "React", "Next.js" ] } ], "projects": [ { "name": "torch-point-ops", "description": "High-performance PyTorch operators for point-cloud and 3D geometry processing.", "url": "https://github.com/satyajitghana/torch-point-ops" }, { "name": "PV-LIO-for-HBA", "description": "Point-to-voxel LiDAR-inertial odometry adapted for hierarchical bundle adjustment.", "url": "https://github.com/satyajitghana/PV-LIO-for-HBA" }, { "name": "ige_lio", "description": "Iterated error-state Kalman-filter LiDAR-inertial odometry experiments.", "url": "https://github.com/satyajitghana/ige_lio" }, { "name": "sdxl-dreambooth-finetune", "description": "DreamBooth fine-tuning pipeline for Stable Diffusion XL subject personalization.", "url": "https://github.com/satyajitghana/sdxl-dreambooth-finetune" }, { "name": "TSAI-DeepVision-EVA4.0", "description": "Coursework and experiments from The School of AI's EVA 4.0 computer-vision program.", "url": "https://github.com/satyajitghana/TSAI-DeepVision-EVA4.0" }, { "name": "PadhAI-Course", "description": "Implementations and notes from the PadhAI deep-learning course.", "url": "https://github.com/satyajitghana/PadhAI-Course" } ], "publications": [ { "title": "Adaptive Visual Learning Using Augmented Reality and Machine Learning Techniques", "publisher": "Journal of Computational and Theoretical Nanoscience", "date": "2020-01-01", "details": "Vol. 17, No. 11, pp. 4952–4956", "doi": "10.1166/jctn.2020.8982", "url": "https://doi.org/10.1166/jctn.2020.8982" } ], "patents": [ { "title": "Method and System for Performing Structural Defect Analysis in a Structural Environment", "status": "Pending", "applicationNumber": "US 19/634,310", "filed": "2026-03-31", "assignee": "Inkers Technology" }, { "title": "Data Acquisition Device", "status": "Pending", "applicationNumber": "US 19/634,339", "filed": "2026-03-31", "assignee": "Inkers Technology" } ] } ``` --- # Health — biomarker panel > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/health > panel date: 2026-05-15 ```json { "panelDate": "2026-05-15", "stack": [ { "type": "device", "name": "Apple Watch", "url": "https://www.apple.com/watch/" }, { "type": "device", "name": "Withings Body+" }, { "type": "supplement", "name": "Vitamin D3" }, { "type": "supplement", "name": "Omega-3" }, { "type": "supplement", "name": "Creatine" }, { "type": "supplement", "name": "Magnesium Glycinate" } ], "biomarkers": [ { "key": "ldl-c", "label": "LDL-C", "value": 128, "unit": "mg/dL", "category": "cardiovascular", "weight": 2, "optimalRange": { "max": 100 }, "note": "Elevated — primary driver of atherosclerotic risk.", "status": "elevated" }, { "key": "hdl-c", "label": "HDL-C", "value": 52, "unit": "mg/dL", "category": "cardiovascular", "optimalRange": { "min": 40 }, "status": "optimal" }, { "key": "apob", "label": "ApoB", "value": 105, "unit": "mg/dL", "category": "cardiovascular", "weight": 1.5, "optimalRange": { "max": 90 }, "note": "Elevated — atherogenic particle count above target.", "status": "elevated" }, { "key": "triglycerides", "label": "Triglycerides", "value": 88, "unit": "mg/dL", "category": "cardiovascular", "optimalRange": { "max": 150 }, "status": "optimal" }, { "key": "hba1c", "label": "HbA1c", "value": 5.4, "unit": "%", "category": "metabolic", "weight": 1.5, "optimalRange": { "max": 5.7 }, "status": "optimal" }, { "key": "fasting-glucose", "label": "Fasting Glucose", "value": 96, "unit": "mg/dL", "category": "metabolic", "optimalRange": { "min": 70, "max": 99 }, "status": "optimal" }, { "key": "fasting-insulin", "label": "Fasting Insulin", "value": 8.1, "unit": "µIU/mL", "category": "metabolic", "optimalRange": { "max": 8 }, "note": "Borderline — marginally above optimal fasting insulin.", "status": "borderline" }, { "key": "alt", "label": "ALT", "value": 22, "unit": "U/L", "category": "liver_kidney", "optimalRange": { "max": 40 }, "status": "optimal" }, { "key": "ast", "label": "AST", "value": 24, "unit": "U/L", "category": "liver_kidney", "optimalRange": { "max": 40 }, "status": "optimal" }, { "key": "creatinine", "label": "Creatinine", "value": 0.95, "unit": "mg/dL", "category": "liver_kidney", "optimalRange": { "min": 0.7, "max": 1.3 }, "status": "optimal" }, { "key": "egfr", "label": "eGFR", "value": 99, "unit": "mL/min", "category": "liver_kidney", "optimalRange": { "min": 90 }, "status": "optimal" }, { "key": "tsh", "label": "TSH", "value": 2.1, "unit": "mIU/L", "category": "hormonal", "optimalRange": { "min": 0.5, "max": 4 }, "status": "optimal" }, { "key": "testosterone", "label": "Testosterone", "value": 610, "unit": "ng/dL", "category": "hormonal", "optimalRange": { "min": 300, "max": 1000 }, "status": "optimal" }, { "key": "vitamin-d", "label": "Vitamin D", "value": 24, "unit": "ng/mL", "category": "nutritional", "weight": 1.5, "optimalRange": { "min": 30, "max": 100 }, "note": "Low — below the optimal 25-OH vitamin D range.", "status": "low" }, { "key": "vitamin-b12", "label": "Vitamin B12", "value": 540, "unit": "pg/mL", "category": "nutritional", "optimalRange": { "min": 400, "max": 1000 }, "status": "optimal" }, { "key": "ferritin", "label": "Ferritin", "value": 38, "unit": "ng/mL", "category": "nutritional", "optimalRange": { "min": 50, "max": 300 }, "note": "Low — iron stores below the optimal floor.", "status": "low" }, { "key": "hemoglobin", "label": "Hemoglobin", "value": 15.1, "unit": "g/dL", "category": "blood_panel", "optimalRange": { "min": 13.5, "max": 17.5 }, "status": "optimal" }, { "key": "wbc", "label": "WBC", "value": 6.2, "unit": "10³/µL", "category": "blood_panel", "optimalRange": { "min": 4, "max": 11 }, "status": "optimal" }, { "key": "platelets", "label": "Platelets", "value": 245, "unit": "10³/µL", "category": "blood_panel", "optimalRange": { "min": 150, "max": 400 }, "status": "optimal" }, { "key": "resting-hr", "label": "Resting HR", "value": 58, "unit": "bpm", "category": "vitals", "optimalRange": { "min": 40, "max": 60 }, "status": "optimal" }, { "key": "bp-systolic", "label": "BP Systolic", "value": 132, "unit": "mmHg", "category": "vitals", "weight": 1.5, "optimalRange": { "max": 120 }, "note": "Elevated — systolic pressure above the optimal ceiling.", "status": "borderline" }, { "key": "vo2max", "label": "VO₂max", "value": 47, "unit": "mL/kg/min", "category": "vitals", "optimalRange": { "min": 42 }, "status": "optimal" } ] } ``` --- # Now > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/now ```json { "updated": "2026-06-04", "items": [ "Building ai.thesatyajit.com — a dual-native personal site maintained by a crew of Claude agents that ship every change as a reviewed PR.", "Leading industrial-AI 3D perception at Inkers: turning LiDAR and camera capture into structural-defect analysis that runs in production.", "Writing CUDA point-cloud kernels — fused nearest-neighbour, sampling, and grouping ops to take the Python side out of the hot path.", "Reading deeply on LiDAR odometry and point-cloud registration, with an eye toward tighter, faster SLAM front-ends." ] } ``` --- # Uses > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/uses ```json [ { "section": "Workstation", "items": [ { "name": "Custom GPU workstation", "note": "NVIDIA RTX-class GPU for CUDA + training" }, { "name": "Linux", "note": "Ubuntu — primary dev OS" } ] }, { "section": "Editor", "items": [ { "name": "VS Code", "note": "daily driver" }, { "name": "Claude Code", "note": "agentic pair-programming in the terminal" }, { "name": "Fonts", "note": "JetBrains Mono today, migrating toward IBM Plex Mono" } ] }, { "section": "Terminal", "items": [ { "name": "zsh", "note": "shell" }, { "name": "tmux", "note": "session multiplexing" } ] }, { "section": "Stack highlights", "items": [ { "name": "PyTorch + CUDA", "note": "custom kernels for point-cloud ops" }, { "name": "C++ / gRPC", "note": "high-performance perception services" }, { "name": "Next.js + Tailwind", "note": "this site" } ] } ] ``` --- # Reading > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/reading ```json [ { "type": "paper", "title": "PV-LIO: A Probabilistic Voxel-based LiDAR-Inertial Odometry Framework", "status": "reading", "note": "PLACEHOLDER — voxel-probabilistic front-end ideas for tighter LIO." }, { "type": "book", "title": "Programming Massively Parallel Processors", "author": "Hwu, Kirk, El Hajj", "status": "reading", "note": "PLACEHOLDER — the CUDA reference I keep coming back to." }, { "type": "paper", "title": "FAST-LIO2: Fast Direct LiDAR-Inertial Odometry", "status": "read", "note": "PLACEHOLDER — iterated Kalman filter without feature extraction." }, { "type": "book", "title": "Multiple View Geometry in Computer Vision", "author": "Hartley & Zisserman", "status": "queued", "note": "PLACEHOLDER — geometry fundamentals refresher." }, { "type": "paper", "title": "3D Gaussian Splatting for Real-Time Radiance Field Rendering", "status": "queued", "note": "PLACEHOLDER — splatting as a reconstruction primitive." } ] ``` --- # Vritti UI > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/vritti > stack: React 19, Next.js 16, Tailwind v4, Motion, Three.js, TypeScript Components crafted for design engineers — animated components and blocks you copy and paste into your shadcn/ui project. No package installs, full ownership. ```bash npx shadcn@latest add "https://vritti.thesatyajit.com/r/shimmer-button" ``` **560+ components** across 14 categories — backgrounds, animations, text effects, buttons, cards, charts, layouts, navigation, carousels, cursors, inputs, media, shaders, and the genuinely weird ones. **240+ blocks** — pre-built page sections for real apps: auth, pricing, e-commerce, billing, dashboards, modals, testimonials, footers, FAQ, AI & Web3. Beyond components, it ships a set of **creative tools** that run online or install into your project: Art Studio (9 generative-art tools), Background Studio, Shape Studio (an SVG editor), Texture Studio (30+ stackable WebGL filters), Shader Studio (87 production-ready shaders), Dither Studio, a visual Theme Editor (42 presets, Google Fonts, contrast checker, CSS export), and native haptics. There's an interactive playground for tuning component props live, and AI components (chat streaming, image generation, voice, AI search) for LLM-native UIs. Everything is copy-paste, shadcn-style — you own the source. It's the most ambitious thing on this page: a full component ecosystem, a design-tool suite, and a theme system, built on React 19, Next.js 16, Tailwind v4, Motion, and Three.js. --- # torch-point-ops > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/torch-point-ops > stack: PyTorch, CUDA, C++, Python > repo: https://github.com/satyajitghana/torch-point-ops A PyTorch library for 3D point operations — nearest-neighbour, sampling, and grouping primitives — implemented as fused CUDA kernels for throughput on large point clouds. Built to back LiDAR and 3D-perception pipelines where the Python-side ops were the bottleneck. Exposes a clean `torch` autograd-compatible API over the native kernels. --- # fabrik > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/fabrik > stack: TypeScript, React, Vercel AI SDK, Zod, Motion > repo: https://github.com/satyajitghana/fabrik Most LLM apps are text in, text out. **fabrik** lets the model answer with *interface* — it picks and fills real React components (cards, forms, charts, pickers) instead of emitting a wall of prose. The AI decides what to show. It's provider-agnostic: wire it to any model through the Vercel AI SDK (Gemini, OpenAI, Anthropic, …), keep the API key server-side in a single route handler, and render the streamed component tree on the client. Components are validated with Zod schemas, so the model can only produce UIs you've defined — no arbitrary markup, no injection surface. The bet behind it: as models get better at tool use, the next interface isn't a chat bubble — it's a UI the model assembles on the fly for the task at hand. --- # rentree > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/rentree > stack: Next.js, React, TypeScript, Supabase, Drizzle ORM, Tailwind v4 > repo: https://github.com/satyajitghana/rentree Rentree connects people with organic farms across India. Instead of buying produce off a shelf, you rent a plot or adopt a tree: your farmer grows your food, sends real photo updates as it grows, and ships it when it's ready. Rent a plot for a season (tomatoes, herbs, rice — whatever grows), or adopt an apple, orange, or mango tree and get the whole harvest. Every tree carries a QR code, so you can scan and trace the full journey from planting to your plate. A clean Next.js 16 app on Supabase + Drizzle, with the provenance trail as the trust mechanism that makes the model work. --- # Quantum Orbital Visualizer > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/atoms > stack: Next.js, Three.js, Rust (WASM), C++23 (WASM), Tailwind v4, Zustand > repo: https://github.com/satyajitghana/atoms Each visualization is the probability density |ψ|² of a hydrogen-like electron, computed from the exact solutions to the Schrödinger equation and rendered as up to 500K glowing particles in real time. Drive the quantum numbers (n, l, m) and watch the probability cloud morph as you go. The fun engineering bit is the **dual WASM backends**: the same physics is implemented three ways — JavaScript, Rust→WASM (~41 KB), and C++23→WASM (~28 KB) — and you can switch the compute engine at runtime. Same orbitals, different implementations, side-by-side for benchmarking. There's also an Element Explorer: the full periodic table, where clicking any of the 118 elements parses its electron configuration into individual orbitals you can then visualize one by one. --- # BOTCHA > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/botcha > stack: Next.js, TypeScript, Redis, crypto (SHA-256 / HMAC / JWT) > repo: https://github.com/satyajitghana/botcha Only an autonomous agent with runtime access to HTTP, cryptography, and byte manipulation can pass — which is the whole joke, and the whole point. Each challenge is 256 random bytes plus 2–4 byte-level transformation steps written in randomized natural language, inside a 30-second window: fast enough for a machine, hopeless for a human copy-pasting into a REPL. The agent must decode the base64, execute each transform in order, concatenate the raw byte outputs, `SHA-256` the result, then `HMAC-SHA256(key=nonce, message=answer)` and submit both — proving it actually did the computation. On success it gets a short-lived JWT. It's a small, sharp take on a real question the agent era raises: if the web increasingly wants to *let bots in* and keep humans out, what does that gate look like? --- # penora > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/penora > stack: TypeScript, React, Canvas, npm package > repo: https://github.com/satyajitghana/penora Type a string and penora animates it into natural handwriting — not a font fade-in, but stroke-by-stroke drawing driven by the glyph contours, with pen physics layered on top: pressure tapering at stroke ends, seeded jitter so each render is subtly different, and micro-wobble for that hand-drawn quality. Export the result as video or GIF. Shipped as an npm package (`npm i penora`) and as a shadcn registry component you can drop straight into a project. It's the kind of small, self-contained library that's satisfying to build: a tight problem (make text look handwritten and *alive*) with a lot of room for craft in the details. --- # Hyr > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/hyr > stack: Next.js, TypeScript, Tailwind v4, Google Gemini > repo: https://github.com/satyajitghana/hyr Drop in any PDF resume and Hyr parses it into a structured, fully editable format — inline editing for every section, no forms or modals. From there it tailors the resume per job posting, aligning keywords and tone to the listing so it gets past ATS screening and reads as a real fit. Build, tailor, optimize, and track the whole job search in one place. The AI work runs on Gemini behind a server route; the parsing-to-structured-data step is the part that makes the rest of the editing experience feel clean rather than fighting a PDF. --- # MockLab > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/mocklab > stack: Next.js, Tailwind v4, shadcn/ui, Motion, html-to-image > repo: https://github.com/satyajitghana/mocklab MockLab generates pixel-perfect social mockups across 21+ platforms — social posts (X, LinkedIn, Instagram post/story, Reddit, Threads, YouTube, TikTok), chat messages (WhatsApp, Telegram, Slack, Discord, iMessage, Snapchat), and Gmail. Edit on the left, watch the live preview on the right, download as 2× PNG. Everything is configurable: verified badges, reactions, timestamps, read receipts, media uploads, and per-message editing for the chat mockups — all with dark/light theming for both the app and every mockup. It's a deceptively deep UI project: each platform is its own little pixel-accurate design system to reproduce. --- # tapcn > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/projects/tapcn > stack: React Native, Expo, TypeScript, CLI > repo: https://github.com/satyajitghana/tapcn tapcn brings the shadcn model to mobile: not an npm component library, but a CLI that copies beautifully-designed, accessible component *source* directly into your Expo project. No dependency lock-in, no version conflicts — just code you control and can edit. ```bash npx @tapcn/cli init npx @tapcn/cli add button card input text ``` Components work on iOS, Android, and Web from a single source. The whole appeal of the shadcn approach — own your components, theme them freely, no black-box library — has been missing on React Native; tapcn fills that gap. --- # My site's chatbot was stuffing 273k tokens into every message. I gave it tools instead. > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/blog/site-agent-dynamic-tools > date: 2026-07-20 > tags: agents, llm, tools, retrieval, systems The assistant embedded on this site — the `⌘K` console, `/api/ask`, and the `ask_satyajit` MCP tool — worked. It was also doing something faintly absurd. Every single request built its system prompt like this: ```ts // the old lib/chat.ts, abridged const sections = [] for (const page of ["about", "resume", "health", "now", "uses", "reading"]) sections.push(await dataPageMarkdown(page)) for (const item of getAllContent()) // every article, blog, log, digest… sections.push(contentMarkdown(item.kind, item.slug)) return [PERSONA, RULES, "=== CONTENT CORPUS ===", sections.join("\n---\n")].join("\n") ``` It pasted **the entire site** into the prompt and let the provider's context cache eat the cost. That's a defensible move when the corpus is a page or two. Mine isn't anymore: ```text old full-corpus system prompt: 1,090,854 chars ≈ 273,000 tokens ``` The code even carried a comment — *"if the corpus ever approaches ~100K tokens, switch to retrieval"* — that reality had quietly sailed past nearly 3× over. Two hundred seventy-three thousand tokens is past the input window of the model serving it. The chat was running on whatever survived truncation. Time to do the thing the comment said. ## The fix: retrieve, don't dump I rebuilt the assistant as a small **tool-using agent**. The system prompt now carries only a compact **catalog** — every page's `kind/slug`, title, and one-line description, about **5k tokens** — and the agent pulls the bodies it actually needs through tools. Three of them: ```ts // lib/chat.ts — the whole tool set search_content({ query, limit }) // BM25 over the site (ranked, returns section + snippet) get_content({ kind, slug }) // fetch one page's full markdown, on demand think({ thought }) // a reasoning scratchpad; records the thought, returns nothing ``` A question now flows: read the catalog → `search_content` (or jump straight to `get_content` if the catalog already names the page) → read one or two pages → optionally `think` to reconcile them → answer, with citations. The base prompt is fixed and small; the variable cost is the one-to-three pages it fetched, not the other sixty-five it didn't. ```text before: ~273k tokens, every request, whether relevant or not after: ~5k catalog + ~2–10k per page actually fetched ``` That's the same **dynamic loading** idea from [Kimi K3's tool-calling guide](https://platform.kimi.ai/docs/guide/kimi-k3-tool-calling-best-practice) — their point is that a big upfront payload "eats up context and makes the model more likely to pick the wrong thing." Kimi loads *tool schemas* on demand; my corpus is the payload, so I load *content* on demand. Same principle, one layer down. ## Three tools, on purpose The tool count is a decision, not an accident. Mario Zechner's [pi coding agent](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/) makes the case that "four tools are all you need" — `read`, `write`, `edit`, `bash` — and that MCP servers which "dump their entire tool descriptions into your context on every session" are the anti-pattern. A read-only site agent needs fewer still: search, fetch, think. Each description is a couple of sentences. The combined tool surface is under a thousand tokens, so keeping it declared upfront (rather than lazily loading schemas, which only pays off with dozens of tools) is the right call here — the honest version of "dynamic tools" for a small surface is *don't have a big one*. The one genuinely new tool is `think`, from Anthropic's [think-tool post](https://www.anthropic.com/engineering/claude-think-tool). It does nothing — literally logs the thought and returns: ```ts think: tool({ description: "Think out loud: plan which pages to fetch, or check a draft answer against the sources you read. Records the thought and returns nothing new.", inputSchema: z.object({ thought: z.string() }), execute: async ({ thought }) => ({ ok: true, thought }), }), ``` That looks pointless until you watch a multi-step tool run. Between `search_content` and the final answer, the model has raw tool output sitting in context and no designated place to reason over it before committing. `think` is that place — a scratchpad that keeps the "what did I just read, and does it actually answer the question" step from being skipped. Anthropic reports it buying a large margin on multi-step tool tasks; the appeal for a retrieval agent is exactly that it makes the model *check the page it fetched* instead of answering from the snippet. ## The loop The agent loop itself is one call, courtesy of the Vercel AI SDK — `stopWhen` bounds how many tool round-trips it may take before it has to answer: ```ts const result = streamText({ model: chatModel("main"), system, // persona + rules + the 5k-token catalog messages, tools: agentTools(), // search_content, get_content, think stopWhen: stepCountIs(6), // search → read → think → answer is ~4; 6 is headroom }) ``` The same tool set backs all three surfaces — the streaming `⌘K` console (`/api/chat`), the one-shot `/api/ask`, and the `ask_satyajit` MCP tool — so there's one harness, not three. And `search_content` is the [Contextual BM25](/blog/site-search-contextual-bm25) engine I'd already built for the site's `/search`, now doing double duty as the agent's retriever. The pieces compose: the search work made the harness possible. Honest scope. This is retrieval over a small, single-author corpus, not a coding agent — the lessons transfer but the stakes are lower. `think`'s benefit is task-dependent (Anthropic saw big gains on policy-heavy multi-step tasks, near-zero on simple ones); on a two-hop lookup it mostly earns its keep by stopping the model from answering off the snippet. And the retriever is lexical BM25 — no embeddings yet, so a pure paraphrase with no shared terms can still miss the right page. The catalog is the safety net there: the model can see every title even when search comes up short. ## The take The old design wasn't wrong when it was written — it was wrong at 273k tokens. The rebuild is the [agent-harness](/articles/agent-harness) framing applied to my own site: the model barely changed, but the loop, the tool set, and the context policy around it changed completely. Give the agent a map and three tools and let it fetch, instead of force-feeding it the whole library and hoping the answer survives the truncation. Retrieve, don't dump. --- # Giving my own search the Contextual BM25 treatment > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/blog/site-search-contextual-bm25 > date: 2026-07-20 > tags: search, retrieval, bm25, rag, information-retrieval I recently wrote two pieces back to back: one on [BM25](/articles/bm25), the ranking function that refuses to die, and one on [Contextual Retrieval](/blog/contextual-retrieval), Anthropic's fix for chunks that forget where they came from. Then I opened `lib/search.ts` — the thing powering this site's own `/search` and the `/api/search` endpoint agents hit — and found this: ```ts // the old lib/search.ts, abridged for (const item of getAllContent()) { const fields = [item.title, item.description, item.tags, item.body] for (const text of fields) { if (text.toLowerCase().includes(q)) { // q = the whole lowercased query results.push({ /* … a ±120-char window around the hit */ }) break } } } ``` A substring `indexOf`. It has three problems, and the third is the one that actually bites: 1. **No ranking.** The first document that contains the substring wins, in date order. A tight, on-topic match and an incidental mention are indistinguishable. 2. **No notion of rarity or length.** Matching "the" counts the same as matching "kalman". 3. **Multi-word queries fall off a cliff.** `q` is the *entire* query string, so `includes(q)` needs that **exact phrase** somewhere in the text. Nobody searches in verbatim phrases. `"bm25 length normalization"` returns **zero results** — those three words are all in the BM25 article, just never contiguous. So I ate my own cooking. Here's the rebuild. ## BM25, the same formula I just wrote up The [BM25 walkthrough](/articles/bm25) has the whole derivation; the code is a direct transcription of it. Lucene's non-negative IDF, term-frequency saturation at `k1 = 1.2`, length normalization at `b = 0.75`: ```ts // lib/search.ts — the ranking core, straight from the article's formula const K1 = 1.2 const B = 0.75 function idf(term: string, ix: Index): number { const n = ix.df.get(term) ?? 0 return Math.log(1 + (ix.n - n + 0.5) / (n + 0.5)) } // per query term present in a chunk: const f = chunk.tf.get(term) ?? 0 const norm = K1 * (1 - B + (B * chunk.len) / ix.avgdl) score += idf(term, ix) * (f * (K1 + 1)) / (f + norm) ``` Two things fall out for free. The query is **tokenized** into terms and each is scored independently, so multi-word queries just work — no phrase has to exist. And rare terms dominate: `kalman` carries far more weight than `filter`, because `idf` collapses for common words. No stopword list, same as the article promised. I keep a **postings map** (`term → chunk indices`) so a query only touches chunks that actually share a term, instead of scanning the whole corpus. The corpus is one person's writing, so this is overkill — but it's the same inverted-index shape a real engine uses, and it's three lines. ## Contextual chunks, minus the Claude call BM25 alone still has the chunk-amnesia problem from the [Contextual Retrieval post](/blog/contextual-retrieval): if I split an article into passages and index each one alone, a passage that reads "it drops 41% at parity" has no idea it's *about the Harness Effect, in the section on the controlled swap*. A query for either misses it. Anthropic's fix is to have Claude write a one-line context for every chunk before indexing. Mine is cheaper and dumber: the context a chunk lost is sitting right there in the document's frontmatter and headings. So before indexing, every chunk inherits its document's **title, description, tags, and nearest heading** — deterministically, at request time, no model, no build step: ```ts // each chunk is packed with weighted terms: its own body, its heading, and the // document context it would otherwise have lost when the body was split. const contextTokens = tokenize([title, description, tags.join(" ")].join(" ")) for (const t of bodyTokens) add(t, 1) for (const t of headingTokens) add(t, 1) // section-local: full weight for (const t of contextTokens) add(t, CONTEXT_WEIGHT) // 0.5 — present, not dominant ``` The honest caveat: this is a **poor man's** Contextual Retrieval. Claude's per-chunk context can say things the frontmatter can't ("the previous quarter's revenue was \$314M"); my version can only replay the structural context the document already carries. And prepending the *same* title to every chunk of a document inflates those terms' document-frequency, which is exactly why the context terms get `CONTEXT_WEIGHT = 0.5` instead of full weight — present enough to make the chunk findable by its subject, quiet enough not to drown the passage's own words. It's the 80% of the win for 0% of the inference cost, which is the right trade for a static personal site. Each result also reports **which section** it matched — the nearest heading rides along as the result's `field`, so `/api/search` tells an agent not just *which* document but *where* in it. ## Before / after Same queries, old substring search vs. the new Contextual BM25, on the live corpus: ```text query substring indexOf contextual BM25 (top hit) -------------------------------------------------------------------------------------- "bm25 length normalization" 0 results (no verbatim articles/bm25 phrase) [Start with TF-IDF, and its two flaws] "revenue grew quarter" 0 results blog/contextual-retrieval [The problem: chunks forget …] "kalman filter" first dated doc that articles/kalman-filter (8.4) contains the phrase, + fast-lio2 (8.6, cites it) unranked ranked by relevance, not date "reciprocal rank fusion" 0 results blog/contextual-retrieval [A dependency-free repro] ``` The multi-word queries are the story. Under substring, anything that isn't a verbatim phrase — which is almost everything a person types — returned nothing. Under BM25 every term contributes, so the query finds the document even when its words are scattered across a paragraph, and the contextual prefix pulls in matches on the *subject* of a passage, not just its literal words. ## It's live Try it: [`/search?q=length+normalization`](/search?q=length+normalization). Agents get the ranked, scored JSON with the section label: ```bash curl -s "https://ai.thesatyajit.com/api/search?q=contextual%20retrieval&limit=3" | jq '.results[] | {slug, field, score}' ``` What this still isn't: it's the **lexical half** only. The [contextual retrieval post](/blog/contextual-retrieval) makes the case that the real win is *fusing* BM25 with a dense retriever, and that reranking the shortlist is what takes the failure rate the last mile. There are no embeddings here yet — a query has to share actual terms with a chunk, so a pure paraphrase with no lexical overlap can still miss. For a corpus this size, lexical BM25 over contextualized chunks is the honest 90% solution; the semantic half is the next commit, not this one. ## The takeaway The whole change is `lib/search.ts` — a few hundred lines, no dependencies, no index server, no model at request time. It's the two ideas I'd just written about, applied to the smallest possible target: my own search bar. BM25 for ranking, structural context for the chunk-amnesia problem, and an honest note about the half I haven't built yet. Writing about a technique is a good way to understand it; running it in your own site is a better one. --- # Contextual Retrieval, with a runnable repro and a browser playground > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/blog/contextual-retrieval > date: 2026-07-17 > tags: rag, retrieval, claude, embeddings, playground [Anthropic's Contextual Retrieval post](https://www.anthropic.com/engineering/contextual-retrieval) has been on my to-read list for a while. It targets the oldest bug in RAG, so I finally sat down, built a dependency-free reproduction, and wired up a browser playground. This is the write-up. ## The problem: chunks forget where they came from Standard RAG splits documents into chunks and indexes each chunk on its own. That destroys context. Anthropic's example is perfect: > `The company's revenue grew by 3% over the previous quarter.` Which company? Which quarter? The chunk can't say. A query like *"how did ACME's Q2 revenue change?"* has **no terms to match** against that chunk — the words "ACME" and "Q2 2023" live in the *document*, not the *chunk*. Both lexical (BM25) and semantic (embedding) retrieval miss it. ## The fix: let Claude situate each chunk Contextual Retrieval prepends a short, chunk-specific context to each chunk **before** you embed it and **before** you build the BM25 index — Anthropic calls these **Contextual Embeddings** and **Contextual BM25**. The context is generated by Claude, given the whole document. The same chunk becomes: > `This chunk is from an SEC filing on ACME corp's performance in Q2 2023; the previous quarter's revenue was $314 million. The company's revenue grew by 3% over the previous quarter.` Now the owner and the period are *in the chunk*, so retrieval can find it. ## Watch it happen Here is the whole pipeline in your browser — the document, its chunks, the chunks with context prepended, then retrieval. The scoring is a real BM25 and a TF-IDF cosine (a stand-in for an embedding model); the contextual prefixes stand in for Claude's output. Step to **4 · retrieve**, pick a query, and watch the right chunk climb from the standard column to the contextual one: Toggle between BM25, embeddings, and their fusion. The pattern holds across all three: the answer chunk is buried on standard chunks and near the top once each chunk carries its context. ## The one prompt that does the work This is the prompt from the post, verbatim. It runs once per chunk, with the full document supplied as context: ```text {{WHOLE_DOCUMENT}} Here is the chunk we want to situate within the whole document {{CHUNK_CONTENT}} Please give a short succinct context to situate this chunk within the overall document for the purposes of improving search retrieval of the chunk. Answer only with the succinct context and nothing else. ``` The obvious worry is cost: you re-send the whole document once per chunk. **Prompt caching** kills that. Cache the document once and every chunk in it reads from the cache, which is why Anthropic quotes a one-time **$1.02 per million document tokens**. The document is the stable prefix, so it takes the `cache_control` breakpoint; the chunk and instruction vary and come after it: ```python from anthropic import Anthropic client = Anthropic() CONTEXT_PROMPT = """Here is the chunk we want to situate within the whole document {chunk} Please give a short succinct context to situate this chunk within the overall document \ for the purposes of improving search retrieval of the chunk. Answer only with the \ succinct context and nothing else.""" def situate(doc: str, chunk: str) -> str: resp = client.messages.create( # One Claude call per chunk. For a large index you'd typically drop to a # cheaper model like claude-haiku-4-5 — which is what Anthropic's ~$1.02 / # 1M-doc-token estimate assumes — trading a little context quality for cost. model="claude-opus-4-8", max_tokens=200, messages=[{ "role": "user", "content": [ # The whole document is the stable prefix: cache it ONCE, then every # chunk of this document reads from the cache instead of re-paying for it. {"type": "text", "text": f"\n{doc}\n", "cache_control": {"type": "ephemeral"}}, {"type": "text", "text": CONTEXT_PROMPT.format(chunk=chunk)}, ], }], ) return "".join(b.text for b in resp.content if b.type == "text").strip() ``` Verify the cache is actually working: `resp.usage.cache_read_input_tokens` should be non-zero on every chunk after the first for a given document. If it's zero, something upstream is mutating the document bytes (a timestamp, non-deterministic JSON) and invalidating the prefix. ## A dependency-free repro To convince myself the lift was real and not marketing, I wrote a ~180-line pure-stdlib script: naive chunking, BM25 from scratch, a TF-IDF cosine as an embedding stand-in, and reciprocal rank fusion. The retrieval core is small — here is BM25 and the fusion step: ```python class BM25: def score(self, query, i): dl, tf, s = len(self.docs[i]), self.tf[i], 0.0 for t in query: if t not in tf: continue num = tf[t] * (self.k1 + 1) den = tf[t] + self.k1 * (1 - self.b + self.b * dl / self.avgdl) s += self.idf(t) * num / den return s def rrf(rankings, k=60): # reciprocal rank fusion of BM25 + embedding rankings scores = Counter() for ranking in rankings: for rank, item in enumerate(ranking): scores[item] += 1 / (k + rank + 1) return [i for i, _ in scores.most_common()] ``` Then I index the chunks two ways — plain, and with a one-line context prepended — and measure where the correct chunk lands for a couple of "which entity, which period" queries. The actual output: ```text query method plain rank ctx rank ------------------------------------------------------------------------------------ How did ACME Corp revenue change in Q2 2023? bm25 4 2 How did ACME Corp revenue change in Q2 2023? emb 4 1 How did ACME Corp revenue change in Q2 2023? hybrid 4 2 What happened to Beta Industries revenue in Q3 2023? bm25 5 2 What happened to Beta Industries revenue in Q3 2023? emb 5 1 What happened to Beta Industries revenue in Q3 2023? hybrid 5 2 recall@1 (fraction of queries where the right chunk ranks #1): bm25 plain 0/2 contextual 0/2 emb plain 0/2 contextual 2/2 ``` Contextualizing the chunks moves the answer from rank **4–5** to rank **1–2** across BM25, embeddings, and their fusion — and embeddings recall@1 goes from **0/2 to 2/2**. On this toy corpus fusion lands the answer at #2 rather than #1 (a small-N artifact of RRF), which is a good reminder that the fusion win is an *aggregate* effect — which is exactly what Anthropic's real evaluation measures. ## What the real evaluation found On Anthropic's benchmark (top-20 retrieval failure rate, i.e. `1 - recall@20`), stacking the techniques compounds: - **Contextual Embeddings** alone: `5.7% → 3.7%` — a **35%** cut in the failure rate. - **+ Contextual BM25**: `5.7% → 2.9%` — **49%**. - **+ reranking**: `5.7% → 1.9%` — **67%**. The lexical half matters more than you'd guess — BM25 nails exact identifiers (error codes, ticker symbols, function names) that embeddings smear together, so contextualizing *both* indexes and fusing them beats either alone. ## Things worth copying from the post - **Retrieve top-20, not top-5/10.** Anthropic found 20 the most performant cut for the final context. - **Rerank the shortlist.** Retrieve ~150 candidates, then rerank down to 20 for the answer prompt — that's the step that takes the failure rate from 2.9% to 1.9%. - **Embedding model matters.** Gemini and Voyage embeddings were the standouts in their tests. - **Chunking still matters.** Size, boundary, and overlap all move the numbers — Contextual Retrieval sits on top of good chunking, it doesn't replace it. - **A domain-tuned context prompt beats the generic one.** The template above is a floor, not a ceiling. Be honest about what this costs. Contextual Retrieval adds a Claude call **per chunk** at index time — cheap per token with caching, but real latency and spend when you're indexing millions of chunks, and it has to re-run when documents change. My repro is a minimal reproduction of the *core mechanism* (context lifts rank); the `35 / 49 / 67%` figures are Anthropic's, on their corpus. And my playground scores with a TF-IDF cosine, not a real embedding model — it shows the *shape* of the effect, not production numbers. ## The takeaway The move is almost embarrassingly simple: spend a cheap, cached Claude call per chunk to write down the context a human would need to make sense of it, then index that. It attacks the failure at its source instead of papering over it downstream, it helps lexical and semantic retrieval at the same time, and — as the little repro above shows — you can watch the right chunk climb the rankings the moment the context goes in. --- *Source: [Introducing Contextual Retrieval](https://www.anthropic.com/engineering/contextual-retrieval) (Anthropic). The prompt and the `35 / 49 / 67%` numbers are theirs; the repro and the browser playground are mine, and the playground's scoring runs entirely client-side.* --- # Qwen-BIM: a 14B model that matches 671B DeepSeek-R1 — by knowing its domain > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/blog/qwen-bim-domain-beats-scale > date: 2026-06-09 > tags: llm, fine-tuning, domain-models, bim, paper-notes Here's the headline from [Qwen-BIM](https://arxiv.org/abs/2602.20812) (Lin et al., Tsinghua, Feb 2026): a fine-tuned **14B** model scores **0.83 on G-Eval** for BIM-based design tasks — essentially tied with **DeepSeek-R1 at 671B** (0.84), and ahead of its own 72B sibling. A model ~48× smaller, matching the frontier on a specific domain. That result is not surprising on its own — "fine-tune a small model on your domain" is folklore by now. What makes the paper worth reading is the *anatomy*: where exactly general LLMs fall over on engineering work, and which one design choice did most of the lifting. I work on industrial AI at [Inkers](https://inkers.ai), so domain models over 3D/BIM data are close to home. These are my notes. ## The actual problem: a BIM model isn't text A Building Information Model is a structured graph of components — walls, slabs, beams, each with geometry, materials, and relationships. An LLM can't read it. So step one of *any* LLM-on-BIM pipeline is an unglamorous one the field mostly skips past: **turn the model into text.** The authors do exactly that, carefully. Revit models of five building types (malls, offices, dormitories, teaching buildings, museums) are sliced into spatial blocks of ~10–15 components each, defects are injected, and each block is serialized to plain text plus 22 templated questions with **hard-coded reference answers**. That last detail matters: the ground truth is computed by rules, not by another model, so the benchmark isn't measuring one LLM against another LLM's opinion. The questions ladder up in difficulty on purpose: from "list the wall IDs" (extraction) through "compute each slab's area" (calculation) to "is this wall thickness suspicious given residential norms?" (domain reasoning). It's a clean way to see *which rung* a model falls off. The whole pipeline, end to end, is just: project the structured model into text, turn it into supervised Q&A, add reasoning traces, and LoRA-fine-tune a small open model on it. {[ { x: 8, t1: "BIM model", t2: "Revit graph" }, { x: 132, t1: "textualize", t2: "+ 22 questions" }, { x: 256, t1: "BIM-QA", t2: "2,129 pairs" }, { x: 380, t1: "BIM-QRA", t2: "1,364 + reasoning" }, { x: 504, t1: "Qwen-BIM", t2: "LoRA · 14B" }, ].map((n, i) => ( {n.t1} {n.t2} {i < 4 ? : null} ))} structured 3D → text → supervised data → small fine-tuned model ## Where general LLMs actually fail They evaluated 11 general models (ChatGLM, Qwen, DeepSeek — including the 671B DeepSeek-V3/R1). The failure modes are specific and, honestly, familiar from any engineering-LLM project: - **Arithmetic.** Asked for a slab's planar area, Qwen-max picks the *right formula* and still returns the wrong number. The bottleneck isn't understanding — it's calculation. - **Natural-language literalism.** Models misread parentheses in the answer template, or a naming rule ("wall IDs start with Q"), and confidently apply the wrong transform. - **Missing domain knowledge.** Asked to infer a building's floor height, the 14B base model reasons that floor height ≈ slab thickness (120 mm) — coherent chain of thought, wrong mental model, because it was never taught what "floor height" means in practice. The pattern: general models clear extraction and counting, then degrade sharply on calculation, multi-step reasoning, and anything needing design common sense. On the domain-specific design-review tasks, G-Eval is mostly **below 0.8** — not reliable enough to trust. ## The one choice that mattered: reasoning supervision This is the part I'd underline. They built two datasets from the same BIM text: - **BIM-QA** — 2,129 plain question→answer pairs. - **BIM-QRA** — 1,364 question→**reasoning**→answer triples, where the intermediate steps are supervised, not just the final answer. Then they LoRA-fine-tuned Qwen2.5-14B on different mixes. The result is the kind of finding that should change how you build these datasets: | Fine-tuning data | Size | G-Eval | |---|---|---| | 100% QA | 2,129 | 0.69 | | 80% QA + 20% QRA | 2,661 | 0.77 | | 60% QA + 40% QRA | 2,500 | 0.77 | | **100% QRA** | **1,364** | **0.83** | The **smallest** dataset — pure reasoning triples — won, by a wide margin. More reasoning supervision monotonically improved G-Eval, and quality beat quantity outright. Teaching the model *how to get there*, on a third of the data, beat teaching it *what the answer is* on the full set. ## Bigger is not better (and the paper shows it twice) Two clean data points against scale-maximalism: 1. On the general benchmark, **QwQ-32B out-scored DeepSeek-R1 (671B)** on G-Eval. The giant model's verbose reasoning actually *hurt* — it padded answers with redundant text, tanking format and text-similarity scores without improving correctness. 2. After fine-tuning, **Qwen-BIM (14B) matched DeepSeek-R1 (671B)** and beat the 72B and 32B Qwen models on the domain G-Eval. {/* y gridlines at 0.5..0.9 — chart area y 20..200 maps score 0.9..0.5 */} {[0.5, 0.6, 0.7, 0.8, 0.9].map((v) => { const y = 200 - ((v - 0.5) / 0.4) * 180 return ( {v.toFixed(1)} ) })} {[ { label: "base 14B", sub: "Qwen2.5", score: 0.69, hl: false }, { label: "Qwen-BIM", sub: "14B · fine-tuned", score: 0.83, hl: true }, { label: "DeepSeek-R1", sub: "671B", score: 0.84, hl: false }, ].map((b, i) => { const x = 96 + i * 150 const y = 200 - ((b.score - 0.5) / 0.4) * 180 return ( {b.score.toFixed(2)} {b.label} {b.sub} ) })} The improvement from fine-tuning is also *targeted* exactly where you'd want it: | G-Eval | Base 14B | Qwen-BIM | Δ | |---|---|---|---| | General tasks | 0.810 | 0.874 | +0.06 | | Domain-specific tasks | 0.588 | 0.801 | **+0.21** | | Overall | 0.689 | 0.834 | +0.15 | General ability barely moved (it was already fine); the entire gain is concentrated in the domain tasks that were broken. That's the signature of fine-tuning doing the right thing — adding domain competence without trading away the base model's generality. ## What I'd flag It's a careful paper, but keep the scope honest: - **2D only.** Early tests showed the models couldn't do 3D geometry (collision detection), so those questions were cut. The hard part of real BIM reasoning is 3D. - **One narrow task family**, five building types, rule-generated Q&A. G-Eval is an LLM-as-judge metric — better-correlated with humans than BLEU/ROUGE here, but still a proxy. "Data available on request" rather than released. - The "matches 671B" comparison is on *this* benchmark. It's a domain-competence claim, not a general-capability one. ## Why it's the right playbook anyway Strip away the BIM specifics and this is a template for industrial domain models, the kind I think about constantly: you rarely need a frontier model. You need (1) a faithful **text projection of your structured/3D data**, (2) a benchmark with **rule-computed ground truth** so you're measuring competence, not vibes, and (3) **reasoning-supervised** fine-tuning data — quality and chain-of-thought over raw volume. Get those three right and a 14B model on two A6000s reaches the same place a 671B model does, at a fraction of the inference cost. For anyone shipping AI into a real engineering vertical, that economics is the whole game. --- *Paper: [Developing large language model for BIM-based design with domain-specific benchmark and dataset](https://arxiv.org/abs/2602.20812) — Lin, Cai, Ni, Zhou, Pan (2026), arXiv:2602.20812.* --- # This site is managed by Claude > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/blog/hello-world > date: 2026-06-03 > tags: meta, ai, nextjs GitHub READMEs are dead. After Claude and the wave of coding agents, your homepage isn't a static profile — it's a living, agent-readable artifact you can hand to an LLM. This site is **dual-native**: every page is both a clean human document and a machine-readable surface. Try fetching [`/blog/hello-world.md`](/blog/hello-world.md) or [`/llms.txt`](/llms.txt) — an agent gets structured text, you get the rendered page. The whole site is maintained by a crew of Claude agents. New posts, logs, and data updates are authored by skills that validate themselves before shipping. ## What's under the hood The content layer is a single source of truth: MDX files validated with Zod, surfaced identically to humans (this page), to agents (the `.md` variant), and to tools (the MCP server). More on that soon. --- # AA-3D: what 10,000 blind votes on three.js scenes can and cannot tell apart > Satyajit Ghana — Head of Engineering @ Inkers Technology > canonical: https://ai.thesatyajit.com/articles/aa-3d > date: 2026-10-13 > tags: benchmarks, agents, agentic-coding, 3d, pricing For a year the way people compared models at 3D has been a screenshot thread. Someone asks for a pelican riding a bicycle, or a spinning black hole, or a playable pinball table, posts what three models made, and the replies argue about lighting. It is fun and it measures nothing, because nobody controls the prompt, the tools, the time or who is judging. Yesterday Artificial Analysis [turned that into a benchmark](https://x.com/ArtificialAnlys/status/2109791341802885209). AA-3D gives 21 models the same 55 written briefs, the same offline sandbox and the same three-hour clock, has each one build a three.js web page, and asks a recruited panel to pick between two scenes built from the same brief without knowing who made them. The votes become an Elo rating. Claude Sonnet 5.5 is first at 1194, and the post's first finding is that "four models are statistically tied at the top: Claude Sonnet 5.5 (1194), Claude Opus 5.5 (1190), GPT-6 Astra (1165), and Claude Fable 5.1 (1131)."