--- name: eval-import description: 'Turn a list of questions into an eval set, whatever shape it arrived in: a JSONL a customer sent, a CSV, a spreadsheet export, a markdown doc, an email thread, or a pull from production logs. Classify each item by what came WITH the question (a query, a number, prose criteria, or nothing), write cases.jsonl and set.json per reference/case-format.md, keep the file as it arrived, and seal each question so a later edit is detectable. Never marks a value-bearing golden verified and never invents a question. Use when questions arrive from outside and need to become a set, or before the first run against a set nobody here authored.' --- # Import questions into an eval set Questions arrive in whatever shape their author had. This turns them into `evals//`, ready for a run, and records what each key is actually worth. **Scope boundary:** cases only. This never answers a question, scores an answer, or edits a model. It is `skill:eval-loop`'s scrape step, expanded, and it is where the set's honesty is decided. The file shapes are defined once, in `reference/ledger-schema.md` of `skill:eval-answer`. Read them there; this skill does not restate them. `reference/case-format.md` covers what is specific to an import: which fields an arriving item maps to, and the four physical formats. ## Where the set goes Two separate rules, and "package" blurs them: - **The same git repository as the model it evaluates**, so one commit pins the model and the ledger together. That is what a checkpoint is. - **Never inside the directory tree the answerer's package serves.** The answerer can read files in the served tree, and gold there is a contamination path. So the set is a SIBLING of the package under test, in its repo. A repo with `ecommerce/` under test gets `evals/ecommerce-questions/`, beside it. ## Step 1: keep the file as it arrived Copy it verbatim into `evals//as-received/`, and never edit that copy. Not the encoding, not the line endings, not an obvious typo. It is there so a later disagreement is about the key and not about whether somebody transcribed the question correctly. That disagreement is guaranteed: `skill:eval-loop`'s `reference/auditing-an-answer-key.md` is about what happens when it arrives, and its first check is the question against this file. If the source cannot be committed, because it carries names, addresses, internal URLs, or row values somebody pasted into a thread, say so. Record in `set.json` where the original lives and a hash of it, note under `sourceNote` that the copy is withheld and why, and tell the user that the byte-for-byte check is now impossible for anyone without the original. Do not redact silently: a redaction you got wrong becomes the provenance record. ## Step 2: classify each item by what arrived WITH the question This is the whole job, and getting it wrong is how an unverified number gets scored as a fact. | what arrived | write | scorable on day one | |---|---|---| | the question only | a case with **no `golden`** | no. It measures coverage and discoverability, which is a real measurement | | question + their query + a number | `golden.value`, their query as `canonicalQuery`, `verifiedBy: authored_query`, `status: provisional` | after step 3 agrees | | question + a number, no query | `golden.value`, `status: provisional`, no `verifiedBy` | no. Somebody typed a number | | question + prose criteria | see step 4 | depends on what the criterion is | Two rules over the whole table: **No golden holding a VALUE imports as `verified`.** `verified` means two differently shaped derivations agreed through the truth package, and an import has performed neither. `provisional` goldens are unscorable by design: `skill:eval-answer` issues `verdict: null` on them. That is not a gap to work around. It is also not a dead end, and it used to read like one. The way out is `verify_goldens.py --promote`, which marks a `provisional` golden `verified` once its value re-derives cleanly from the truth package and a second derivation exists. It is the only thing anywhere that writes `golden.status`. Tell the user that command when you hand over a set of provisionals, because until it is run the set scores nothing. A set that arrives with 60 typed numbers genuinely has 60 unverified numbers, and the number it can honestly produce on day one is a coverage score. The exception is a golden that holds no value at all, and there are two kinds. Prose criteria (`kind: criteria`, step 4) have nothing to re-derive: the author's words ARE the key. So does a case whose stated answer is that the data is not there (`kind: unanswerable`), where the pass is a refusal that names what is missing and a confident number is the failure. Both import `verified` with `verifiedBy: authored_criteria` and score immediately. The rule is about numbers, because a number is the thing that can be wrong while looking right. Watch for the second one in what arrives. A criterion reading "a refusal that names the missing data" is not a rubric clause on a normal case, it is the whole key, and importing it as `criteria` on a case with a value would make a refusal fail. **A question with no golden is a case, not a reject.** Do not hold questions back until somebody derives keys. A set of bare questions already measures whether the model can express an answer at all, and the answers it produces are what the keys get derived from. ### Deriving a key yourself, when nothing arrived with the question The table above is about what ARRIVED. Sometimes nothing did, nobody knows the right number, and the set still needs keys. You may derive one, and the rules are the ones already stated rather than new ones: - Use your full model access and query until you can defend the number. The agent that gets tested later holds only `get_context` and `execute_query`; you are not that agent, and the gap is the point. - Record the query as `canonicalQuery`. A number with no query is somebody's guess, which is the row above it in the table. - Record the entities you used to get there as `expectedEntities.required`, in the `kind:source:name` form: the measures, dimensions and views the answer cannot be produced without. `reference/case-format.md` says to leave this field empty at import because guessing it invents a retrieval expectation nobody stated; you are not guessing, you just used them. That is the only trustworthy producer this field has, and it is what makes retrieval measurable on a set nobody sent keys for. Where the model offers two legitimate routes, write a `requiredAnyOf` group rather than pick one. `verify_goldens.py` check 5 audits every id against the model, so a wrong one is a hard finding rather than a silent retrieval miss. - **Derive the list with the model in front of you, not from memory, and not with grep.** The model knows what each field IS; an author does not reliably. Ask the question of the model: which entities does an answer to this depend on, and is each a measure or a dimension. Confirm every id with `get_context` before writing it. A list written from recollection is the most common way a set acquires a retrieval expectation nobody can satisfy. Measured: an agent given the model and one question found alternatives a hand-written key had missed on all three cases tried -- carrier name against nickname against code, `destination_count` against its underlying `destination.airport_count`. At roughly $0.13 a question. It is better than a person working from notes at finding what the model actually offers. It is worse at deciding what to REQUIRE, and the two rules below are why. On the same three cases it proposed demoting two named measures on the grounds that a raw expression answered identically. One of those equivalences was false; the other was true today and is not a reason to demote anything. - **But VERIFY every "this is not strictly required" claim by running both sides.** This is the rule that makes the step above safe, and it is not optional. The same probe got one badly wrong. Told that a named measure is not required when a raw column plus a plain aggregate answers identically, it wrote that `average_plane_size` was "equally answerable by `avg(aircraft.aircraft_models.seats)`". Executed, those are 229 and 196.93. The measure is `aircraft.avg(...)`, an average over distinct aircraft, and the inline version averages over flights. The claim was false for exactly the measure whose entire purpose is to carry that distinction -- and it is the same mistake, on the same field, that produced the only wrong answer in the run this set came from. Had that proposal been written into the key, the case would have stopped testing the thing it exists to test. So: an equivalence is a claim about DATA, and reading a definition is not evidence. Run both expressions and compare the rows. **But agreeing today is not a reason to demote the model's entity, and this is the part the probe got backwards.** `airport_count is count()` returns the same 984 as a bare `count()` right now, and stops doing so the moment anyone adds a null filter or a deduplication to it. The key would not notice: it would go on passing while testing nothing. That applies to every measure that is currently a thin wrapper, which is most of them until the day one is not. **`requiredAnyOf` is for two routes the MODEL defines** -- a carrier's `name` and its `nickname`, `destination_count` and the `destination.airport_count` it wraps. Both are entities, both are retrievable, and an answer using either has gone through the semantic layer. That is a real choice and the group records it. It is NOT for "the model's entity, or a raw expression that happens to agree". Those are not two routes: one is the route, the other is a bypass that currently works. Keeping the named entity in `required` is what makes the eval measure what the semantic layer is for, and finding it IS the retrieval behaviour under test -- an agent that searches for the pre-defined entity inherits its definition and its rules, while one that rebuilds from raw columns inherits whatever it thought of. What the equivalence check is FOR is reading a run afterwards. When an answer came out right without the entity, comparing the two says whether it got lucky or got it wrong, and a set whose passes are merely lucky is a set of near-misses. - **Then prove each one is retrievable, and record what finds it.** `check_findable.py --set --mcp-url --publisher --environment --package ` runs two checks and needs no model call. With `--publisher` it reads the COMPILED model and settles whether each field exists and what KIND it is; then it searches for each entity to confirm the index can actually deliver it. This matters more than it looks, because `required` is what BOTH retrieval numbers are computed against: whether the agent asked for the entity, and whether it came back. An id retrieval cannot deliver makes both fiction, and the case reports a retrieval miss on every run, which reads as a defect in the model or the agent rather than in the key. **Prefer the compiled model over a grep, and prefer it in both directions.** Measured on one package: `dimension:flights:flight_count` names a field the model really has, so grepping the text passes it, and the compiled model says `flight_count` is a MEASURE, which no `dimension` request can return. Conversely `dimension:airports:own_type` appears zero times in the `.malloy` and the compiled model declares it, because the source exposes it implicitly from the data; the grep calls it missing and acting on that deletes a good entity. `verify_goldens.py` check 5 is the grep, it is free and needs no server, and it is not the authority. A pass is a floor, not a verdict on the docs: each entity is searched by its OWN NAME, the easiest query that could find it. An entity that answers to its identifier and not to the words a question uses still fails at run time, as `not retrieved`. Writing the list forces the question an author has to answer anyway: what would a reasonable agent search for, and of what type, to find this? If you cannot state that, the agent cannot be expected to guess it. - `status: provisional`, never `verified`, for the reason stated above: you derived it THROUGH the model under test, so a model bug would certify its own key. `verify_goldens.py --promote` is still the only way out. - **Where two readings are both defensible from the model, do not pick one.** A model that documents two conventions for the same population produces two honest numbers, and choosing quietly is how a confident wrong key gets written. Go to Hold an ambiguous golden in `skill:eval-loop`'s `reference/golden-side-door.md`: record the competing candidates rather than a new number. - What no query can settle is not a failure to derive. It is step 4's second kind, and those clauses score on day one. ## Step 3: run their query, if they gave one Do it at import, before any run. It is the cheapest finding in the whole loop. Execute their query with `execute_query` against the model under test and compare with the number they stated. Three outcomes, three different things learned: - **It returns their number.** `verifiedBy: authored_query`. Keep `status: provisional`: this proves the number came from that query, and NOT that the query is right. It ran against the model under test, so a model bug certifies its own golden, which is the exact circularity the truth package exists to break. It is still far more than a typed number is worth. - **It returns a different number.** A finding on day one, before a single answer was scored. Record BOTH numbers on the case and leave the status `provisional`. Do not quietly adopt either one: their number may be stale, the model may have moved, or the query may have always been wrong, and which of those it is decides who owns the fix. - **It does not compile.** Also a finding, and usually the most informative one: their query names entities this model version does not have. Record the error. This is what a coverage gap looks like before anyone has phrased a question about it. Never repair their query to make it run. A query that does not compile against this model is evidence about this model. ## Step 4: prose criteria are two different things Decide which, per criterion. The test: > **Can you write a query whose result settles it?** **If yes, it is a value in disguise.** "Should be about 4.2M." "The top category is Denim." "Should come back with twelve rows." There is one right answer, so the criterion is not the key, it describes one. Write it as `golden.rubric`, derive the value, and until it is derived the case is `provisional` and unscorable. Grading such a criterion as prose is how an unverified number passes: the judge reads "about 4.2M", the answer says 4.2M, and nothing ever checked whether 4.2M is right. **If no, it is a judgment about the shape of the answer**, and prose is the right home for it. "Must break the total out by region." "Must say the window excludes returns." "Must not use list price." No query settles these, the judge grades them against the answer, and these cases score on day one. Write the golden as `kind: criteria` with no `value`, the criteria as `golden.rubric`, and `verifiedBy: authored_criteria`. Use `golden.mustState` and `golden.mustNotUse` where the criterion fits those fields exactly, per `reference/case-format.md`. Most arriving criteria are mixed: one sentence naming a number and a shape. Split it. The number half goes provisional; the shape half scores. Where you cannot decide, mark the case and report it rather than guessing. A criterion nobody could classify is a question for its author, and `skill:eval-loop`'s golden side door is where it waits. ### Four rules for writing the rubric itself Each of these cost a scored case on a real set, and none of them is obvious while you are writing one. **The golden rows are the figures. The rubric's prose is a gloss on them, and where the two disagree the prose is what is stale.** On one set 26 of 29 rubrics quoted a figure that appears nowhere in their own golden rows: the goldens were re-derived the next morning, the prose was not, and an agent that computed 747 and 370 -- the exact numbers in the golden JSON -- was failed against a rubric still saying 615 and 502. Say how to derive the figure, not what it equalled. `verify_goldens.py` check 2 reports figures in the accepting clause that are absent from the rows, but it reads only figures specific enough to be a quoted result -- a bare three-digit number is invisible to it, and 615 is exactly that. So the check is a help, not a guarantee, and the rule above is yours to keep. The judge is told the same thing from the other side: where a rubric and a golden disagree about a figure, it scores against the golden. **Do not assert the model's current behaviour.** "`contract_terms` cannot be used here at all -- it returns ZERO rows" was true when written and false four hours later, when a commit unblocked that view. Nothing linked the two, and the judge then reasoned from the stale claim against an answer that was right. Co-locating the set with the model makes such drift visible in a diff; it does not detect it. A rubric that describes a bug is a rubric with an expiry date: write the requirement, not the defect. **When a question admits two honest populations, accept either and require the answer to name which.** "Products in a category" can mean listed-in or primary-category, and on one set the two readings differed by 4,070 against 2,707. Six or more cases turned on it and one rubric had been written to accept both. That one was right. The rest were repaired afterwards, having failed correct answers in the meantime. **If the rubric accepts an alternative, the entity list must too.** The two halves of a key are read by different things: the rubric is prose for the judge, `expectedEntities.required` is ids for retrieval scoring. They can disagree without anything noticing. A rubric saying "either the full carrier name or the nickname is fine" beside a required list naming only `dimension:carriers:name` scores an answer that used the nickname CORRECT and docks it recall in the same run, and that lost recall then reads as a retrieval failure. Use a `requiredAnyOf` group, which is satisfied when any member is delivered. `verify_goldens.py` reports the mismatch as a review item, but the rule is yours: it is a heuristic over prose and cannot catch every phrasing. **A trap note is not a requirement.** Notes that arrive beside the questions describe what their author thought was hard, and they mention things the question never asked for. Turning one into a rubric clause invents a requirement the answerer was never given, and it happened: a term appearing nowhere in any question became a clause an answer was marked down for missing. Convert a note only where it constrains the answer to the question as asked. ## Step 5: never change a question, and seal it The question is the stimulus. It is what a human asked, and it is not yours to tidy. Do not fix a typo, normalize casing, expand an abbreviation, or sharpen a vague phrase. Vagueness is often the thing the case tests, and narrowing a question to match what an answerer keeps doing deletes the test and hands the model a pass. `reference/auditing-an-answer-key.md` has the audit where that happened to four questions. At conversion, stamp `questionSha`: the SHA-256 of the exact question text, as written. `scripts/import_cases.py --stamp` does it, and refuses to overwrite a stamp that already exists. The seal is not a derivation from the arriving file, which is why it works for an email thread as well as a CSV. It records the decision you made at conversion about what the question is. From then on, `verify_goldens.py` compares the stamp against the question in `cases.jsonl` on every audit, and a mismatch means somebody edited a question after it was imported. A question that genuinely has to change gets a **new `qid`**, not a new stamp. A changed question is a different stimulus, and scores on the old wording must not roll into the new one. ## Step 6: freeze the split Every case gets `split`: `dev` or `holdout`. Diagnose and improve read dev only; the acceptance check runs both, and a set that is all dev cannot defend an accept. `skill:eval-loop` owns the rule; import is where it is frozen, because a split chosen after the first failures is not a holdout. Prefer variety over volume. Cases differing in grain, source, filter shape and phrasing are what move a measurement. ## Step 7: validate, and report what the set is worth ``` python3 scripts/import_cases.py --set evals/ --stamp ``` It checks the required fields, unique `qid`s, a `split` on every case, a golden status from the allowed statuses, that nothing claims `verified` without the evidence for it, and that no question has drifted from its stamp. Exit 0 clean, 1 with a finding, 2 on a usage error. Then tell the user, in these terms, what arrived: ``` 47 cases from 50 lines 12 scorable now 29 provisional (21 with their query, 5 a number alone, 3 nothing to compare yet) 6 no golden (question only) ``` Say what turns the 29 into scorable cases, in the same breath: a truth package (`init_truth_package.py`), then `verify_goldens.py --set --publisher --promote`. A reader told only the counts has no way to know the set is not simply broken, and the provisional bucket is the one that looks like progress and is not. The three provisional buckets are three different amounts of work, which is why they are counted apart. "With their query" needs a re-run. "A number alone" needs a derivation. "Nothing to compare yet" is a case whose criteria describe a key nobody has derived, and it is the one most easily mistaken for progress: measured on a real markdown thread, all three of its asks landed there. Say the scorable count out loud when you report it, not just the case count. "47 cases" reads like a 47-case measurement, and 12 is the number a first run can actually score. Read the two numbers on the first line against each other too: 47 from 50 means three lines did not parse, and the findings say which. ## Never - invent a question, or a number, or a criterion; - edit a question, including to fix a typo; - mark a golden that holds a value `verified`; - drop an item you could not classify, instead of reporting it; - put `evals/` inside the served package tree; - score anything. That is `skill:eval-answer`. ## Related skills - `skill:eval-loop`: the conductor. Import is its scrape step; its `reference/auditing-an-answer-key.md` is the procedure for a key you doubt, and its golden side door is where an unclassifiable item waits. - `skill:eval-answer`: `reference/ledger-schema.md` defines every file and field this skill writes. - Pulling questions from production logs is the other good source, and where those logs live is a host concern. Look for a host-specific log-fetching skill; this skill takes over once you have the text.