# Data Quality Gate — Full Reference > Deterministic post-scrape data cleaner and quality gate for AI agents. Repairs how data was **encoded** — residual HTML, mojibake, invisible characters, non-breaking spaces — and never what it **says**. Also returns a reliability verdict. 100% deterministic, no LLM: identical input always produces byte-identical output. One HTTP call, no setup, any tabular JSON, any domain. This document is the complete reference. For a short summary, see [/llms.txt](/llms.txt). ## Three tiers | Tier | Endpoint | Returns | Price | |---|---|---|---| | CLEAN | `POST /api/clean` | the repaired data, as the response body | $0.04 | | CLEAN + AUDIT | `POST /api/clean/audit` | the same data + a replayable, reversible ledger | $0.12 | | VERDICT | `POST /api` | score + facts + RELIABLE / USABLE_WITH_CLEANING / UNRELIABLE | $0.01 | All three run the same deterministic engine, and are paid via x402 — no account, no API key, no signup. Cleaning is documented in its own section near the end of this document; everything between here and there describes the verdict. Which to call: the **verdict** answers "can I trust this source?" — bought once per source, then cached. The **cleaner** answers "make this batch usable" — every extraction run produces new dirt, so it is called once per run. ## Endpoint ``` POST https://www.aidatatools.dev/api Content-Type: application/json ``` Body is either: - a bare JSON array of row objects — `[{...}, {...}]`, or - an envelope object — `{"rawJson": [...]}` or `{"rawJson": [...], "datasetId": "..."}` `GET` on the same URL returns a short usage summary instead of running a check. Never returns a 5xx or a stack trace for bad *data* — a malformed, empty, or huge dataset degrades to a valid, low `UNRELIABLE` verdict with an explanation in `meta.errors`, not an error response. Only a malformed *HTTP request* (unparseable JSON body, wrong method) returns 4xx. ## The 8 checks Each check produces a `facts` block (always present) and, for 6 of them, a 0–100 `score` component (weighted into the overall score — see below). `structure` and `cardinality` are facts-only; they inform reasons/recommendations but do not carry a score weight. 1. **structure** — homogeneity. `homogeneous` is true iff every field name (union of keys across all rows) is present as a key in every row. 2. **completeness** (weight 0.25) — a value is "usable" iff the key is present AND the value is not null AND not an empty/whitespace-only string. Reports `overall_presence_pct`, `complete_rows_pct`, `fully_empty_fields`, `fields_with_missing_values`. 3. **nulls** (weight 0.20) — counts values that are literally JSON `null` (distinct from completeness: does not include empty strings or absent keys). Reports `global_null_pct`, `fields_with_nulls`. 4. **types** (weight 0.20) — per field, the dominant type is the most common type among usable values (int and float merge into `numeric`). Reports `type_coherence_pct`, `mixed_type_fields`, `numeric_stored_as_text` (numeric-looking values stored as strings, e.g. `"$180,000"`). 5. **impossible_values** (weight 0.15) — field-name heuristics (e.g. a field that looks like a rating gets a plausible-range rule, a field that looks like a price gets a non-negative rule) select which rule(s) apply per field; a value failing multiple applicable rules is counted once. This is the only check with a hard gate on the final verdict (see below). 6. **duplicates** (weight 0.10) — two sub-checks: - `exact`: full-row hash (`json.dumps(row, sort_keys=True)`); any row hash repeated more than once is an exact duplicate. - `near`: fuzzy text match via `rapidfuzz.fuzz.token_set_ratio` (threshold ≥ 85) on concatenated string fields, but only fields that plausibly carry distinguishing content (string type, not url/id/image/date-like by name, average length ≥ 12 chars, cardinality ratio ≥ 0.5 — this excludes e.g. a shared `searchQuery` field from dominating the comparison). Skipped (with a stated `reason`) when no field qualifies, when there are under 2 rows, or when the dataset has more than 5,000 rows (pairwise comparison is O(n²); skipped rather than run slowly or time out). 7. **outliers** (weight 0.10) — Tukey fence per numeric field: bounds = `[Q1 - 1.5*IQR, Q3 + 1.5*IQR]` using inclusive quartiles. Fields with fewer than 5 numeric values are skipped (listed in `skipped_fields`) rather than guessed at. The score comes from this fence alone; a facts-only `modified_z_mad` sub-block reports a second, more robust test alongside it (see "Facts-only signals" below). 8. **cardinality** (facts only, no score weight) — `constant_fields`: exactly 1 distinct usable value across ≥ 2 rows (no information content). `quasi_unique_fields`: distinct/usable ratio ≥ 0.95 across ≥ 5 rows (identifier candidates; each is flagged `likely_identifier` by field-name heuristic). ## Facts-only signals (reported, never scored) Three signals report without judging. None of them is part of the weighted score, none is wired into `verdict.reasons`/`recommendation`, and none can change `score.overall` or `verdict.level`. Each simply adds a block to `facts` when it has something to say. That separation is deliberate in every case: a signal that is genuinely useful to a caller is not automatically safe to fold into a number, and the cost of getting that wrong is a silently mis-scored dataset. | Signal | Where it appears | What it reports | |---|---|---| | `price_divergence` | `facts.price_divergence` | same asset priced inconsistently across sources | | `text_cleanliness` | `facts.text_cleanliness` | extraction artifacts inside string fields | | modified z-score (MAD) | `facts.outliers.modified_z_mad` | outliers the Tukey fence structurally cannot see | ### price divergence (financial/crypto data) Not part of the weighted score, not one of the 8 checks above, and not wired into `verdict.reasons`/`recommendation` — it cannot change `score.overall` or `verdict.level`. It only ever adds an additional `facts.price_divergence` block when applicable. This is deliberate: it is new, and folding it into scoring/verdict is a separate decision not yet made. **Why this exists alongside `outliers`:** the generic `outliers` check runs one Tukey fence over an entire numeric column, regardless of what each value represents. A dataset with rows for BTC (~$67k), ETH (~$3.5k), and SOL (~$180) mixed into one `price` column hides a SOL-only anomaly inside the BTC/ETH range — nothing in that column looks statistically unusual once BTC-scale values are in the mix. `price_divergence` groups by entity first (e.g. by `symbol`), then looks for divergence *within* each group only. **Auto-activation:** `facts.price_divergence` appears only when the dataset has both: - a `group_field` — a string field whose name looks like an entity key (`symbol`, `ticker`, `asset`, `instrument`, `pair`) with at least 2 distinct values, and - a `value_field` — a numeric field whose name looks like a price (`price`, `cost`, `rate`). On a dataset without such fields (e.g. e-commerce, real estate), `facts.price_divergence` is simply absent — output is byte-identical to before this check existed. All plausible `(group_field, value_field)` pairs are checked, not just the first match. **Method, stated honestly (two branches depending on sample size per group):** - group has **≥ 5** values: Tukey fence (`IQR*1.5`), the same statistical method/multiplier as `outliers`, computed within the group only. Can identify *which* value is the outlier. - group has **2–4** values: too few points for quartiles to mean anything. Falls back to a ratio heuristic: flagged if `max/min >= 3.0`. Can only say the group disagrees with itself — with exactly 2 points it cannot identify *which* value is wrong (both are equidistant from the group median). - group has **< 2** values: skipped, nothing to compare against. This split is the honest limit of the method: cross-exchange price snapshots often have only 2–5 sources per asset per batch, too few for a rigorous quartile-based test. The 3x ratio fallback trades statistical rigor for catching gross divergences (a wrong decimal, a stale quote, a bad symbol mapping) — not routine bid/ask spread, which stays well under 3x. **Positioning:** built to be called per-batch, before a decision (e.g. before a trading agent acts on an aggregated price snapshot) — like the rest of this tool, this is not a real-time/streaming/HFT feed. Example `facts.price_divergence`: ```json { "applicable": true, "method": "For each (group_field, value_field) pair: group rows by group_field, then per group with >= 5 values, Tukey fence (IQR*1.5, same method as the generic outliers check but computed within the group only); per group with 2..4 values, a ratio heuristic (max/min >= 3.0x) since quartiles aren't reliable below 5 points -- flags group-level disagreement, not a specific bad value. Groups with < 2 values are skipped (nothing to compare).", "pairs_checked": [ { "group_field": "symbol", "value_field": "price", "groups_checked": 3, "groups_flagged": 1, "flagged_groups": [ { "group": "SOL/USDT", "n": 2, "method": "ratio_heuristic", "min": 178.45, "max": 1784.5, "ratio": 10.0, "threshold": 3.0, "note": "fewer than 5 points: cannot compute reliable quartiles here, so this flags the group as internally inconsistent but cannot identify which individual value is wrong" } ] } ], "total_groups_flagged": 1 } ``` ### text cleanliness (scraped / aggregated text) `facts.text_cleanliness` appears when a string field carries extraction artifacts. It exists because `completeness` answers "is a value there?" and `types` answers "is it the right Python type?" — neither can see that a value which IS there, and IS a string, is an artifact rather than data. Six categories, reported separately because they have different remedies: - **`html_markup`** — leftover tags or entities (`
`, `&`). A real-tag regex, so `price < 100` and `Tom & Jerry` are not matches. - **`executable_markup`** — `