AI that gets the numbers right, and shows its work.

I build AI systems that answer questions about company data. They check their own answers, cite their sources, and are tested before anyone relies on them. This page follows one question through the loop that does it.

Xander Bennett · AI engineer · Salt Lake City · Remote or local · Six years in data before this
grounded_sql_agent · askidle
Q
a compensation analyst's question · synthetic warehouse · no customer dataFollow it through the loop ↓
[01]retrieve38 ms

Find the right tables before writing a line of SQL.

Retrieval runs over the dbt manifest and catalog rather than the raw schema, so the agent sees model descriptions, column docs, tests, accepted values, and lineage. The five best matches are all it is allowed to use.

In plain terms: the assistant looks up which tables are relevant and is forbidden from using any others. Most wrong answers from AI over data start with a guessed table or column; this removes the guess.

retrievefct_band_position 0.91 · dim_employee 0.74 · dim_job 0.52 · dim_pay_band 0.47 · stg_pay_bands 0.21 · bm25 · k=5 · 38 ms
retrieve · top 5example
fct_band_position0.91 dim_employee0.74 dim_job0.52 dim_pay_band0.47 stg_pay_bands0.21
What the agent saw. Filled dots were used in the final query. Hybrid retrieval with a reranker is on the milestone list, with the recall delta to be measured.
what it may namethe constraint
fct_band_positiondim_employeedim_jobdim_pay_bandstg_pay_bandsdim_levelfct_comp_eventint_employee_current_comp

Solid chips are retrieved and allowed. Dashed chips exist in the warehouse but were not retrieved for this question; a query that names one is rejected before it runs.

Grounding as a hard rule, not a suggestion in the prompt.
warehouse · dbt lineage18 nodes · real
employees jobs levels locations pay_bands comp_events stg_employees stg_jobs stg_levels stg_locations stg_pay_bands stg_comp_events dim_employee dim_job dim_pay_band int_employee_current_comp fct_comp_event fct_band_position agent SEEDS · 6STAGING · 6DIMENSIONS · INTERMEDIATEFACTSRETRIEVES
seed · intermediatestagingmart, documented and testedagentviolet edges: the models the golden set leans on
What retrieval indexes. Every node with its description, columns, tests, and lineage. That is why “compa-ratio” resolves to fct_band_position, where it is computed, rather than to a column guess. Synthetic warehouse: 1,400 employees who do not exist, 12 dbt models, 35 tests, on DuckDB.
[02]draft1.3 s · 312 tokens

Write SQL that can only name what it found.

The model gets the question and the five retrieved model docs, and returns one SELECT as JSON. The first attempt below is what it actually wrote: it reached for a dim_level table that exists in its head but was never retrieved.

In plain terms: the assistant writes the query, and its first try contains a plausible mistake. The next stage is where that mistake gets caught, which is the whole point.

draft1 statement · 312 output tokens · claude-sonnet-4-5 · 1.3 s
draft · attempt 1as written · one line will fail
-- attempt 1 · constrained to the five retrieved modelsselect  e.job_family,  count(*) as below_midpointfrom fct_band_position as bjoin dim_employee as e on e.employee_id = b.employee_idjoin dim_level as l on l.level_id = e.level_idwhere e.is_active  and l.level_name = 'L4'  and b.compa_ratio < 1.0group by 1order by below_midpoint desc;
Confident and wrong in one place. The join to dim_level is the kind of guess text-to-SQL systems make constantly. Here it cannot survive, because the next stage checks every referenced table against the retrieved set.
[03]validate · repair4 checks · 1 repair · 0.6 s

Four checks before anything runs. One fails, one repair.

Parse as DuckDB SQL, confirm a single read-only SELECT, confirm every referenced table is in the retrieved set, dry-run with EXPLAIN. A failure goes back to the model with the exact error, up to three times. Attempt 1 failed the grounding check; attempt 2 passed all four.

In plain terms: before the query touches any data, it has to pass four tests. The mistake from the previous step is caught here, the assistant is told exactly what was wrong, and it fixes it. Nothing unverified ever runs.

validate✓ parses · ✓ select-only · ✗ refs ⊆ retrieved · referenced dim_level, which was not retrieved
repair 1error fed back · redrafted with level_id on fct_band_position · ✓ ✓ ✓ ✓ explain ok · 0.6 s
validate · attempt 1 → 2real
✓parses as DuckDB SQL · sqlglot
✓single read-only SELECT
✗references ⊆ retrieved · dim_level
repair 1 · error returned to the model · redrafted with level_id on fct_band_position
✓references ⊆ retrieved
✓EXPLAIN dry-run · DuckDB
Before anything runs. A query that names a table the agent never retrieved is rejected without touching the warehouse.
repair · what changedattempt 1 → 2
# the model's second attempt, as a diff against the first
- join dim_level as l on l.level_id = e.level_id-   and l.level_name = 'L4'+   and b.level_id = 'L4'
Two lines out, one line in. The level lives on fct_band_position already; the model just had to be told it could not have dim_level.
PR #12 · back-translation checkexample · on the milestone list
# agent.py · after validate(), before execute(): catch SQL that is valid but answers a different question
  v = validate(draft.sql, allowed, con)
+ back = self.llm.back_translate(v.sql)              # "what question does this SQL answer?"+ align = self.judge.alignment(question, back)+ if align < settings.min_alignment:+     return self.repair(draft, reason=back)- return self.execute(v)+ return self.execute(v, confidence=align)
The failure the four checks cannot see. SQL that runs cleanly but answers a different question is caught by result-set matching only when a golden answer exists. This is the guard for live questions.
[04]execute3 rows · 12 ms

Run it read-only, capped, and timed.

The validated query runs on a read-only DuckDB connection with a 200-row cap. Latency and token cost are logged on every run, so a change that improves accuracy by adding two model calls shows its price.

In plain terms: the query runs against a copy of the data it cannot change, with a limit on how much it can pull back, and the cost and speed of every answer are recorded.

execute3 rows · 12 ms · read-only · row cap 200
result3 rows · execution_match(g004) ✓
strjob_familyintbelow_midpointvizshare of 102
Software Engineering61
Data27
Product14
The join to dim_employee that survived into attempt 2 is redundant, since fct_band_position carries is_active and job_family. The result is still correct, and the eval grades the result set rather than the SQL, so this counts as a pass. Redundant joins are what the retrieval reranker on the milestone list is meant to reduce.
cost · latencyexample
p50
2.1 slatency p50
3.9 slatency p95
$0.006cost / question
The price of an answer. The two spikes are questions that needed a repair. Illustrative numbers until the first baseline is committed.
[05]answer2.1 s · $0.006 · 1 repair

Answer in prose, cite the models, show the cost.

The rows become a sentence a person can read, with the models that produced it attached. The same loop runs behind three surfaces: a command line, a small FastAPI app, and an MCP server so Claude Desktop or Cursor can call it as a tool.

In plain terms: the person gets a plain answer with its sources and a receipt for what it cost. The same engine can be used from a terminal, a web page, or inside an AI assistant.

answerSoftware Engineering has the most L4s below midpoint (61), then Data (27) and Product (14). Active employees only, against current bands.
citesfct_band_position · dim_employee · 1 repair · 2.1 s · $0.006
grounded_sql_agent · answer2.1 s · 1 repair
Software Engineering has the most L4s paid below their range midpoint, 61 people, followed by Data with 27 and Product with 14. This counts active employees against the pay bands in effect today.
fct_band_positiondim_employee102 rows examined
repairs 1latency 2.1 scost $0.006rows 3row cap 200grade execution_match ✓
mcp · toolsreal
fn
ask_warehouse(question)Answer with validated, cited SQL.
fn
search_models(query, k)The dbt models most relevant to a query.
fn
describe_model(name)Full documentation for one model.
Same loop, third surface. Exposed with FastMCP so an assistant can use the agent as a tool.
surfacesone loop · three doors
$
gsa ask "…"Command line, via Typer. Prints the answer, the SQL, and the cites.
↗
POST /askFastAPI, with a small UI at /. The same result object as JSON.
⌥
MCP serverClaude Desktop, Cursor, or any MCP client calls ask_warehouse directly.
Built once, called three ways. Every surface returns the same object: answer, SQL, cites, repairs, latency, cost.
[06]proveoutside the loop · grades every lap

Then prove it, on every change.

A golden set of questions with reference queries, an eval that compares result sets by value, and a CI gate that fails a pull request when accuracy or retrieval recall drops more than three points below the committed baseline. The targets are written down first so the numbers can miss them in public.

In plain terms: the assistant is re-tested every time its code changes, against questions with known correct answers. If it gets worse, the change cannot ship. This is the part most AI features skip.

evals/golden.yamlyaml · real
# one of 12 starter questions · target 50- id: g004  question: Which job families have the most active L4            employees paid below the band midpoint?  models: [fct_band_position]  sql: >    select job_family, count(*) as below_mid    from fct_band_position    where is_active and level_id = 'L4' and compa_ratio < 1.0    group by 1  compare: set  tags: [compa, medium]
How a question is graded. The agent's result set has to match this reference query's; the wording of its SQL is never compared. The question on this page is g004, asked the way a person would type it.
ci · pull request #12example
✓lint · ruff14s
✓tests · pytest41s
✓dbt build · 35 tests1m 08s
✓eval gate · within 3 pts of baseline3m 52s
All checks have passed · 4 successful checks
Where the gate lives. The golden set runs on every pull request. A drop of more than three points in accuracy or recall fails the check.
evals.py · report6 metrics · 12 golden questions today, 50 at v1 · baseline pending
strmetricstrhow it is measurednumv1 targetnummeasured
execution_accuracyThe agent's result set matches the reference query's, by value. Order-insensitive with a float tolerance; scalar and ordered modes where the question demands them.≥ 0.80—
retrieval_recall@5The models a golden question needs appear in the top five retrieved.≥ 0.90—
repair_rateShare of questions that needed at least one repair before a valid query.reported—
failure_rateShare of questions with no valid SQL after three repairs.≤ 0.05—
guardrailsAdversarial slice: destructive requests must be blocked, unanswerable questions must be refused rather than answered.1.00 blocked—
latency_p50 · costSeconds and dollars per question, logged on every run so an accuracy gain shows its price.≤ 5 s · ≤ $0.01—
The measured column fills in when the first baseline is committed to evals/baseline.json. The project is in progress and says so: the first green run and the first committed baseline are the next milestones, and the source goes public with that first baseline.
report · run 14 vs 13example
metricrun 13run 14Δ
execution_accuracy0.780.84+0.06
retrieval_recall@50.920.920.00
repair_rate0.310.22−0.09
latency_p502.4 s2.1 s−0.3 s
cost / question$0.007$0.006−$0.001
Flipped: g009 ✗→✓ · g010 ✗→✓ · g003 ✓→✗. Every run is diffed against the last, so a change that fixes two questions and breaks one is seen as three events rather than averaged into a point of accuracy.
schema-mapper · review queueproject 2 · design
Emp ID → employee_id.98 Base Comp → base_salary.93 Lvl → level_id.71 Bonus Tgt % → bonus_target_pct.64 Loc → location?.38
The same discipline, second project. Confident matches go through, uncertain ones queue for a person, and every correction becomes a labeled example for the next file.
[07]what i build

Three things companies pay for, in plain terms.

Each one ships with a test that says whether it is working. One is the loop above, in progress. Two are queued behind it. Each goes public once it has a committed baseline.

Ask a question, get a trustworthy answer

An assistant that turns a plain-English question like “which teams are paid below market?” into a query against the company's data, checks its own work, and shows where the numbers came from.

For a company, this means fewer wrong numbers reaching a CFO, and analysts spending their time on judgment rather than lookups. grounded-sql-agent · in progress

Turn messy spreadsheets into clean records

Customer files rarely match the format the software expects. This maps the incoming columns onto a standard set, flags uncertain matches for a person to check, and learns from the corrections.

For a company, this means onboarding a new customer in days rather than weeks, with less manual cleanup each time. schema-mapper · queued

Catch AI mistakes before customers do

A safety net that re-tests an AI feature every time it changes, compares the results with the previous version, and blocks the release if quality drops.

For a company, this means AI changes ship with evidence behind them, and problems are found in a test run before a customer sees them. regression-gate · queued

[08]background

Six years in data, then AI.

I came to AI engineering from the data side. In that work, a wrong answer is a number a finance leader has already repeated in a meeting, and that is the standard I hold AI systems to. It is why the loop above spends more of its time checking than generating.

I spent six years at Addepar, a wealth-management data platform, going from operations analyst to owning the reporting and data-modeling layer. I then led an analytics rebuild at Dorsia, including a cloud migration, and ran a small team.

Today I'm a Customer Success Engineer at Pave, a compensation platform. I run technical implementations, clean up the HR and pay data that makes customers' software work, and build the AI workflows my team runs every week: a comp-cycle forecasting agent (Cycle Radar), scheduled CRM and reporting updates, and a customer-feedback sweep that drafts product requests from calls and email.

Outside work: two kids, tabletop games, and a side project teaching game development.

career_metricslive
6yrs
Building data systems
Addepar · Dorsia · Pave
5
AI workflows on a schedule at work
forecasting · CRM updates · feedback · reporting
35
Tests on the flagship's warehouse
12 dbt models · every mart documented
3
Ways to call the agent
CLI · FastAPI · MCP server
  1. 2025 → now
    Customer Success Engineer · PaveImplementations, data engineering, internal AI tooling for the customer team.
  2. → 2024
    Lead Data Analyst · DorsiaAnalytics rebuild, cloud migration, led the team.
  3. 2018 → 2024
    Operations Analyst → Senior Data Analyst · AddeparReporting and data modeling for a wealth-management platform.
  4. 2018 · 2019
    B.S. Finance, University of Utah · Data Science, BloomTechPlus AWS Cloud Practitioner.
character_sheet.sqld20 · the scores are for fun, the evidence is accurate
abilityscoreevidence
INT17Six years of data modeling, now building the retrieval, validation, and eval layers of an AI system from scratch.
CON16Keeps five scheduled AI workflows running at work while building the flagship at night.
WIS15Grades an AI feature on its result set, not on whether the SQL looked right.
DEX14dbt, LookML, SQL, Python, DuckDB, FastAPI, MCP. Comfortable moving between the data layer and the application layer.
CHA13Runs implementations with customer HR and finance teams and translates between them and engineers.
STR12Carried an analytics stack through a cloud migration at Dorsia.
[09]contact

Hiring for AI work that has to be right?

I'm open to full-time AI engineering roles, in Salt Lake City or remote. Send the problem you're stuck on and I'll reply with how I'd approach it and how I'd measure it.

Emailxanderwbennett@gmail.com LinkedInlinkedin.com/in/xander-bennett GitHubgithub.com/xander-bennett RésuméPDF LocationSalt Lake City, UT · remote friendly