Block ads and trackers at the DNS layer before they load - 16 KB of int8 weights running on a NodeMCU. No cloud, no runtime lists, no API calls. The offline, no-dependency Pi-hole, distilled into a single dot product.
Priorities: Quality > Size > Speed
Trained on: AdTrap v1 - 713,539 domains (93,541 ad/tracker + 619,998 legitimate), built from StevenBlack/hosts, AdAway, Yoyo, and Majestic Million.
AdVig is a tiny hybrid logistic regression that classifies any bare domain as BLOCK (ad/tracker) or ALLOW (legitimate) using the domain string alone - the exact input a DNS query carries. No page content, no network calls at inference time. Score = one int32 dot product over hashed character n-grams plus 34 structural features.
Property
Value
Architecture
Logistic regression: hashed char n-grams + structural features
Structural features alone top out at F1 0.634 (XGBoost): most blocklisted spam/parked domains have no structural tells. Lexical memory is what unlocks quality:
model
F1
AUC
gaussian_nb
0.5674
0.8197
logistic_regression (structural)
0.6006
0.8298
decision_tree d8 (structural)
0.6157
0.8112
lightgbm 30x6L15 (structural)
0.6222
0.8515
xgboost 30x4 (structural)
0.6341
0.8598
gram-only LR 2^15
0.6953
0.9180
AdVig hybrid LR 2^14
0.7854
0.9478
On-Device Benchmarks (NodeMCU ESP8266)
Measured on hardware (Arduino core 3.1.2, 240 stratified test domains x 30 passes = 7,200 inferences per run):
Metric
80 MHz
160 MHz
Avg latency
1840 us/domain
933 us/domain
Min / Max
1280 / 3043 us
650 / 1879 us
Throughput
~543 domains/s
~1072 domains/s
Gram-hash phase
144 us
77 us
Structural phase
1685 us
850 us
Memory: int8 weights live in flash (PROGMEM, 16.4 KB, zero RAM); working set is a 2048-entry tally table + bookkeeping (~7.7 KB static). Free heap: 42,192 B.
Accuracy on-device (balanced sample): acc 0.8792, precision 0.9789, recall 0.7750, F1 0.8651 - bit-exact with host emulation, 100% parity (240/240), identical prediction bitmap at both clock speeds.
Scaling is linear with clock (compute-bound); other MCUs scale predictably.
Known headroom: the structural phase dominates (~92% of latency) due to linear PROGMEM lexicon scans; sorted arrays + binary search should roughly halve total latency.
At ~1,000 domains/s, one NodeMCU comfortably keeps up with household-scale DNS traffic.
Use Cases
DNS-level Pi-hole replacement - answer DNS queries with an on-device verdict; fully offline, zero external dependencies
Router/firewall firmware integration - classify unknown domains at query time, complementing exact-match blocklists
Parental controls & IoT gateways - block ad/tracker endpoints on devices that cannot run browser extensions
Privacy tooling research - a compact baseline model for tracker-domain generalization studies
Char 3/4/5-grams over .domain. hashed with FNV-1a (random sign projection) into 2^14 buckets. Inference collapses to logit = SCALE * gram_sum + BIAS + SCALE * dot(STRUCT_W, feats) - trivially portable to any MCU in C. Note BIAS is negative (-1.245059); see the bias-correction note under Usage.
Usage
Python (ONNX Runtime)
python
1import numpy as np
2import onnxruntime as ort
3from gramlib import hash_grams # reference hasher (in repo files)4from features import extract_features # reference structural extractor56sess = ort.InferenceSession("advig.onnx", providers=["CPUExecutionProvider"])7domain ="ads.tracker-cdn.example.com"89grams = np.zeros((1<<14), dtype=np.float32)10for idx, val in hash_grams(domain, buckets=1<<14).items():11 grams[idx]+= np.sign(val)12x = np.concatenate([grams, extract_features(domain)]).astype(np.float32)[None]1314p_block = sess.run(["prob"],{"features": x})[0].item()15blocked = p_block >=0.477
ESP8266 / NodeMCU (C) - reference implementation included
This repo now ships a complete, parity-verified reference implementation:
advig_weights.h - int8 weights. On ESP8266/ESP32 the table carries __attribute__((progmem)), so read it with pgm_read_byte(&ADVIG_W[i]).
advig.c / advig.h - full feature extractor + streaming n-gram scorer (no histogram buffer needed: each gram contributes sign * W[bucket] independently, so inference is O(1) RAM).
Bias correction (2026-08-22): earlier revisions of this card implied a
positive integer bias (31 * SCALE). The actual ONNX bias is
-1.245059. advig_weights.h now ships the correct ADVIG_BIAS_F; do not
reconstruct it from an integer.
Verification & Reproducibility
ONNX parity - 100% match between ONNX Runtime and closed-form sigmoid(Gemm).
On-device parity - 100% (240/240) agreement between NodeMCU firmware and host emulation; confusion matrices identical.
Deterministic - identical test predictions across seeds 42/1/7/123.
No leakage - train/val/test disjoint at the registrable-domain level; synthetic subdomains inherit their parent's split.
int8 verified end-to-end - quantized weights re-evaluated after quantization, not assumed lossless.
Limitations
Enumeration ceiling - random parked/spam domains (05tz2e9.com) carry zero signal in their names; no string-based model can catch them. Pair AdVig with an exact-match blocklist: the list memorizes the tail, AdVig generalizes over unseen trackers.
"ad-" prefix traps - adyen.com-style collateral exists (~1.9% FPR). Raise the threshold for allow-biased operation.
English-centric lexicons - token lists are Western-market oriented.
Feed dependence - inherits StevenBlack/AdAway/Yoyo coverage as of build date; retrain for fresh feeds.
Dataset noise - Majestic top-1M contains some parked/ad-heavy registrable domains treated as ALLOW.