Golden dataset reader - #237
Conversation
A golden dataset is a file of questions whose answer is already agreed — the question, the SQL the author accepts as the answer key, and how strictly a run has to match it. This adds the reader for them, at <artifacts_dir>/<profile>/golden_datasets/*.yaml. Two properties the reader is built around: * Fault isolation. One unparseable file, or one malformed case inside an otherwise good file, costs that file or that case and nothing else. Every drop comes back as a finding, so a typo is loud rather than a case that quietly stopped running. * No path escapes. Nothing returned carries a filesystem path, so a downstream runner cannot forward a dataset location into a subprocess — the records are self-sufficient by construction rather than by the runner's good manners. The filename is the dataset's identity: the reader injects it and a file that declares its own `name:` is refused, because two places to say it would disagree with nothing on disk to settle which won. `sql_confirmed` is the one field with no default, and a confirmed case with no SQL is refused: it is the one kind of case that can gate a run, so it is the one kind that must be able to fail one. Findings reuse validator.py's Finding/ValidationResult, and the models derive models.py::_Base, so `extra="forbid"` is what names a near-miss field rather than dropping it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… does not
A question asked relative to today ("last quarter", "past 30 days") over an
answer key pinned to fixed dates is a rotted case: the window the question names
slides forward and the SQL it is scored against stays where the author left it.
Today that shows up as a model failure, which blames the wrong side — the item
is broken, not the answer.
So the reader now lints for it and reports
`golden_relative_question_frozen_sql` naming the file and the case. Unlike the
refusals around it this does NOT drop the item: dropping would hide a dataset
fault behind a shrinking suite, so the case still reads and still comes back,
and the fault travels as a finding.
The rule is "relative iff anchored to now": the question matches a relative
phrase, the statement carries a date literal, and no CURRENT_DATE / NOW /
SYSDATE / GETDATE / 'now' anchor appears. The anchor set is the "what is now"
functions only — INTERVAL, DATEADD, DATE_SUB and DATE_TRUNC are arithmetic and
are relative only when their own anchor is, which already matches on its own.
DATE_TRUNC in particular would suppress the lint on exactly the frozen shape it
exists to catch.
Regex, not a parser: the question side is not SQL at all, and sql_guard.py
already sets the house rule that this kind of textual check does not bring a
second parser with it. An item with no expected.sql is skipped in silence —
nothing to inspect, and an unconfirmed case with no answer key is legal.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Documents the shape the reader accepts, so an author never has to glob a sibling profile to find out — which here would read another tenant's questions together with the SQL that answers them. The doc's example is parsed by the real reader in a test, so the reference cannot drift from the parser that has to accept it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reader promised fault isolation and path-free records; three ways in it kept neither. A non-list `test_cases` and a non-string YAML root key each raised out of `load_golden_datasets`, so one bad file cost every other file in the directory — both are now refused as `golden_invalid_dataset` with the file skipped. `OSError` was interpolated whole, carrying the absolute path (and in a hosted deployment the tenant) into a finding; only the error class and its `strerror` survive now. Pydantic's default rendering carried `input_value=` — the author's SQL, filters and recorded rows — into a finding that any caller may forward to a log; findings now name the field, the reason and the rule and nothing else. The relativity lint treated any bare 1900-2099 integer as a frozen date, so `LIMIT 2000` earned a correct file an error-severity finding. That branch is gone; the quoted-ISO-date branch already catches the shape the reference teaches. The other direction was wrong too: bare `NOW`/`TODAY`/`GETDATE` matched a comment or a CTE and *suppressed* the lint, so the function-style anchors now need their call paren. The authoring reference said `must_filter` takes predicates where the contract takes column names — every file authored from it would have failed the gate. It also promised that a typo costs one case, which holds for a case but not for a key at the top of the file, where the whole file is lost; and it never mentioned that a repeated id silently drops a case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`match: bounded` was writable and inert: the item model forbids unknown keys, so there was nowhere to author the band the level compares against, and a case that asked for it silently got nothing. `bounds` is that band — `min_rows`/`max_rows` on the row count and `min_value`/`max_value` on a single-cell answer, every edge optional so a case that only cares the answer is not zero can say so without inventing a ceiling it never checked. The two halves are refused apart. `bounded` with no band has nothing to compare against, and a band under any other level is read by nothing; both keep passing, so both are the silent hole `extra="forbid"` exists to close. A band that names no edge, a negative row count, and a floor above its own ceiling are refused for the same reason — each reads as a model fault to whoever reads the run rather than as the authoring mistake it is. No refusal names a value: the band sits on a case that also carries the answer key, and a finding travels wherever its result does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Pushed What changed: a sixth authored item field, Why now: building the result-set comparator surfaced that Doing it here costs one commit; after this merges the same change costs a new spec, which is why it landed mid-review rather than as a follow-up. 53 → 73 tests, no existing test modified. Full suite 4719 passed, 12 skipped, 0 failed. |
|
Heads-up: #239 (the result-set comparator) is stacked on this branch and targets it as its base, because it imports Merging this one first lets #239 retarget cleanly onto |
There was a problem hiding this comment.
Pull request overview
Adds the “golden dataset” read-path to semantic_model/: a YAML reader plus typed (Pydantic v2) records and validation/findings reporting, designed to (1) isolate faults to a single file/case and (2) avoid leaking filesystem paths in returned records or findings.
Changes:
- Introduces
semantic_model/golden.pywithGoldenDataset/GoldenItemmodels, YAML loading, per-file/per-case validation findings, and the relativity lint + bounded-band pairing rules. - Adds the canonical authoring reference for the YAML shape (
plugins/agami/shared/golden-dataset-shape.md) and links the new artifact type indocs/format-spec.md. - Adds a large unit test suite covering happy paths, fault isolation, and non-leakage assertions.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| packages/agami-core/src/semantic_model/golden.py | New golden dataset reader + Pydantic models, validation/findings, and relativity lint logic. |
| plugins/agami/shared/golden-dataset-shape.md | New canonical documentation for authoring golden dataset YAML files. |
| tests/test_golden_dataset_reader.py | New unit tests validating parsing, refusal behavior, fault isolation, and “no path escapes” guarantees. |
| docs/format-spec.md | Documents the new <profile>/golden_datasets/<name>.yaml artifact in the format spec table. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # four-digit integer — `LIMIT 2000`, `total_amount > 1999` and `order_id = 2024` are all far more | ||
| # common than a year written loose, and this lint is error severity, so a false positive here | ||
| # flips a correct file to not-ok and teaches readers to skim the findings that matter. | ||
| _FROZEN_DATE_RE = re.compile(r"'\d{4}-\d{2}-\d{2}(?:[ T][\d:.]+)?'") |
Spec: AH-100
Summary
A golden dataset had nowhere to live and no shape to be written in. This adds the on-disk location,
the YAML shape, the typed records, and the validation posture — the read half only. Nothing here
writes a dataset, touches a database, or runs anything; a later slice owns each of those.
A golden dataset is a file of questions whose answer is already agreed: the author writes the
question, the SQL they accept as the answer key, and how strictly a run has to match it. The runner
that consumes these decides pass/fail, so this module's whole job is to hand it records it can trust
— and to say out loud which files and cases it had to drop getting there.
Two properties are load-bearing and both are pinned by tests:
not cost the run every other case. Each fault becomes a finding and the read continues.
finding message — so a downstream runner cannot forward a dataset location into a subprocess.
Changes
packages/agami-core/src/semantic_model/golden.py(new) —GoldenDataset/GoldenItemandtheir nested models, all deriving
models.py::_Basesoextra="forbid"makes a typo'd field anamed refusal rather than a silent no-op. One entry point,
load_golden_datasets(profile, art=None) -> tuple[list[GoldenDataset], ValidationResult],reusing
validator.py'sFinding/ValidationResultrather than inventing a second reportingshape. Includes the relativity lint: an item whose question reads as relative while its answer key
is pinned to fixed dates is reported as a dataset error and left in place, because the case is
broken, not the model.
plugins/agami/shared/golden-dataset-shape.md(new) — the canonical authoring reference,following
metric-entity-shape.mdand inheriting its hard rule against reading another profile tolearn a shape. That rule binds harder here: a golden dataset is the business definitions and the
answer key in one file, so globbing a sibling profile reads another customer's questions together
with the SQL that answers them. A test parses the doc's example through the real reader, so the
reference cannot drift from the parser that has to accept it.
docs/format-spec.md— one row in the sharable table, ownerUser-authored.tests/test_golden_dataset_reader.py(new) — 73 tests.Not touched:
semantic_model/__init__.py, which is mirrored byte-for-byte intoplugins/agami/lib/and pinned by
test_plugin_lib_resolution.py::VENDORED.golden.pyis deliberately absent from thatlist — the mirror is stdlib-only and this needs PyYAML + pydantic. No
pyproject.tomlchange isneeded;
semantic_modelalready ships as a whole package.Decisions made during the build
Four were taken at the grounding gate and are recorded in the spec's
## Decisions:idandqueryare required, andsql_confirmed: truewith noexpected.sqlis refused.Read literally, "everything but
sql_confirmedis optional" yields an item with noitem_keyandnothing for the runner to generate against; and a confirmed item with no answer key is the one
kind that can gate a run and can never fail it.
recordedis typedcolumns/rows/ optionalat, not a free-form map — a receiptnothing can assert against cannot support the drift detection it exists for.
confirmed_byis a free-textmethod+ optional ISOat. Narrowing to aLiterallater isadditive; widening a closed set is not.
semantic_model/, which already owns the pydantic models, the YAMLloading and the validator.
Findings from the build and review
F22's list only reads as more because it carries
name(the filename stem) anditem_key(derived from
id), neither of which an author writes. That was a miscount and is corrected inthe spec. Then a sixth field was genuinely added:
bounds(see below), so the count is nowsix for a real reason rather than an arithmetic one.
boundswas added after the first review round, because the comparator slice found thatmatch: boundedwas writable while its band was not — the model isextra="forbid"and thedocs bound the added fields with "and nothing else", so one of the contract's five match levels
could never do anything.
GoldenBoundscarriesmin_rows/max_rows/min_value/max_value, and two pairing refusals close the inert combination in both directions:boundedwithout a band, and a band under any other level. Landed here while this PR was open; after
merge the same change would have cost a new spec.
must_filteras predicates (status = 'paid') while F22 defines it ascolumn names. Caught in review, fixed to columns. The doc is the only authority authors have
until the statement comparator lands, so every dataset authored from the draft would have failed
the gate — and no test caught it, because the reader takes
list[str]either way.LIMIT 2000ortotal_amount > 1999raised an error-severity false positive on a correct dataset. The bare-yearbranch is gone; the quoted-ISO-date branch still catches the canonical frozen shape.
test_cases, and a non-string YAML root key(
no:andon:resolve to booleans under YAML 1.1) both raisedTypeErrorpast theexcept ValidationError, losing every other file in the directory. Both now produce a namedfinding.
OSErrorcarried the absolute path, andthe full pydantic
ValidationErrorechoedinput_value=— which can hold answer-key SQL,must_filtervalues, andrecordedrow data. Messages now name the field and the reason withoutthe value. There is no caller yet, so the contract was settled while it was still free.
agami_paths.profile_dirjoinsprofilewith no containmentcheck, so
profile="../.."escapes the artifacts dir. It sits on an untouched line and is sharedby every loader in the package; fixing it here would leave the others inconsistent.
Checklist
Spec: AH-100)E2E items and REQ-006)
ruff checkclean; gitleaks cleanmust_filterand asample-store column, because the doc no longer teaches predicate form; assertions unchanged.
shipped sample store
dispositioned
🤖 Generated with Claude Code