<?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>D L Narayana — Notes</title><link>https://dln-portfolio.vercel.app/notes/</link><atom:link href="https://dln-portfolio.vercel.app/feed.xml" rel="self" type="application/rss+xml"/><description>Engineering notes distilled from public repositories: data pipelines, AI systems and products.</description><language>en</language><lastBuildDate>Tue, 15 Sep 2026 09:00:00 GMT</lastBuildDate><item><title>Zero inference APIs: ML in the browser tab</title><link>https://dln-portfolio.vercel.app/notes/zero-inference-apis/</link><guid isPermaLink="true">https://dln-portfolio.vercel.app/notes/zero-inference-apis/</guid><pubDate>Tue, 15 Sep 2026 10:20:00 GMT</pubDate><description>VeriLens, VeriDoc Studio and DocuForge run detection, OCR and forensics client-side. What it took to make “the document never leaves the device” true.</description><content:encoded><![CDATA[<p>Document intelligence is usually a black-box API: upload the passport, receive a verdict. Three of my projects take the opposite constraint — every stage runs in the browser and, after the initial load, there are zero network calls. The constraint shaped almost every engineering decision.</p><h2>Make every stage replaceable</h2><p>VeriDoc Studio verifies Aadhaar, PAN, Driving Licence, Passport and Voter ID through eight stages — image-quality scoring, WASM OCR, type classification, field parsing, checksum validation, ONNX face detection and ELA forgery analysis. Every stage resolves through a ModelRegistry, so the OCR engine, the classifier or the face detector can be swapped for a fine-tuned model without touching the UI.</p><p>Aadhaar numbers are validated with the real Verhoeff checksum UIDAI uses, and the parser corrects the classic OCR confusions (O→0, I/L→1) behind digit-presence guards so it never “corrects” a genuine letter.</p><h2>Benchmark the exact engines you ship</h2><p>DocuForge runs five forensic engines — Error Level Analysis, copy-move detection, noise consistency, EXIF forensics and JPEG quantisation analysis — each a vanilla ES2017 module producing a heatmap and a score, fused with a weighting that favours spatial evidence. The important part is how it is measured: the production engines run end-to-end in Chromium under Playwright against a reproducible 64-document dataset, including recompression and noise attacks.</p><p>ROC-AUC 0.947 on the clean split and 0.936 after JPEG q60 laundering — measured on the code that ships, not on a Python re-implementation of it.</p><h2>Keep a research path back into the browser</h2><p>Both projects carry a PyTorch layer: DocuForge trains an EfficientNet-B0 over ELA maps of CASIA v2 with an evaluation harness and a model card; VeriDoc goes from synthetic cards through LoRA fine-tuning to ONNX export. The loop closes when a trained model drops back into the registry and the same Playwright suite decides whether it earned its place.</p><ul><li>Payload is a budget line — see When INT8 was the wrong answer.</li><li>Everything is vendored; a CDN outage cannot break a verification.</li><li>End-to-end tests are the accuracy gate, because the browser is the runtime.</li></ul>]]></content:encoded><category>WebAssembly</category><category>ONNX</category><category>Forensics</category></item><item><title>Agents that act need a gate that says no</title><link>https://dln-portfolio.vercel.app/notes/agents-that-act-need-a-gate/</link><guid isPermaLink="true">https://dln-portfolio.vercel.app/notes/agents-that-act-need-a-gate/</guid><pubDate>Tue, 15 Sep 2026 10:00:00 GMT</pubDate><description>AeroSentry lets AI agents schedule, inspect and alert on drone fleets — and stops every high-risk action at a deterministic policy check and a human approval.</description><content:encoded><![CDATA[<p>Agentic systems that only suggest are safe but useless. Agents that act are useful but dangerous. Drone operations need something that can schedule, inspect and alert on its own, yet can never launch, abort or escalate without a policy check and an operator’s explicit approval.</p><h2>Shape of the system</h2><p>A LangGraph supervisor loads persistent SQLite memory and routes each request to one of three specialists — planner, analyst, knowledge. Agents run a ReAct tool loop over Pydantic-validated Python functions: telemetry, weather, geofence, schedule, abort. Validation at the tool boundary means a malformed argument fails loudly before it touches anything real.</p><p>The knowledge agent answers from operator manuals and incident logs through hybrid retrieval — BM25 and dense embeddings fused with Reciprocal Rank Fusion — and cites what it used. The analyst reads aerial thermal frames with a vision-language model and falls back to a deterministic numpy hotspot detector; an alert’s severity has to be backed by imagery evidence.</p><h2>The gate</h2><p>Before any high-risk action, a safety gate applies hard policy checks — battery floor, wind limit, geofence, altitude ceiling — in plain code, not in a prompt. If the action survives the checks, the graph interrupts: state is persisted and nothing proceeds until a human approves via the API, the console or the CLI. Approval is first-class graph state, not a chat message the model is asked to wait for.</p><p>Determinism where it matters, language models where they help. Policy lives in code and is tested; the LLM plans, explains and retrieves.</p><h2>Proving it keeps working</h2><ul><li>Every LLM call, tool call, routing decision and verdict is traced to JSONL (LangSmith optional).</li><li>A golden-set evaluation harness scores routing accuracy, tool-call accuracy, HITL gating and p50/p95 latency — and runs in CI.</li><li>17 offline tests, FastAPI service, Docker image, GitHub Actions.</li></ul><p>The evals matter most for the gate itself. A regression that lets one high-risk action through unapproved is not a quality dip; it is the failure the whole system exists to prevent, so it is the first thing the suite checks.</p>]]></content:encoded><category>LangGraph</category><category>Human-in-the-loop</category><category>Evals</category></item><item><title>When INT8 was the wrong answer</title><link>https://dln-portfolio.vercel.app/notes/when-int8-was-the-wrong-answer/</link><guid isPermaLink="true">https://dln-portfolio.vercel.app/notes/when-int8-was-the-wrong-answer/</guid><pubDate>Tue, 15 Sep 2026 09:40:00 GMT</pubDate><description>Quantising VeriLens cut the model payload by two thirds. Then the liveness model lost the ability to tell a face from a photo of one — so it stayed FP32.</description><content:encoded><![CDATA[<p>VeriLens is a KYC suite that runs entirely in the browser: face detection, face matching, passive liveness, OCR and tamper forensics on ONNX Runtime Web and Tesseract WASM. Nothing leaves the device — which means every model has to be downloaded to the device, and payload size becomes a product feature.</p><h2>The obvious win</h2><p>Static INT8 quantisation (QDQ format) for UltraFace RFB-320 detection and MobileFaceNet embeddings brought the model payload from 16.6 MB to 5.6 MB — a 66 % cut — with the face-match margin intact: genuine pairs still land around cosine 0.85 while impostors sit near −0.01.</p><h2>The one that broke</h2><p>MiniFASNetV2 scores passive liveness with three-crop test-time augmentation. In FP32 it separates a live face (p ≈ 0.998) from a replayed photo (p ≈ 0.06) by a comfortable margin. Quantised to INT8, that separation collapsed. The headline accuracy on easy cases barely moved; the margin on the cases that matter did.</p><p>Accuracy is a summary. Decisions are made on margins. Quantise per model, and measure the separation you actually threshold on — not a benchmark average.</p><h2>What shipped</h2><ul><li>Detection and embeddings in INT8; liveness deliberately kept in FP32, with the measurement written down in the README rather than hidden.</li><li>A weighted trust score — 0.35 face · 0.25 liveness · 0.25 tamper · 0.15 OCR completeness — under hard-fail rules, producing a verdict and a downloadable JSON audit report.</li><li>About 48 ms for the three models on two vCPUs.</li><li>Everything vendored; Playwright end-to-end tests assert both the genuine flow and the tampered flow.</li></ul><p>The lesson generalises beyond faces. Whenever a model feeds a threshold, the quantity to protect is the distance between the classes on either side of it. Payload budgets are real, but a 5.6 MB download that cannot tell a photo from a person is not a smaller product — it is a different one.</p>]]></content:encoded><category>ONNX Runtime Web</category><category>Quantisation</category><category>Computer vision</category></item><item><title>Fail the run, not the row</title><link>https://dln-portfolio.vercel.app/notes/fail-the-run-not-the-row/</link><guid isPermaLink="true">https://dln-portfolio.vercel.app/notes/fail-the-run-not-the-row/</guid><pubDate>Tue, 15 Sep 2026 09:20:00 GMT</pubDate><description>Retail Lakehouse ETL reads a million defective sales lines. Its quality gate quarantines bad rows with a reason and only stops the job when the share crosses a threshold.</description><content:encoded><![CDATA[<p>A warehouse built on raw retail feeds silently mis-attributes revenue unless the bad data is caught, quarantined and reported. The feeds in this project are deliberately hostile: month-partitioned JSON with malformed lines, duplicate sale IDs, null foreign keys and two timestamp formats.</p><h2>Read with a schema, never infer</h2><p>Every source is read with an explicit StructType in PERMISSIVE mode, so a corrupt line does not kill the job — it is captured in _corrupt_record and counted. Schema drift is detected on a sample and logged before it turns into a column of nulls. In the reference run, 981 corrupt lines were captured this way.</p><h2>Deduplicate deterministically</h2><p>Duplicates are removed with row_number() over a total ordering, so re-running the job on the same input keeps the same row every time. 9,993 duplicate sale lines were dropped, and a second run would drop exactly the same ones.</p><h2>Eight rules, one gate</h2><p>Data-quality rules are declarative — a name and a condition — and produce a per-rule breakdown plus a quarantine table. The job does not fail because a row is wrong; it fails when the quarantined share crosses a threshold. That distinction is the whole point: one bad supplier file should not block the month, but a broken upstream export should.</p><pre>RULES = [
    (&quot;non_null_customer&quot;, F.col(&quot;customer_id&quot;).isNotNull()),
    (&quot;positive_quantity&quot;, F.col(&quot;quantity&quot;) &gt; 0),
    (&quot;known_product&quot;, F.col(&quot;product_id&quot;).isin(known_products)),
    # …five more
]

def gate(df: DataFrame) -&gt; tuple[DataFrame, DataFrame]:
    bad = None
    for name, cond in RULES:
        failed = df.filter(~cond).withColumn(&quot;rule&quot;, F.lit(name))
        bad = failed if bad is None else bad.unionByName(failed)
    good = df.filter(reduce(lambda a, b: a &amp; b, [c for _, c in RULES]))
    return good, bad  # per-rule counts come from bad.groupBy(&quot;rule&quot;).count()</pre><h2>Model history, not just state</h2><p>Dimensions carry SCD Type 2 validity windows and the fact table joins on the version that was valid at order time, so a customer’s segment change never rewrites last quarter. The fact is Hive-partitioned by year and month so Hive, Athena, Synapse or Databricks can query it in place. Marts use LAG, DENSE_RANK and cumulative windows; the MongoDB serving loads are idempotent upserts.</p><p>1,009,989 raw lines read, 50.9 s end-to-end (~19.8K rows/s), 1.30 % quarantined with a per-rule breakdown, five tests, and a JSON run-metrics file for every execution.</p>]]></content:encoded><category>PySpark</category><category>Data quality</category><category>Star schema</category></item><item><title>One set of transforms, two paths</title><link>https://dln-portfolio.vercel.app/notes/one-transform-set-stream-and-backfill/</link><guid isPermaLink="true">https://dln-portfolio.vercel.app/notes/one-transform-set-stream-and-backfill/</guid><pubDate>Tue, 15 Sep 2026 09:00:00 GMT</pubDate><description>LakeFlow’s streaming path and its nightly backfill run the same code, so one pytest suite proves both. That mattered more than the throughput number.</description><content:encoded><![CDATA[<p>Most streaming systems grow a second code path the first time something goes wrong. The stream misses a day, someone writes a batch job to “fix yesterday”, and from then on the two paths drift: the backfill fixes yesterday and quietly reintroduces last month’s bug. LakeFlow was built so that this cannot happen.</p><h2>What the pipeline does</h2><p>PostgreSQL runs with logical WAL and REPLICA IDENTITY FULL, so Debezium emits complete before/after images for every insert, update and delete. Kafka (KRaft) carries one topic per table. Spark Structured Streaming appends the raw envelope to an immutable Bronze layer — checkpointed, partitioned by table and ingest date, exactly-once at the sink.</p><p>Silver is derived inside foreachBatch: latest-per-key on the WAL commit order (ts_ms, then lsn), deletes honoured, and the customers table versioned as SCD Type 2. Rows that fail declarative rules land in a quarantine table with the names of the rules they failed. Gold marts — daily revenue, product ranks, customer lifetime value — are Spark SQL.</p><h2>The one decision that holds it together</h2><p>Every Silver and Gold transform is a plain function from DataFrame to DataFrame. The stream calls them once per micro-batch. The Airflow backfill calls the same functions over a range of Bronze partitions. Nothing in the transforms knows which path invoked it, so the seven pytest tests exercise the logic once and cover both.</p><pre>def to_silver(bronze: DataFrame) -&gt; DataFrame:
    w = Window.partitionBy(&quot;id&quot;).orderBy(F.col(&quot;ts_ms&quot;).desc(), F.col(&quot;lsn&quot;).desc())
    latest = bronze.withColumn(&quot;rn&quot;, F.row_number().over(w)).filter(&quot;rn = 1&quot;)
    return latest  # deletes carry op == &quot;d&quot; and are applied by upsert()

# streaming path — one micro-batch at a time
(bronze_stream.writeStream
    .foreachBatch(lambda df, _: upsert(to_silver(df)))
    .option(&quot;checkpointLocation&quot;, ckpt)
    .start())

# backfill path — the same function over a partition range (Airflow)
upsert(to_silver(spark.read.parquet(bronze_path).where(ingest_day.between(start, end))))</pre><h2>Why replay is safe</h2><p>Upserts are keyed by the business key and ordered by commit position, so replaying the same offsets — or re-running a backfill over the same days — produces identical tables. If a micro-batch fails halfway, the checkpoint replays it and the upsert absorbs the duplicates. Idempotency is a property of the design, not a retry policy.</p><p>Measured on 2 vCPUs: 510,663 CDC events replayed end-to-end into Gold in 29.5 s (~17.3K events/s), with 5,239 rows quarantined — each with the rule that rejected it.</p><p>The throughput is a by-product. The thing that holds is the discipline: one transform set, explicit schemas, a test for every transform, and metrics for every run.</p>]]></content:encoded><category>Spark Structured Streaming</category><category>CDC</category><category>Idempotency</category></item></channel></rss>