Say when the answer can't be given
About 24 minutes to read, with 5 examples in 7 sections.
Written with AI. Verified by people. How we make books
Anyone can check this book or correct it. Report an error · How to fix one
Labs
Learn this chapter by doing it
Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Opens in about 30 seconds.
Run every example exactly as the book did, change the code to see what happens, and try your own ideas on the book's code and data. Nothing to install, and your work is saved. Included with membership: 20 hours a month with Member, or 60 with Member Plus.
Checking your hours…
Checking for your Lab…
Every chapter is read aloud for members. Chapter 1 of every book is free for everyone. Become a member, or sign in.
Read by an AI voice, word for word from this edition's text. Code, output and tables are on the page.
Pocket TTS by Kyutai, voice alba, from Alba MacKenna's recordings, CC BY 4.0.Pocket TTS by Kyutai, voice eve, from VCTK corpus speaker p361, CSTR, University of Edinburgh, CC BY 4.0, adapted by Kyutai.
By the end of this chapter, you can make declining a first-class answer: tell the questions the warehouse can't answer from the ones it can, decline with a reason of a known kind, check that the data for the period asked about has arrived, show the provenance behind every answer, and measure declines with precision and recall, so the agent neither guesses nor refuses too often.
The shop, the online outdoor-gear shop in three regions that this book follows throughout, has an agent that answers from its semantic layer. Chapter 6 built it: the model chooses a metric, its dimensions and its filters, and the layer writes the SQL. On the dev split of the golden questions, that agent got most answerable questions right, and two of its failures are this chapter's subject. Asked "Which region is doing best?", it returned every region's all-time revenue, smallest first, a number for a question that has no single answer. Asked for next quarter's net revenue, it asked whether the person meant the calendar or the fiscal quarter, as if either reading could be answered from data that ends on the last day of June 2026.
Both failures look like answers, and a wrong number from a warehouse is worse than none, because it gets pasted into a deck. Research on text-to-SQL makes the same point: unanswerable questions "may generate not only incorrect text but also executable programs that yield misleading results" (Ren et al.). This chapter gives the agent ways to say no, and the measurements to tell a good decline from a refusal to work. It builds on chapter 6's metrics-first agent rather than on chapter 7's guarded SQL path, so a question no metric covers is still declined here, and the measurements count each such decline as a cost.
Kinds of questions the warehouse can't answer
A question that shouldn't get a single number is one of two kinds, and they need different replies. An ambiguous question has several valid answers: the words fit more than one metric, period or filter, and the right reply is a question back to the person or a stated assumption. An unanswerable question has no valid answer in the warehouse: the data doesn't hold what it needs, or it asks for something SQL can't do, such as a forecast.
The research gives both kinds names you can use. PRACTIQ, a conversational text-to-SQL dataset, sorts them into four ambiguous categories, such as an ambiguous SELECT column, a "Question with multiple valid SQLs that differ in the columns used in the SELECT clause", and four unanswerable ones, such as a nonexistent SELECT column, where "The column that contains the results asked in the question do not exist in the database" (Dong et al.). TrustSQL adds the questions that fail "due to schema incompatibility or functionalities beyond SQL" (Lee et al.). Knowing the schema doesn't remove the ambiguity: AMBROSIA's authors found that "the ambiguity persists even when the database context is provided" (Saparina and Lapata).
The shop's golden questions, from chapter 3, include eight whose right answer is a decline, four in each split, and each carries a decline_type: two ambiguous and two unanswerable per split. The example prints each one with its category in PRACTIQ's terms, or TrustSQL's for the forecast, and searches both schemas for a column whose name matches the words the question needs. Then it answers two ambiguous questions in each of their readings, from the semantic layer. One of the ambiguous questions has no reading to answer: "Who are our best customers?", gq-019, is ambiguous in PRACTIQ's terms, but no metric or dimension ranks customers, so its right reply is a decline, not a question back.
1"""The golden questions whose right answer is a decline: which are ambiguous, which are unanswerable, and why."""2from lib.golden import load_golden3from lib.semantic import MetricRequest, load4from lib.warehouse import connect56con, layer = connect(), load()7# Each decline question's category: PRACTIQ's names, and TrustSQL's for the forecast. Words to look for in the schema.8CATEGORIES = {"gq-005": ("beyond SQL: a forecast", "forecast|predict"), "gq-019": ("ambiguous SELECT column", None),9 "gq-020": ("ambiguous SELECT column", None), "gq-021": ("nonexistent SELECT column", "ship"),10 "gq-042": ("ambiguous filter criteria", None), "gq-043": ("ambiguous values within a column", None),11 "gq-044": ("unsupported join", "view|page"), "gq-045": ("nonexistent WHERE column", "deliver|promise")}12for q in load_golden():13 if q.kind == "decline":14 category, words = CATEGORIES[q.question_id]15 print(f"{q.question_id} ({q.split}, {q.decline_type}): {category}")16 if words:17 found = con.execute("""SELECT string_agg(table_name || '.' || column_name, ', ') FROM information_schema.columns18 WHERE table_schema IN ('raw', 'analytics') AND regexp_matches(column_name, ?)""",19 [words]).fetchall()[0][0]20 print(f" columns matching {words!r}: {found or 'none'}")2122cost = con.execute("""SELECT string_agg(table_name || '.' || column_name, ', ') FROM information_schema.columns23 WHERE table_schema IN ('raw', 'analytics') AND regexp_matches(column_name, 'cost')""").fetchall()24print(f"columns named for cost: {cost[0][0] or 'none'}")25reasons = [r for (r,) in con.execute("SELECT DISTINCT reason FROM analytics.fct_refunds ORDER BY 1").fetchall()]26print(f"refund reasons: {', '.join(reasons)}")27print(f"last order day: {con.execute('SELECT max(order_date_local) FROM analytics.fct_orders').fetchall()[0][0]}")2829# "Which region is doing best?" in fiscal 2026, by three metrics the layer defines.30fiscal_2026 = {"metric_time__fiscal_year": 2026}31for metric, order in (("net_revenue", "-net_revenue"), ("orders", "-orders"), ("refund_rate", "refund_rate")):32 answer = layer.query(con, MetricRequest((metric,), ("order__region",), fiscal_2026, (order,), 1))33 print(f"best region by {order.lstrip('-')}{' (lowest)' if order == metric else ''}: {answer.rows[0][0]}")3435# "What was revenue in Q2?" read as a calendar quarter and as a fiscal quarter of the same year.36for name, value in (("metric_time__quarter", "2025-04-01"), ("metric_time__fiscal_quarter", "2026-Q2")):37 (revenue,), = layer.query(con, MetricRequest(("net_revenue",), (), {name: value})).rows38 print(f"net revenue, {name.removeprefix('metric_time__')} {value}: {revenue:,.2f}")
python unanswerable-cases.pygq-005 (dev, unanswerable): beyond SQL: a forecast
columns matching 'forecast|predict': none
gq-019 (dev, ambiguous): ambiguous SELECT column
gq-020 (dev, ambiguous): ambiguous SELECT column
gq-021 (dev, unanswerable): nonexistent SELECT column
columns matching 'ship': none
gq-042 (test, ambiguous): ambiguous filter criteria
gq-043 (test, ambiguous): ambiguous values within a column
gq-044 (test, unanswerable): unsupported join
columns matching 'view|page': none
gq-045 (test, unanswerable): nonexistent WHERE column
columns matching 'deliver|promise': none
columns named for cost: dim_products.unit_cost, products.unit_cost
refund reasons: changed_mind, damaged, late_delivery, other, wrong_size
last order day: 2026-06-30
best region by net_revenue: EMEA
best region by orders: EMEA
best region by refund_rate (lowest): APAC
net revenue, quarter 2025-04-01: 415,495.84
net revenue, fiscal_quarter 2026-Q2: 467,081.90
Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.
No column in raw or analytics is named for shipping, product views, delivery promises or forecasts, and the last order day is 2026-06-30. So the four unanswerable questions have nothing to compute from, but each has something nearby that a model could reach for. The products table has unit_cost (dim_products.unit_cost, products.unit_cost), which is the product's cost, not shipping. The refunds table has a reason called late_delivery, which counts refunds, not late deliveries. A model that trusts the question's premise will write runnable SQL against those columns: PremiseSQL's authors found that models "are strongly premise-trusting by default and rarely critique flawed premises" (Lin et al.).
The ambiguous questions have answers, too many of them. "Which region is doing best?" in fiscal 2026 is EMEA by net revenue and by orders, and APAC by the lowest refund rate. "What was revenue in Q2?" in 2025 is 415,495.84 euros of net revenue for the calendar quarter and 467,081.90 for the fiscal one, which runs from May to July. Each of those numbers is right for one reading and wrong for the others, and nothing in the result says which reading produced it.
A third kind looks unanswerable and isn't: a filter value no row holds, such as a region called Europe. PRACTIQ lists it as a nonexistent filter value. In the shop, chapter 6's validation refuses it and names the regions that exist, so the model can correct its request instead of declining.
Working rule: Sort every question your golden set marks as a decline into ambiguous or unanswerable, and name its category, so you know which reply it needs. Ask back on an ambiguous question only when every reading is answerable, and decline an unanswerable one with what's missing. For each unanswerable question, find the column or value nearby that a model could mistake for the answer, because that's the wrong number you're guarding against.
Decline with a reason
The two kinds of question need a reply that isn't a number, and a reply the code can read. A decline is an answer: it ends the run, it says why, and the person can act on it. The reason has two parts. One is a sentence for the person, naming what's missing. The other is a kind from a fixed list, for the code, so declines can be counted, routed and scored by kind instead of read one at a time. In chapter 6, the forecast's question back about the calendar or the fiscal quarter scored as correct because it returned no number, and only its trace showed that the reason was wrong.
This chapter's agent, which we call the declining agent, lives in lib/declining.py, a module this chapter adds. Its DecliningToolbox keeps chapter 6's five metric tools and changes what two of them do. cannot_answer takes a kind as well as a reason, and refuses a kind outside the list. query_metric runs two checks in code before it answers: chapter 6's ranking check, which refuses a request that ranks its result over no period, and the freshness check that the section on freshness builds. The prompt, DECLINING in lib/prompts.py, differs from chapter 6's in three places. It lists the kinds the model may give; it allows ask_back only when every option is a request the metrics can answer, chapter 6's working rule; and it states what a wrong number costs, in the form Kalai and colleagues propose: "Answer only if you are >t confident, since mistakes are penalized t/(1-t) points" (Kalai et al.), with t at 0.75, so by the formula a wrong number costs 0.75/0.25 = 3 points and a decline 0.
The recording decline-runs holds the pinned model's replies to that prompt for the dev split's answer and decline questions, 21 of them. It was made by recordings/record_decline_runs.py with the same model file, sampling and seed as every recording in this book, and at most four model calls a run, as in chapter 6. The three forbidden questions on dev aren't recorded, because their decline belongs below the model, in chapter 9. We chose the prompt's changes by reading chapter 6's dev traces, so the dev split informed them. We recorded the dev split once, after writing the prompt, and changed nothing afterwards. The recording's manifest says so in its tuning field, and every replay checks it in part: a reply is found by a hash of the messages that produced it, prompt included, so a changed prompt finds no reply.
The example prints the kinds and who decides each, sends cannot_answer a call without a kind, and replays the four dev decline questions and one answerable question the agent declined.
1"""Decline with a kind from a fixed list and a reason, so declines can be checked and counted like answers."""2from lib.agent import run_agent3from lib.declining import DECLINE_KINDS, MODEL_KINDS, DecliningToolbox, decline_kind4from lib.golden import load_golden5from lib.metric_tools import catalog6from lib.model import RecordedModel7from lib.prompts import declining_prompt8from lib.warehouse import connect910con = connect()11toolbox = DecliningToolbox(con)12for kind, meaning in DECLINE_KINDS.items():13 print(f"{kind}{' (model)' if kind in MODEL_KINDS else ' (code)'}: {meaning}")14print(toolbox.call("cannot_answer", {"reason": "no shipping costs"}).content)15print(toolbox.call("cannot_answer", {"kind": "not_in_data", "reason": "no shipping costs"}).answer.show())1617system, model = declining_prompt(catalog(toolbox.layer)), RecordedModel("decline-runs")18for q in load_golden():19 if q.split == "dev" and q.kind == "decline" or q.question_id == "gq-016":20 trace = run_agent(q.question, system, model, DecliningToolbox(con, toolbox.layer, role=q.role), max_steps=4)21 calls = [step for step in trace.steps if step.kind == "model"]22 print(f"{q.question_id} ({q.decline_type or q.kind}): {q.question}")23 print(f"{q.question_id} ended with: {calls[-1].tool}, kind {decline_kind(trace.answer)}")24 print(f"{q.question_id} model calls: {len(calls)}")25 label = "asks" if trace.answer.provenance.source == "ask_back" else "reason"26 print(f" {label}: {trace.answer.reason.splitlines()[0]}")27 if q.question_id == "gq-021":28 print(f" first reply: {calls[0].content}")29 if q.question_id == "gq-020":30 asked = next(i for i, step in enumerate(trace.steps) if step.tool == "query_metric")31 request = trace.steps[asked].arguments32 print(f"gq-020 request: {', '.join(request['metrics'])} by {', '.join(request['group_by'])}, ordered by "33 f"{', '.join(request['order_by'])}, period: {request.get('filters') or 'none'}")34 print(f"gq-020 check: {trace.steps[asked + 1].content}")35 print(f"gq-020 options: {', '.join(trace.steps[-2].arguments['options'])}")
python cannot-answer-tool.pynot_in_data (model): no metric or dimension holds what the question needs
forecast (model): the question asks about a period that hasn't happened, or for a prediction
no_definition (model): the question uses a term the shop hasn't defined, and no metric settles it
stale (code): the data for the period hasn't all arrived: not loaded yet, or not happened yet
forbidden (code): the role asking may not see the answer (chapter 9)
error: kind must be one of not_in_data, forecast, no_definition; got ''
cannot answer: not_in_data: no shipping costs
gq-005 (unanswerable): What will our net revenue be next quarter?
gq-005 ended with: cannot_answer, kind forecast
gq-005 model calls: 1
reason: forecast: The question asks about a future period that hasn't happened yet, and no data is available for forecasting.
gq-016 (answer): How many customers placed at least one order in 2025?
gq-016 ended with: cannot_answer, kind not_in_data
gq-016 model calls: 2
reason: not_in_data: The metric 'new_customers' cannot use 'order__order_date' as a filter, and no alternative metric or dimension is available to answer the question about customers placing at least one order in 2025.
gq-019 (ambiguous): Who are our best customers?
gq-019 ended with: cannot_answer, kind no_definition
gq-019 model calls: 1
reason: no_definition: The term 'best' is not defined in the available metrics or dimensions, and no metric or dimension can determine customer performance based on this vague criterion.
gq-020 (ambiguous): Which region is doing best?
gq-020 ended with: ask_back, kind None
gq-020 model calls: 3
asks: Which time period are you interested in? For example, the most recent month, the last quarter, or a specific year?
gq-020 request: net_revenue by customer__region, ordered by net_revenue, period: none
gq-020 check: error: request rejected: the request ranks its result but names no period; filter or group by a metric_time grain; or ask the person which period they mean
gq-020 options: last month, last quarter, 2026
gq-021 (unanswerable): How much did shipping cost us in fiscal year 2026?
gq-021 ended with: cannot_answer, kind not_in_data
gq-021 model calls: 2
reason: not_in_data: The shop's data does not include a metric for shipping costs.
first reply: {"kind": "not_in_data", "reason": "The shop's data does not include a metric for shipping costs."}
Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.
The model may give three kinds: not_in_data, forecast and no_definition. Code gives the other two: stale, when the data for the period hasn't arrived, and forbidden, which chapter 9's access rules will give. A call without a kind comes back as an error that lists the allowed kinds, so the model can correct it in its next call.
In the declining agent's run, the forecast, gq-005, ends in a decline of kind forecast, in 1 model call. "Who are our best customers?", gq-019, is declined with kind no_definition, which is the honest reply chapter 6 said was due: the shop's metrics don't rank customers, so no option could have been answered. The shipping question, gq-021, took 2 calls. The model's first reply was the decline's arguments without the tool's name, the loop sent back an error, and the second reply declined with kind not_in_data.
"Which region is doing best?", gq-020, is the one to read closely. The model asked for net revenue by region, ordered, with no period (net_revenue by customer__region, ordered by net_revenue, period: none), the request chapter 6's agent ran. The ranking check refused it because it ranks its result but names no period, and the model then asked back which period the person meant, offering last month, last quarter, 2026. That counts as a correct reply, and it settles half the question. A period doesn't say what "best" means, and the readings give different regions.
The last question, gq-016, asks how many customers placed at least one order in 2025. The warehouse can answer it, but no metric in the layer counts customers who ordered, and chapter 6's agent answered it with the count of new customers, a wrong number. The declining agent declined it as not_in_data, which is honest about the layer, and gave a reason about a filter the metric can't use, which is beside the point.
Working rule: Make declining a tool that ends the run with a kind from a fixed list and a sentence for the person. Let the model choose among the kinds it can judge, such as missing data, a forecast or an undefined term, and let code decide the kinds code can check, such as stale data or a forbidden role. Count declines by kind, and read the reasons in traces, because a decline of the right kind can still give the wrong reason.
Check freshness before answering
A decline the model chooses covers questions the data can't answer at all. A subtler case is data that can answer the question, but hasn't all arrived. A partial total looks complete: the SQL runs, the number has the right shape, and it's too small. Freshness is how far a source's events have arrived, read from when its rows were loaded. dbt's source freshness check works that way: it reads the newest load time from a loaded_at_field, or from loaded_at_query, "A SQL expression that returns the most recent loaded timestamp", and compares it to thresholds such as warn_after, "How old the most recent data can be before a freshness check reports a warning" (dbt). dbt can also read the warehouse's own metadata when you give it neither; we don't, for the reason below.
Freshness has to come from the data, not from the warehouse's table metadata. Snowflake's LAST_ALTERED is the "Date and time the object was last altered by a DML, DDL, or background metadata operation" (Snowflake), so maintenance moves it too. DuckDB's table catalog, duckdb_tables(), has no time column at all (duckdb_tables() time columns: none).
The shop's generator gives every raw table a _loaded_at column, when the nightly load brought the row in, at 02:00 UTC the day after the event, and the marts carry it through. Its refunds feed stopped after June 26, 2026, while orders run to June 30. Some metrics keep changing after their period ends. The refund rate, refund_rate, divides refunded euros by the gross revenue of the same orders, and counts each refund on its order's day. A refund comes weeks after its order, so a month's rate keeps rising until every refund on its orders has come. We call that time the metric's settling window. The freshness check in lib/declining.py compares each feed's newest load with the end of the period a request filters on, plus the settling window of the metrics that read the feed. FEEDS names, for each table a metric reads, the feed whose loads decide its freshness, and SETTLING_DAYS gives the window of each metric counted on an earlier date than its events; every other metric's is zero. Loads are in UTC and the shop's days are local, so the period ends when its last local day ends in the region furthest behind UTC. The example measures the window, estimates why May and June fall short, and runs the check.
1"""Check freshness: refunds stopped loading after June 26, 2026, and come weeks after the order."""2from datetime import date34from lib.declining import (FEEDS, SETTLING_DAYS, DecliningToolbox, check_freshness, refund_delays, refund_shortfall,5 settled_refund_rate)6from lib.semantic import MetricRequest, load7from lib.warehouse import connect, data_as_of89con, layer = connect(), load()10catalog = [c for (c, t, *_) in con.execute("DESCRIBE SELECT * FROM duckdb_tables()").fetchall() if "TIME" in t]11print(f"duckdb_tables() time columns: {', '.join(catalog) or 'none'}")12for table in ("analytics.fct_orders", "analytics.fct_refunds", "analytics.dim_customers"):13 print(f"data as of, {table}: {data_as_of(con, table)}")14delays = refund_delays(con) # the longest delay on settled orders is what SETTLING_DAYS uses15print(f"refunds per day: {delays['per day']:.1f}\nmedian days from order to refund: {delays['median']}\n"16 f"refunds within 30 days of the order: {delays['within 30']:.0%}\n"17 f"refunds within 45 days of the order: {delays['within 45']:.0%}\n"18 f"longest days from order to refund: {delays['longest']}\n"19 f"settling window, order_refunds: {SETTLING_DAYS['order_refunds']} days")20for month in ("2026-04", "2026-05", "2026-06"): # without a check, the layer answers for any month21 (rate,), = layer.query(con, MetricRequest(("refund_rate",), (), {"metric_time__month": month})).rows22 print(f"refund rate, {month}, unchecked: {rate:.2%}")23# Why May and June are short, estimated from the twelve months whose refunds have all come.24print(f"refund rate, settled months: {settled_refund_rate(con):.2%}")25for month, end in (("2026-05", date(2026, 6, 1)), ("2026-06", date(2026, 7, 1))):26 short = refund_shortfall(con, end)27 print(f"{month}, settled months' rate with refunds as loaded: {short['as loaded']:.2%}\n"28 f"{month}, points short, refunds not loaded: {short['not loaded'] * 100:.2f}\n"29 f"{month}, points short, refunds not happened yet: {short['not happened'] * 100:.2f}\n"30 f"{month}, share of the shortfall not loaded: {short['share not loaded']:.0%}")3132print(f"new customers, feed for dim_customers: {FEEDS['analytics.dim_customers']}") # customers load at sign-up33for name, metric in (("refund rate", "refund_rate"), ("new customers", "new_customers")):34 for check in check_freshness(con, layer, MetricRequest((metric,), (), {"metric_time__month": "2026-06"})):35 print(f"{name}, 2026-06: {check.describe()}")36for month in ("2026-06", "2026-05", "2026-04"):37 query = {"metrics": ["refund_rate"], "filters": {"metric_time__month": month}}38 a = (r := DecliningToolbox(con, layer).call("query_metric", query)).answer39 print(f"refund rate, {month}, checked: "40 + (f"{a.rows[0][0]:.2%}, data as of {a.provenance.data_as_of}" if a.answered else r.content))
python stale-refunds.pyduckdb_tables() time columns: none
data as of, analytics.fct_orders: 2026-07-02 02:00:00
data as of, analytics.fct_refunds: 2026-06-27 02:00:00
data as of, analytics.dim_customers: 2026-06-15 02:00:00
refunds per day: 1.2
median days from order to refund: 27.5
refunds within 30 days of the order: 56%
refunds within 45 days of the order: 93%
longest days from order to refund: 49
settling window, order_refunds: 49 days
refund rate, 2026-04, unchecked: 6.74%
refund rate, 2026-05, unchecked: 6.85%
refund rate, 2026-06, unchecked: 0.54%
refund rate, settled months: 7.21%
2026-05, settled months' rate with refunds as loaded: 5.78%
2026-05, points short, refunds not loaded: 0.48
2026-05, points short, refunds not happened yet: 0.95
2026-05, share of the shortfall not loaded: 33%
2026-06, settled months' rate with refunds as loaded: 1.17%
2026-06, points short, refunds not loaded: 0.46
2026-06, points short, refunds not happened yet: 5.57
2026-06, share of the shortfall not loaded: 8%
new customers, feed for dim_customers: analytics.fct_orders
refund rate, 2026-06: analytics.fct_orders last loaded 2026-07-02 02:00, period ends 2026-06-30: complete
refund rate, 2026-06: analytics.fct_refunds last loaded 2026-06-27 02:00, period ends 2026-06-30, its rows can arrive through 2026-08-18 (49 days later): STALE (not loaded, not happened yet)
new customers, 2026-06: analytics.fct_orders last loaded 2026-07-02 02:00, period ends 2026-06-30: complete
refund rate, 2026-06, checked: declined: stale: analytics.fct_refunds was last loaded at 2026-06-27 02:00, and rows for the period ending 2026-06-30 can arrive through 2026-08-18, 49 days later (not loaded, not happened yet), so the answer would be missing rows
refund rate, 2026-05, checked: declined: stale: analytics.fct_refunds was last loaded at 2026-06-27 02:00, and rows for the period ending 2026-05-31 can arrive through 2026-07-19, 49 days later (not loaded, not happened yet), so the answer would be missing rows
refund rate, 2026-04, checked: 6.74%, data as of 2026-06-27 02:00:00
Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.
The refunds were last loaded at 2026-06-27 02:00:00, and the orders at 2026-07-02 02:00:00. On orders placed before March 2026, old enough for all their refunds to have come, 56% of refunds came within 30 days of the order and 93% within 45, and the longest took 49 days. So the refund rate's window is 49 days, the longest delay the shop has seen.
Without a check, the layer reports a refund rate of 0.54% for June 2026, against 6.85% for May and 6.74% for April. June's fall has two causes: some of June's refunds were made but never loaded, and most haven't been made yet. The twelve settled months, May 2025 to April 2026, estimate each. Their refund rate is 7.21%, and 1.17% counting only the refunds made by the point the feed's last load reaches for June. Of June's shortfall, refunds not loaded account for about 0.46 points and refunds not yet made for about 5.57: the stale feed explains only 8% of the shortfall. May's rate looks normal and is short too, by about 0.48 points not loaded and 0.95 not yet made, 33% from the stale feed. These are estimates from other months, not measurements.
The check finds June's orders complete and its refunds short for both causes, and the tool declines May and June with kind stale and a reason that names the feed, its last load and the last day the period's refunds can come. The model never sees a number to report. April's window ended before the refunds' last load, so its rate is answered, with the refunds' load time in its provenance.
The naive version of the check misfires. The customers table was last loaded at 2026-06-15 02:00:00, because a customer's row loads when they sign up, and nobody signed up after mid-June. Checked against its own loads, June's new customers would be declined. But a customer's first order date comes from the orders, so FEEDS maps the customers table to the orders feed, and June's new customers pass. Which feed decides a table's freshness is a fact about your pipeline, and it belongs in a mapping a person writes, as dbt sources declare their own loaded_at_field.
We decline a period that hasn't settled rather than answer with a warning, as a rule of thumb. A warning travels with the answer only until someone copies the number. A decline that names the last load and the last day the period's events can come lets the person ask for a period that has settled.
Working rule: Check freshness in code before every answer that names a period: compare the newest load of each feed the answer reads with the end of the period plus the metric's settling window, and decline as stale when the feed stops short, whether its rows haven't loaded or haven't happened. Measure each window from your own data, as the delay that covers the events you've seen. Read load times from your own load columns or a load log, never from table metadata, and map each table to the feed that decides its freshness. Answer a question with no period, and show its data as of in the provenance.
Show the provenance with every answer
A decline tells the person why there's no number. An answer owes them the same: where the number came from. An answer's provenance is the metric or SQL behind it, the filters, the role it was computed for and how fresh the data was. The shop's Answer has carried answer.provenance since chapter 1. This section is about showing it. We found no study that measures whether showing provenance makes people catch more wrong numbers, so this is our design choice, not a measured effect. The reasoning is that a person checks a metric's definition in seconds, and a query in minutes.
provenance_card() in lib/declining.py prints an answer with its provenance: the result, each metric's name and the definition its owner wrote in the semantic layer, the filters, the role, the data as of, the freshness check for each feed and, optionally, the SQL. A decline's card is its kind, its reason and the role. The example replays three of the declining agent's runs: net revenue by region in March 2026, which it got right; total revenue by sales region in 2025, which it got wrong; and the cost of shipping, which it declined.
1"""Show the provenance with every answer: the metric's definition, filters, role, freshness and the SQL that ran."""2from lib.agent import run_agent3from lib.declining import DecliningToolbox, provenance_card4from lib.golden import golden_result, grade, load_golden5from lib.metric_tools import catalog6from lib.model import RecordedModel7from lib.prompts import declining_prompt8from lib.semantic import load9from lib.warehouse import connect1011con, layer = connect(), load()12system, model = declining_prompt(catalog(layer)), RecordedModel("decline-runs")13golden = {q.question_id: q for q in load_golden()}14for question_id in ("gq-002", "gq-012", "gq-021"):15 q = golden[question_id]16 toolbox = DecliningToolbox(con, layer, role=q.role)17 trace = run_agent(q.question, system, model, toolbox, max_steps=4)18 print(f"{question_id}: {q.question}")19 print(provenance_card(trace.answer, layer, toolbox.freshness, sql=question_id == "gq-002"))20 print(f"{question_id} outcome: {grade(q, trace.answer, golden_result(con, q))}")21 print()
python answer-with-provenance.pygq-002: What was net revenue in each region in March 2026?
order__region net_revenue
AMER 51,762.14
APAC 35,296.93
EMEA 63,023.76
metric: net_revenue: Revenue in EUR after discounts and before tax, from real orders that weren't cancelled, counted on the order's local business day. Returned orders count; refunds are not subtracted.
filters: {'metric_time__month': '2026-03'}
role: analyst
data as of: 2026-07-02 02:00:00
freshness: analytics.fct_orders last loaded 2026-07-02 02:00, period ends 2026-03-31: complete
sql:
SELECT
orders.region AS order__region,
SUM(orders.net_revenue_eur) AS net_revenue
FROM analytics.fct_orders AS orders
WHERE
orders.status <> 'cancelled'
AND CAST(DATE_TRUNC('MONTH', CAST(orders.order_date_local AS DATE)) AS DATE) = CAST('2026-03-01' AS DATE)
GROUP BY
orders.region
ORDER BY
order__region ASC
gq-002 outcome: correct
gq-012: What was total revenue for each sales region in 2025?
order__region gross_revenue
AMER 538,429.66
APAC 444,472.81
EMEA 897,698.19
metric: gross_revenue: Revenue in EUR including tax, before refunds, from real orders that weren't cancelled.
filters: {'metric_time__year': 2025}
role: analyst
data as of: 2026-07-02 02:00:00
freshness: analytics.fct_orders last loaded 2026-07-02 02:00, period ends 2025-12-31: complete
gq-012 outcome: wrong
gq-021: How much did shipping cost us in fiscal year 2026?
declined: not_in_data: The shop's data does not include a metric for shipping costs.
role: finance
gq-021 outcome: correct
Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.
The first card, gq-002, is the answer with everything behind it. Net revenue's definition says what the number includes, the filter says March 2026, the freshness line says the orders feed was complete for that period, and the SQL shows the cancelled orders left out. Its outcome is correct.
The second card, gq-012, is why the card exists. The question asks for total revenue, and the shop's finance team means net revenue by it. The agent chose gross_revenue, and the card's definition line says "Revenue in EUR including tax". A person who reads only the numbers sees three plausible regional totals. A person who reads the first line under them sees that tax is included and asks again. The outcome is wrong, and the card made the mistake visible without the SQL.
The third card is a decline with its kind and its reason, not_in_data, and the role it was computed for, finance. A decline needs no SQL, but it needs the role: when chapter 9 adds access rules, the same question can be answered for one role and declined for another.
Working rule: Show every answer with its provenance, and put the metric's name and definition first, since that's where a near-miss metric shows. Then show the filters, the period, the role and the data as of, and keep the SQL one step away for the people who check it. Show declines the same way, with their kind, reason and role.
Measure declines, not only accuracy
The declines, freshness check and provenance change how the agent fails, and a score has to show which way. Accuracy doesn't. It counts a decline on an answerable question as a miss, the same as a wrong number, so an agent that guesses scores no worse than one that declines. Kalai and colleagues argue that grading this way rewards guessing (Kalai et al.). Two pairs of measures separate the cases, and one score weighs them.
Decline precision and recall treat a question whose right answer is a decline as a positive, and a run that ended without a number, by a decline or a question back, as a predicted positive. Precision asks how many of the agent's declines were due; recall asks how many of the due declines it made. Coverage and selective risk come from selective prediction: coverage is "the probability mass of the non-rejected region", the share of questions answered, and selective risk is the error rate on those answered (Geifman and El-Yaniv). Lowering the bar for answering raises coverage and usually risk. TrustSQL's penalty score weighs them. Each question scores one point for a right answer or a right decline, nothing for declining an answerable question, and minus c for a wrong number, where "A penalty of −c is assigned when the generated SQL is incorrect" or when the model answers a question it should have declined; the score is the mean over the questions (Lee et al.). TrustSQL's questions to decline are unanswerable ones only, and it leaves resolving ambiguity out of its scope. We score the ambiguous questions too, as questions to decline, and count a question back on one as a right decline, so our score is TrustSQL's adapted to the shop's golden set. With c at 0 it is the share of correct replies, as accuracy is; with c large, one wrong number cancels many right ones. As formulas, where a wrong number includes any number given for a question that should be declined:
decline precision = correct declines / all declines
decline recall = correct declines / questions whose right answer is a decline
coverage = questions answered with a number / questions asked
selective risk = wrong numbers / questions answered with a number
penalty score = (right answers + correct declines − c × wrong numbers) / questions asked
A run that hit the step limit is neither a decline nor an answer: it adds nothing to the score and counts as unanswered for coverage. Tally in lib/declining.py computes all five. The example replays chapter 6's metrics-first agent and the declining agent on the same 21 dev questions, 17 answerable and 4 to decline, and prints each measure, with c at 3, the cost the prompt states, and at 10, a value TrustSQL uses. Choosing c is a judgement about what a wrong number costs, not a measurement.
1"""Measure declines, not only accuracy: precision, recall, coverage, selective risk and a penalty score, on dev."""2from lib.agent import run_agent3from lib.declining import DecliningToolbox, Tally4from lib.golden import golden_result, grade, load_golden5from lib.metric_tools import MetricToolbox, catalog6from lib.model import RecordedModel7from lib.prompts import declining_prompt, metrics_first_prompt8from lib.semantic import load9from lib.warehouse import connect1011con, layer = connect(), load()12agents = {"metrics-first": (metrics_first_prompt(catalog(layer)), RecordedModel("metric-agent-runs"), MetricToolbox),13 "declining": (declining_prompt(catalog(layer)), RecordedModel("decline-runs"), DecliningToolbox)}14# The dev split's answer and decline questions; forbidden questions are decided below the model, in chapter 9.15questions = [q for q in load_golden() if q.split == "dev" and q.kind in ("answer", "decline")]16for name, (system, model, Toolbox) in agents.items():17 tally, by_type = Tally(), {"ambiguous": Tally(), "unanswerable": Tally()}18 for q in questions:19 answer = run_agent(q.question, system, model, Toolbox(con, layer, role=q.role), max_steps=4).answer20 outcome = grade(q, answer, golden_result(con, q))21 tally.add(q.question_id, q.kind, answer, outcome)22 if q.kind == "decline":23 by_type[q.decline_type].add(q.question_id, q.kind, answer, outcome)24 for ending in ("right", "wrong", "abstained", "step limit"):25 print(f"{name} {ending}: {tally.count(ending=ending)} of {len(questions)}")26 for label, kind, ending in (("wrong numbers", None, "wrong"), ("declined answerable", "answer", "abstained")):27 ids = [i for i, k, e in tally.rows if kind in (None, k) and e == ending]28 print(f"{name} {label}: {', '.join(ids) or 'none'}")29 print(f"{name} decline precision: {tally.precision:.2f}")30 print(f"{name} decline recall: {tally.recall:.2f}")31 for kind, part in by_type.items():32 print(f"{name} recall, {kind}: {part.count('decline', 'abstained')} of {part.count('decline')}")33 print(f"{name} coverage: {tally.coverage:.2f}")34 print(f"{name} selective risk: {tally.selective_risk:.2f}")35 for c in (3, 10):36 print(f"{name} penalty score, c={c}: {tally.penalty_score(c):.2f}")
python decline-precision-recall.pymetrics-first right: 12 of 21
metrics-first wrong: 5 of 21
metrics-first abstained: 3 of 21
metrics-first step limit: 1 of 21
metrics-first wrong numbers: gq-012, gq-016, gq-017, gq-018, gq-020
metrics-first declined answerable: none
metrics-first decline precision: 1.00
metrics-first decline recall: 0.75
metrics-first recall, ambiguous: 1 of 2
metrics-first recall, unanswerable: 2 of 2
metrics-first coverage: 0.81
metrics-first selective risk: 0.29
metrics-first penalty score, c=3: 0.00
metrics-first penalty score, c=10: -1.67
declining right: 11 of 21
declining wrong: 4 of 21
declining abstained: 5 of 21
declining step limit: 1 of 21
declining wrong numbers: gq-008, gq-012, gq-017, gq-018
declining declined answerable: gq-016
declining decline precision: 0.80
declining decline recall: 1.00
declining recall, ambiguous: 2 of 2
declining recall, unanswerable: 2 of 2
declining coverage: 0.71
declining selective risk: 0.27
declining penalty score, c=3: 0.14
declining penalty score, c=10: -1.19
Updated from sqlglot 30.27.0 to 30.29.0 in Edition 2026.11.3.
Recall rose from 0.75 to 1.00: the declining agent declined or asked back on all four questions, where chapter 6's agent answered "Which region is doing best?". Precision fell from 1.00 to 0.80, because it declined one answerable question, gq-016, the count of customers who ordered. Coverage fell from 0.81 to 0.71, and selective risk moved from 0.29 to 0.27. The penalty score at c = 3 went from 0.00 to 0.14, and at c = 10 from -1.67 to -1.19.
The wrong numbers tell the rest. Chapter 6's agent gave gq-012, gq-016, gq-017, gq-018, gq-020; the declining agent gave gq-008, gq-012, gq-017, gq-018. Two left the list by the routes this chapter built: gq-020 through the ranking check and a question back, and gq-016 through a decline. One joined it: gq-008, total revenue in 2025, which chapter 6's agent answered with net revenue and this one with gross revenue, the near-miss pair chapter 6 described. The prompt's sentence that a metric with a similar name isn't the same metric didn't prevent it, though with one sample per call, a single flip such as gq-008 may be chance. The rest are the near-miss metrics of gq-012 and gq-018, and gq-017, which needs the asker's region, chapter 9's work. Both agents score below zero at c = 10: a handful of wrong numbers outweighs every right one, which matches TrustSQL's own finding, that "achieving high scores under severe penalties requires significant effort".
Read these as small numbers. The dev split has four questions to decline, so recall moves in steps of a quarter, and one question of 21 changes coverage by about five points. The prompt's changes were chosen from chapter 6's dev traces, so the dev numbers are optimistic, and this chapter records nothing on the test split. The direction agrees with larger studies: prompting helps abstention but doesn't fix it (Kirichenko et al.), and prompts that push models to critique questions also raise "false invalid judgments" on valid ones (Lin et al.).
Working rule: Score an agent on declines as well as answers: report decline precision and recall, coverage and selective risk, and a penalty score with a c your stakeholders agree to, stated as a judgement. Compare agents on the same questions, and list the wrong numbers each gave, because a change that removes one wrong number can add another. When the decline questions number in single digits, read the runs, not only the rates.
What goes wrong in production
The shop's declining agent declines the dev questions it should, and pays for it with one answerable question. An agent with declines over a real warehouse fails in these ways, and each has a way to notice.
A quiet feed looks stale. The freshness check reads the newest _loaded_at in the data, and a feed with no events on a day loads no rows that day. The shop's refunds arrive at about 1.2 a day, so a quiet weekend would make a complete period look stale. A load log that records every run, with its row count, fixes this; dbt's loaded_at_query exists for sources like that. You notice by counting stale declines per feed: a feed that is declined on most days is quiet, not late.
A settling window goes out of date. Half the shop's refunds come more than 27.5 days after their order, and the window is the longest delay seen so far. A longer returns period, or a slower payment provider, makes the window too short, and the check passes months that are still rising. Measure each window again on a schedule, from orders old enough to have all their events, and watch the share of events that arrive after it.
A series' last period escapes the check. The check reads the period a request filters on. A request grouped by month with no period filter returns every month, the last one partial, and passes. Check the last group of any time series against the feeds, or require a period in every request that groups by time.
Declines grow where the layer stops. The declining agent declined gq-016 because no metric counts customers who ordered. Each not_in_data decline on a question the warehouse could answer is a gap in the layer: read them as a backlog for a metric to write or for chapter 7's guarded SQL.
The kind can be right and the reason wrong. gq-016's reason talks about a filter, and chapter 6's forecast reply asked about the wrong thing. The kind is for counting; the sentence is for the person, and the model writes it. Read a sample of reasons each week, and keep the kinds few enough that a person can tell them apart.
A confidence target in the prompt isn't calibration. The prompt asks the model to answer only above 75% confidence, and the model has no reliable way to know its confidence: LLMs verbalizing their confidence "tend to be overconfident" (Xiong et al.), and one unreviewed study puts "query executability" among signals that rank correct SQL above incorrect only at about 0.61 to 0.68 AUROC (Richardson). Prefer checks in code, such as an unknown metric, a ranking over no period or a stale feed, and treat the prompt's target as a nudge.
A question back can settle half the question. gq-020 asked about the period and left "best" undefined, and the score counts it as correct. Read questions back in traces, and give the ask-back tool options that name metrics, so the person chooses a definition.
Working rule: Before you widen the agent's audience, give the freshness check a load log, measure each metric's settling window again on a schedule, check every time series' last period, count declines by kind and feed, and read a sample of reasons and questions back. Start with the not_in_data declines on answerable questions, because each one names a metric to write.
Decisions this chapter made
- Declining is a tool that ends the run, with a kind from a fixed list and a sentence for the person. The model chooses among
not_in_data,forecastandno_definition; code givesstaleand, from chapter 9,forbidden. - Ambiguous and unanswerable questions are told apart, each named in PRACTIQ's or TrustSQL's terms. Ask back only when every reading is answerable.
- Checks in code come before the model's judgement: the ranking check refuses a ranking over no period, and the freshness check declines a period whose feed stops short of its end plus the metric's settling window.
- Freshness comes from load times in the data, mapped from each table to the feed that decides it, never from table metadata.
- A period that hasn't settled is declined, not answered with a warning, as a rule of thumb, with a reason that names the feed's last load and the last day its events can come. The refund rate's window is the longest delay the shop's refunds show.
- Every answer shows its provenance, metric and definition first, then filters, role, data as of and freshness, with the SQL one step away.
- Declines are measured with precision and recall, coverage and selective risk, and a penalty score at c = 3 and c = 10, TrustSQL's mean with the ambiguous questions scored as declines, on the dev split's 21 answer and decline questions, beside chapter 6's agent. Forbidden questions are left to chapter 9.
- The prompt was written from chapter 6's dev traces and recorded once, with no test split recorded for this chapter.
This chapter, in this edition
Edition 2026.11.3Examples passed Nov 6, 20265 of 5 examples in this chapter pass
Anyone can check this book or correct it. Found something wrong? Report it with the section's ID, the last part of the link beside each heading, or fix it yourself with a pull request.