--- name: malloy-model-as-you-go description: 'After answering a data question, write down what the answer assumed so the next reader can trust the number. A field with a #(doc) in the model when you can edit it, an extend in the notebook when you can only author reports, or a stated assumption plus a Malloy snippet when you can only chat. Use after every answered question that rested on a judgment call, and whenever a question is asked against tables that have no model yet.' --- # Model as you go An analysis that lives in a chat transcript is not reproducible. The numbers were right, and six weeks later nobody can say what "revenue" excluded, which of four timestamps was the order date, or whether the last period was complete. The work is unauditable, so it gets redone. The fix is to write the assumptions down while the query is still in front of you, in the most durable place your session can write. Answer the question, codify what the answer assumed, answer the next one. After a handful of questions there is a model, or a notebook, where every definition exists because a real question needed it, and every judgment call is on the record. This skill is the codify step. `skill:malloy-analysis` answers the question; this skill decides what to write down afterwards, and where. > **Tool names** are bare here - `get_context`, `execute_query`, `search_database_schema`. The > exact prefixed name depends on the host; match against the tools you actually have. ## The loop ``` QUESTION → ANSWER → CODIFY ⟲ ↑___________| ``` **The user gets a real answer on question one.** If three tool calls have gone by without producing an insight they can read, you have drifted into modelling for its own sake. Stop and answer something. ## 1. Answer the question, with `skill:malloy-analysis` This skill starts when someone asks a data question. **That question is the unit of work, and it is theirs.** Do not widen it into a modelling project, and do not swap it for a more interesting one you found on the way. **A broad ask is still an ask.** "Analyse the sales data" is a question whose subject is given and whose metric is not. Pick the most obvious question about that subject, say in one line which one you picked, and answer it. Never open with a row-count table and a menu of "analytical domains"; the menu is worth less than the first real answer would have been. Load `skill:malloy-analysis` and follow it: discover the model, construct the query, run it, verify it, present it. Two of its rules matter most here: - **Every number you present comes out of a query.** Adding up the rows of a `limit: 15` table by hand drops everything below the cut, and nobody can re-run it. - **Your first result is a draft.** Work step 5 of `skill:malloy-analysis` (verify before trusting) before presenting. ### Name the decisions the answer rests on **This is what makes the analysis auditable, and it is the step most easily skipped.** Almost every query needs at least one judgment call the data cannot settle. Which of four timestamps is "the order date". Whether returns count as revenue. Whether to count lines or orders. Whether a partial final period belongs in a trend. A judgment call made silently becomes a hidden assumption. It will be wrong for someone eventually, and by then nobody remembers it was a choice rather than a fact. So make the call, run with it, and **say what you chose and what you rejected**, with the other number attached whenever it is cheap to get: > Revenue here excludes cancelled **and** returned orders - that's $8.10M. Counting returns as > revenue and netting them separately gives $9.18M. I went with the stricter one; say if your > reporting does it the other way. Not every choice rises to this. Raise it when **any** of these holds: | Raise it when | Because | |---|---| | The alternative changes the number materially | The user would answer differently depending on which they meant | | You are about to codify it | It stops being your choice and becomes everyone's definition | | A reasonable analyst would pick the other one | It is a convention, not a fact | Otherwise state it in a clause and move on. **Asking about everything is as bad as asking about nothing**; it turns the loop into a form and trains the reader to skim past it. And **give the number under each option, not an abstract question**: "$8.10M excluding them, $9.18M including" lets the user answer in one word. ## 2. When there is no model yet If the tables have no model, define only enough to run the first query. One line is normal: ```malloy source: order_items is my_conn.table('ecommerce.order_items') ``` **Read the columns with `run: source -> { select: * limit: 3 }`.** Prefer it to a schema listing: it returns the columns *and* real values, and the values are what catch the surprises a column list hides - a date stored as a string, a `total` that excludes tax, a metric column that is null on every row. If a schema tool returns something you cannot explain (no columns, or no tables for a filter you can see matches), that is a bug in the tool. Report it. Do not write the workaround into your model or your notes as though it were a property of the data. No `primary_key:`, no dimensions, no measures, no joins, **not yet**. Those arrive in CODIFY, each one paid for by a question that needed it. Adding fields because the table has them is the habit this skill exists to break. Add a join only when *this* question cannot be answered without it, then verify its cardinality before trusting any aggregate (`group_by: fk, aggregate: n is count(), having: n > 1`). If a model already exists, ground yourself in it with `get_context` and reuse what is there. ## 3. CODIFY: write the answer's assumptions down Run this after *every* answered question, while the query is still in front of you. Skipping it is how a session ends with a transcript and nothing else. ### Where it goes depends on what you can write The same assumption lands in a different place depending on the session. Pick the **highest rung your tools allow**, and never skip codifying because the top rung is out of reach. | You can | Codify as | Who inherits it | |---|---|---| | **Edit the model files** (a local package, a draft package, a compile or reload tool) | A `dimension:`, `measure:`, `join_*:`, or `view:` with a `#(doc)`, in the `.malloy` file | Everyone who queries the model. **Confirm binding decisions first** (below) | | **Author notebooks or reports, but not the model** (a viewer of a published package) | In the notebook: `source: orders_q is orders extend { measure: ... }` with the `#(doc)` above it, plus a markdown cell stating the assumption. A question worth re-asking becomes a cell | Readers of the report. State the decision in the cell, and say which definitions the model should adopt | | **Only answer in chat** (no file or report tools) | The assumption stated in the answer, plus the Malloy snippet a modeler could paste: the `measure:` with its `#(doc)` | Nobody, until someone acts on it. That is why the snippet matters | Tell the rungs apart by the tools you have, not by guessing at the user's role: a file-write or compile tool means the top rung; a report or notebook tool without model edits means the middle one; neither means the bottom. ### What to codify Look at the query you just ran. For each dimension, measure, join, filter, **and for the shape of the query itself**, ask **in this order**: | Codify it when | Example | |---|---| | **A reader needs it to trust the number** | the cancelled/returned exclusion - without it every revenue figure reads high | | **It encodes a business rule** | a regex parsing a messy column, a status mapping, tier cutoffs | | **Another question would reuse it** | `revenue is sum(sale_price)` - everything about orders needs it | | **It was hard to get right** | a window function, a multi-step derivation, a verified join | The first row is the one that matters. A definition is worth keeping less because it saves typing than because it stops the next reader misreading the number. **Codify thin, and leave the ad-hoc behind**: filters tied to one finding, calculations that answered exactly one question. A question that needed one measure codifies one measure, not the neighbouring columns as well. An empty CODIFY is a fine outcome; say so and move on. **Then say what you did**, in one line, every time: > Codified: `revenue is sum(sale_price)` excluding cancelled and returned, `category` via the > products join. Left ad-hoc: the `where: created_at > @2023` - just this question's window. That line shows the model growing and gives the reader a place to object. Never codify silently. ### A decision becomes binding the moment it enters the model While a judgment call lives in one ad-hoc query it is yours, and stating it is enough. Once it is in the model file it is the definition everyone inherits, and nobody downstream sees the reasoning. So on the top rung, **stop, ask, and wait for an answer** before writing it down. Reporting it afterwards ("I used the midpoint, say if you'd rather have the floor") is not confirming it; by then it is already the default. On the middle rung, confirm when the report will be shared; a private notebook is still yours. This is not only about measures. Confirm anything that changes what later questions return: | Confirm before codifying | Because it silently sets | |---|---| | A source-level `where:` | the scope of *every* later question against that source | | A `measure:` definition | the default value of that metric for everyone | | A `dimension:` that buckets, maps, or parses | which rows land in which group | | A `join_one:`/`join_many:` and its grain | whether aggregates fan out | | A saved `view:` | the shape people will re-run and cite | Ask with the numbers attached: > I want to make `net_revenue` exclude cancelled and returned. That makes it the default revenue > number for every later question - $8.10M rather than $10.81M gross. Good, or does your > reporting treat returns differently? **One confirmation per decision, not per question.** Once the user has settled how revenue treats returns, it is settled: reuse it and stop asking. And if the user has told you to stop checking in, believe them; state each decision in a clause and keep going. ### Write it down twice **In the model (or the notebook), as a `#(doc)` on the definition.** This is the part that survives. Use `#(doc)`, never a `//` comment, for anything a consumer needs: `#(doc)` is machine-readable, so it renders in the UI and retrieval tools read it, while a `//` comment reaches nobody but whoever opens the file. Keep `//` for maintainer-only notes. Tag formats on the definition too: bare `# currency` on the measure, any scale (`# currency=usd0m`) only in views. ```malloy #(doc) Order line items joined to products. Grain is one row per line, not per order. source: order_items is my_conn.table('ecommerce.order_items') extend { join_one: products is my_conn.table('ecommerce.products') on product_id = products.id dimension: #(doc) Canonical order date. Data runs 2019-01-05 to 2026-03-14, so the final year is PARTIAL - never present it as a full period. order_date is created_at::date measure: #(doc) Revenue in USD, excluding cancelled and returned orders. Those are 25% of gross, so excluding them is not optional. # currency net_revenue is sum(sale_price) { where: status != 'Cancelled' and status != 'Returned' } #(doc) Monthly revenue trend. The question asked on 2026-03-02. # line_chart view: revenue_trend is { group_by: order_month is order_date.month aggregate: # currency=usd0m net_revenue order_by: order_month } } ``` One file per analytical domain, named for it: `order_revenue.malloy`, not `model.malloy`. It grows monotonically across the session. **In a notes file, as the reasoning.** Keep an `analysis-notes.md` beside the model (or a markdown cell in the notebook) recording each question, what it found, what got codified and what was left ad-hoc, and every verification finding. The model carries the *what*; the notes carry the *why* and the evidence. It is also what lets you resume after losing context. ### Save the view when a question is worth re-asking A saved `view:` turns "we answered that once" into "re-run it". A trend wanted again next month belongs in the file as a `view:` with its chart tag (`skill:malloy-charts`); views wanted side by side belong in a dashboard (`malloy-dashboards`, where your host has it), or in a notebook (`skill:malloy-notebooks`) only when the user asks for one or one already exists. A genuine one-off does not. > **This departs from `skill:malloy-model` on purpose.** Its "no views in source files" rule > assumes a schema-first model, written before anyone asked a question, so its views would be > guesses. Here every view is a question that was asked and verified. Two more of its rules do > not apply either: skip access modifiers and curation (there is no discovery surface to curate > when every field was paid for by a question), and keep one domain file rather than one file > per table until it genuinely gets unwieldy. Everything else in `skill:malloy-model` applies: > `#(doc)` on every field, verified join cardinality, `nullif` on division, a `given:` for a > runtime parameter. ### Then loop Go back to the question, and let what you just found sharpen the next one: > Revenue is concentrated in three categories. Worth asking whether that's new - want the same > cut by year? ## When the analysis is going to be shared Docs are not on this list; they happen in CODIFY, as you go. When the user wants to hand it over: 1. **Names.** Rename anything whose name only made sense inside one question. 2. **Doc pass.** Re-read the `#(doc)` lines for anything that drifted as the model grew. Check grain, units, and null handling are each stated somewhere. 3. **Re-verify.** Re-run the session's key queries against the finished sources. If a number moved, something was codified wrong. This is the check that proves reproducibility; do not skip it. 4. **Structure**, only if one file has genuinely become unwieldy: base sources per table plus a joined source per domain, per `skill:malloy-model`. Being shared is not itself a reason. ## When to do something else | Situation | Go to | |---|---| | Porting prior art (LookML, dbt, a metrics doc): the definitions exist and are agreed, the job is translation | `skill:malloy-lookml-review`, then `skill:malloy-model` | | The user names the sources they want built outright, before any question | `skill:malloy-model` | | A model already exists, the question rests on no judgment call, and nothing is worth keeping | `skill:malloy-analysis` alone | | Open-ended exploration with no intent to keep anything | `skill:malloy-analysis` (its "no specific question" branch) | ## Anti-patterns ``` WRONG Turn "what's our default rate?" into a modelling project RIGHT Answer it, then codify what the answer assumed WRONG Propose every dimension and measure for each table in scope RIGHT Codify the two fields this question actually needed WRONG Quietly pick one of four timestamps as "the order date" RIGHT "Using created_at; shipped_at would drop 38% as nulls. OK?" WRONG Skip codifying because you cannot edit the model RIGHT Put the extend and its #(doc) in the notebook, or hand over the snippet WRONG Codify the source-level where:, then mention it in the write-up RIGHT Stop and confirm it; it scopes every question anyone asks later WRONG The assumption lives in the chat transcript, or in a // comment RIGHT #(doc) on the definition for what a consumer needs, plus a line in the notes WRONG Leave the answered question as a transcript table and move on RIGHT Save it as a view; a question worth answering is usually worth re-asking WRONG Curate access modifiers and split one file per table to "do it properly" RIGHT Skip both; they solve a schema-first problem this model does not have ```