feat(core): add wren-context-loader, the MDL adapter for Warble bindings

Warble is a context-neutral behavior framework: it probes a bound semantic layer
through a narrow trait and knows no semantic format. Reading Wren MDL therefore
does not belong inside it — a framework that ships an MDL reader has a Wren
dependency in its core. The adapter moves here, so Wren depends on Warble and
never the reverse.

The crate reads a v5 wren project into Warble's Info projections and builds the
semantic lineage DAG. It exposes both ways of handing that to Warble: MdlContext
implements ContextLoader for an in-process host, and the wren-context-loader
binary renders the same projection as a prepared-context document for a host
that drives `warble` as a subprocess and so cannot pass a Rust trait object.
Serialization is Warble's own, so the two ends cannot disagree about the format.

Verified by compiling the same jaffle project down both paths — Warble reading
the project natively, and Warble reading this crate's document — and diffing:
the IRs are byte-identical.

Sources build against the in-tree wren-core-base 0.2.0 unchanged. Test fixtures
travel with the tests, minus the data files the adapter never reads. The warble
dependency is pinned to a branch until the contract it needs is in a release.
This commit is contained in:
Jax Liu
2026-09-03 17:02:37 +08:00
parent a98aebfaf2
commit 9342c51bc4
73 changed files with 7933 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Context Loader
permissions:
contents: read
on:
push:
branches:
- main
paths:
- 'core/wren-context-loader/**'
- 'core/wren-core-base/**'
- '.github/workflows/context-loader-ci.yml'
pull_request:
paths:
- 'core/wren-context-loader/**'
- 'core/wren-core-base/**'
- '.github/workflows/context-loader-ci.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
defaults:
run:
working-directory: core/wren-context-loader
jobs:
check:
name: fmt, clippy, test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
# `core/wren-core-base` is a path dependency, so a change there breaks this crate without
# touching it — which is why this workflow watches that path too.
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
core/wren-context-loader/target/
key: context-loader-${{ hashFiles('core/wren-context-loader/Cargo.toml') }}
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo clippy
run: cargo clippy --all-targets -- -D warnings
- name: cargo test
run: cargo test
+999
View File
@@ -0,0 +1,999 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "ar_archive_writer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73cd58deff2140a0a8eae87e417bd01db68a33e148aa93d1e8cd837e55e312b6"
dependencies = [
"object",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "darling"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
dependencies = [
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
dependencies = [
"darling_core",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc"
dependencies = [
"defmt",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
"windows-link",
]
[[package]]
name = "jiff-core"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09"
dependencies = [
"defmt",
]
[[package]]
name = "jiff-static"
version = "0.2.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204"
dependencies = [
"jiff-core",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "jiff-tzdb"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
[[package]]
name = "jiff-tzdb-platform"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
dependencies = [
"jiff-tzdb",
]
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "object"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "portable-atomic-util"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
dependencies = [
"portable-atomic",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "psm"
version = "0.1.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622"
dependencies = [
"ar_archive_writer",
"cc",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "recursive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e"
dependencies = [
"recursive-proc-macro-impl",
"stacker",
]
[[package]]
name = "recursive-proc-macro-impl"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "ref-cast"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_with"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [
"base64",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.1",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.1",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "sqlparser"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4b661c54b1e4b603b37873a18c59920e4c51ea8ea2cf527d925424dbd4437c"
dependencies = [
"log",
"recursive",
"sqlparser_derive",
]
[[package]]
name = "sqlparser_derive"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "stacker"
version = "0.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967"
dependencies = [
"cc",
"cfg-if",
"libc",
"psm",
"windows-sys",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinyvec"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "warble"
version = "0.9.0"
source = "git+https://github.com/Canner/Warble?branch=feat%2Fprepared-context-binding#9de76ff93ef0490ef03fe0e4c732920fb5baf0a3"
dependencies = [
"serde",
"serde_json",
"serde_yaml",
"thiserror",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wren-context-loader"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sqlparser",
"tempfile",
"thiserror",
"warble",
"wren-core-base",
]
[[package]]
name = "wren-core-base"
version = "0.2.0"
dependencies = [
"serde",
"serde_json",
"serde_with",
"sqlparser",
"wren-manifest-macro",
]
[[package]]
name = "wren-manifest-macro"
version = "0.2.0"
dependencies = [
"quote",
"syn 2.0.119",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "wren-context-loader"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
description = "Reads a Wren MDL project into Warble's ContextLoader projection, so a Warble profile can bind a wren project without Warble depending on Wren."
homepage = "https://getwren.ai"
repository = "https://github.com/Canner/WrenAI"
keywords = ["mdl", "semantic-layer", "lineage", "wren"]
categories = ["database"]
[dependencies]
# Pinned to a branch, not a release: `PreparedContext` and `prepared_document_from` are not in a
# published warble version yet. Becomes a plain version requirement once that branch merges and
# warble cuts a release — until then a branch dep is what keeps this reproducible off this machine.
warble = { git = "https://github.com/Canner/Warble", branch = "feat/prepared-context-binding" }
wren-core-base = { path = "../wren-core-base", version = "0.2.0" }
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
serde_json = "1"
sqlparser = { version = "0.58", features = ["visitor"] }
thiserror = "2"
[dev-dependencies]
tempfile = "3"
+175
View File
@@ -0,0 +1,175 @@
//! Parse **consumer artifacts** — the git-native files that consume the semantic layer — plus the
//! SQL table-reference extraction shared with view lineage.
//!
//! Two consumer sources (both read by the host into [`crate::ProjectSources`], never from the
//! filesystem here):
//! - `knowledge/sql/<slug>.md` — the wren CLI's confirmed NL→SQL memory store. Each file is a YAML
//! frontmatter block (`nl`, `sql`, `source`, `tags`); only `sql` matters for lineage.
//! - `dashboards.yml` — a minimal declarative dashboard spec: `dashboards[].name` +
//! `panels[].sql` (a raw query) or `panels[].cube` + `measures` (declared cube references).
//!
//! Table extraction parses SQL with `sqlparser` (GenericDialect) and collects every relation name;
//! a statement that fails to parse is reported to the caller, who falls back to a whole-word text
//! scan and records the degradation (no silent caps).
use std::collections::BTreeSet;
use std::ops::ControlFlow;
use serde::Deserialize;
use sqlparser::ast::{visit_relations, ObjectNamePart};
use sqlparser::dialect::GenericDialect;
use sqlparser::parser::Parser;
/// The lineage-relevant slice of a `knowledge/sql/<slug>.md` frontmatter.
#[derive(Debug, Deserialize)]
struct KnowledgeFrontmatter {
#[serde(default)]
sql: Option<String>,
}
/// Extract the confirmed query's SQL from a `knowledge/sql/<slug>.md` file. The file is YAML
/// frontmatter between `---` fences (any body after the closing fence is ignored); a fence-less
/// file is tried as bare YAML. Returns a human reason on failure so the caller can record it.
pub(crate) fn knowledge_sql(contents: &str) -> Result<String, String> {
let yaml = frontmatter_block(contents).unwrap_or(contents);
let front: KnowledgeFrontmatter =
serde_yaml::from_str(yaml).map_err(|e| format!("frontmatter is not valid yaml: {e}"))?;
front
.sql
.filter(|sql| !sql.trim().is_empty())
.ok_or_else(|| "frontmatter has no `sql:` field".to_string())
}
/// The YAML between the first `---` fence and the next line starting with `---`, if the file uses
/// frontmatter fences at all.
fn frontmatter_block(contents: &str) -> Option<&str> {
let rest = contents.strip_prefix("---")?;
let rest = rest
.strip_prefix('\n')
.or_else(|| rest.strip_prefix("\r\n"))?;
rest.lines()
.scan(0usize, |offset, line| {
let start = *offset;
*offset += line.len() + 1;
Some((start, line))
})
.find(|(_, line)| line.trim_end() == "---")
.map(|(start, _)| &rest[..start])
}
/// The root `dashboards.yml` document.
#[derive(Debug, Deserialize)]
pub(crate) struct DashboardsFile {
#[serde(default)]
pub dashboards: Vec<DashboardSpec>,
}
/// One dashboard: a named set of panels.
#[derive(Debug, Deserialize)]
pub(crate) struct DashboardSpec {
pub name: String,
#[serde(default)]
pub panels: Vec<PanelSpec>,
}
/// One panel — either a raw SQL query (`sql`, references *discovered* by parsing) or a declared
/// cube reference (`cube` + `measures`, dangling if the target doesn't exist — deliberately loud,
/// like a cube's `base_object`).
#[derive(Debug, Deserialize)]
pub(crate) struct PanelSpec {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub sql: Option<String>,
#[serde(default)]
pub cube: Option<String>,
#[serde(default)]
pub measures: Vec<String>,
}
pub(crate) fn parse_dashboards(yml: &str) -> Result<DashboardsFile, String> {
serde_yaml::from_str(yml).map_err(|e| format!("dashboards.yml is not valid yaml: {e}"))
}
/// Every relation (table-position) name referenced by `sql`, by parsing it with sqlparser's
/// GenericDialect. Compound names keep only their last part (`main.orders` → `orders`), since MDL
/// node names are unqualified. Returns `None` when the SQL does not parse — the caller decides the
/// fallback (whole-word scan) and records the degradation.
pub(crate) fn sql_table_names(sql: &str) -> Option<BTreeSet<String>> {
let statements = Parser::parse_sql(&GenericDialect {}, sql).ok()?;
let mut names = BTreeSet::new();
let _: ControlFlow<()> = visit_relations(&statements, |relation| {
if let Some(ObjectNamePart::Identifier(ident)) = relation.0.last() {
names.insert(ident.value.clone());
}
ControlFlow::Continue(())
});
Some(names)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn knowledge_sql_reads_frontmatter() {
let md = "---\nnl: monthly revenue\nsql: SELECT status, COUNT(*) FROM orders GROUP BY 1\nsource: user\ntags:\n- source:enrich\n---\n";
assert_eq!(
knowledge_sql(md).unwrap(),
"SELECT status, COUNT(*) FROM orders GROUP BY 1"
);
}
#[test]
fn knowledge_sql_ignores_body_after_closing_fence() {
let md = "---\nsql: SELECT 1\n---\n\nSome trailing notes: not yaml [at all\n";
assert_eq!(knowledge_sql(md).unwrap(), "SELECT 1");
}
#[test]
fn knowledge_sql_with_unterminated_fence_still_parses_as_bare_yaml() {
// An opening `---` with no closing fence: `frontmatter_block` finds no fence pair, so the
// whole file is tried as bare YAML — where `---` is just a document-start marker.
let md = "---\nsql: SELECT 1\n";
assert_eq!(knowledge_sql(md).unwrap(), "SELECT 1");
}
#[test]
fn knowledge_sql_without_sql_field_is_an_error() {
let md = "---\nnl: just a note\n---\n";
assert!(knowledge_sql(md).unwrap_err().contains("no `sql:` field"));
}
#[test]
fn knowledge_sql_bad_yaml_is_an_error() {
let md = "---\nsql: [unclosed\n---\n";
assert!(knowledge_sql(md).is_err());
}
#[test]
fn sql_table_names_walks_joins_and_subqueries() {
let names = sql_table_names(
"SELECT c.country, SUM(o.total) FROM orders o \
JOIN customers c ON o.customer_id = c.id \
WHERE o.id IN (SELECT order_id FROM refunds) GROUP BY 1",
)
.unwrap();
let expected: BTreeSet<String> = ["orders", "customers", "refunds"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(names, expected);
}
#[test]
fn sql_table_names_unqualifies_compound_names() {
let names = sql_table_names("SELECT * FROM main.orders").unwrap();
assert!(names.contains("orders"));
assert!(!names.contains("main.orders"));
}
#[test]
fn unparseable_sql_returns_none() {
assert!(sql_table_names("SELECT FROM WHERE ((( broken").is_none());
}
}
+322
View File
@@ -0,0 +1,322 @@
//! Project a wren [`Manifest`] into Warble's narrow Info types + infer measure additivity.
//!
//! The projection is deliberately **loose for existence, strict for semantics** (the Phase 2
//! predicate-semantics decision): a queryable quantity is either a declared cube measure *or* a
//! plain numeric column (so `has_metric` holds on cube-less projects), but additivity is only
//! expressible for a declared measure — an implicit column carries `additivity: None`, which is
//! what makes `metric_additive` unanswerable (a `can_answer=false` loud-fail) rather than silently
//! `false`.
use warble::{Additivity, ContextLoader, DimensionInfo, LineageGraph, MetricInfo, ModelInfo};
use wren_core_base::mdl::manifest::Manifest;
use crate::lineage;
use crate::project::{assemble, LoadError, ProjectSources};
/// A `ContextLoader` backed by a wren MDL manifest. Built once from an assembled manifest; all
/// accessors return borrowed, pre-projected data (pure, sans-IO).
pub struct MdlContext {
parseable: bool,
parse_error: Option<String>,
metrics: Vec<MetricInfo>,
dimensions: Vec<DimensionInfo>,
time_dimensions: Vec<DimensionInfo>,
models: Vec<ModelInfo>,
lineage: LineageGraph,
lineage_diagnostics: Vec<String>,
}
impl MdlContext {
/// Build from assembled project sources. On assembly failure returns an *unparseable* context
/// (rather than an error) so the compiler can evaluate `mdl_parseable`/`wren_project_exists`
/// to `false` and loud-fail with a precondition message, per the sans-IO probe model. The
/// underlying [`LoadError`] text is discarded here — use [`Self::try_from_sources`] (or catch
/// the error and call [`Self::unparseable_with_error`]) when the caller wants it surfaced.
pub fn from_sources(sources: &ProjectSources) -> Self {
match assemble(sources) {
Ok(loaded) => Self::from_manifest_and_consumers(&loaded.manifest, sources),
Err(_) => Self::unparseable(),
}
}
/// Build from an assembled project, surfacing the assembly error to the host if it wants it.
pub fn try_from_sources(sources: &ProjectSources) -> Result<Self, LoadError> {
assemble(sources).map(|loaded| Self::from_manifest_and_consumers(&loaded.manifest, sources))
}
/// [`Self::from_manifest`] plus the consumer extension: `knowledge/sql` confirmed queries and
/// `dashboards.yml` specs from `sources` become `query:`/`dashboard:` lineage nodes. Consumer
/// artifacts live outside the MDL manifest, which is why this needs the raw sources.
fn from_manifest_and_consumers(manifest: &Manifest, sources: &ProjectSources) -> Self {
let mut ctx = Self::from_manifest(manifest);
lineage::extend_with_consumers(
&mut ctx.lineage,
manifest,
sources,
&mut ctx.lineage_diagnostics,
);
ctx
}
/// An empty, non-parseable context (bound project missing or malformed), with no detail on
/// *why* — used when the caller has no [`LoadError`] to hand (e.g. no wren project directory
/// found at all).
pub fn unparseable() -> Self {
Self::unparseable_with_error(None)
}
/// An empty, non-parseable context carrying the real assembly failure text (from
/// [`Self::try_from_sources`]'s `Err`), so the host can surface *why* the bound project didn't
/// parse instead of only the generic `mdl_parseable` floor message.
pub fn unparseable_with_error(parse_error: Option<String>) -> Self {
MdlContext {
parseable: false,
parse_error,
metrics: Vec::new(),
dimensions: Vec::new(),
time_dimensions: Vec::new(),
models: Vec::new(),
lineage: LineageGraph::default(),
lineage_diagnostics: Vec::new(),
}
}
/// Project a parsed manifest into the Info types + lineage.
pub fn from_manifest(manifest: &Manifest) -> Self {
let mut metrics = Vec::new();
let mut dimensions = Vec::new();
let mut time_dimensions = Vec::new();
let mut models = Vec::new();
// Declared cube members: measures are metrics (additivity determinable); cube dimensions
// and time-dimensions are dimensions.
for cube in &manifest.cubes {
for measure in &cube.measures {
metrics.push(MetricInfo {
name: measure.name.clone(),
owner: cube.name.clone(),
declared: true,
additivity: Some(infer_additivity(&measure.expression)),
});
}
for dim in &cube.dimensions {
dimensions.push(DimensionInfo {
name: dim.name.clone(),
owner: cube.name.clone(),
is_temporal: false,
});
}
for tdim in &cube.time_dimensions {
let d = DimensionInfo {
name: tdim.name.clone(),
owner: cube.name.clone(),
is_temporal: true,
};
time_dimensions.push(d.clone());
dimensions.push(d);
}
}
// Implicit column-level projection: numeric columns are queryable quantities (metrics);
// categorical columns are groupable dimensions; date/timestamp columns are time dimensions.
for model in &manifest.models {
let mut has_timestamp = false;
let mut column_names = Vec::new();
for col in model.columns.iter().filter(|c| !c.is_hidden) {
column_names.push(col.name.clone());
// Relationship columns are navigation, not queryable values.
if col.relationship.is_some() {
continue;
}
if is_temporal_type(&col.r#type) {
has_timestamp = true;
let d = DimensionInfo {
name: col.name.clone(),
owner: model.name.clone(),
is_temporal: true,
};
time_dimensions.push(d.clone());
dimensions.push(d);
} else if is_numeric_type(&col.r#type) {
metrics.push(MetricInfo {
name: col.name.clone(),
owner: model.name.clone(),
declared: false,
additivity: None,
});
} else {
// Textual / boolean / other → groupable dimension.
dimensions.push(DimensionInfo {
name: col.name.clone(),
owner: model.name.clone(),
is_temporal: false,
});
}
}
models.push(ModelInfo {
name: model.name.clone(),
has_timestamp,
columns: column_names,
});
}
let (lineage, lineage_diagnostics) = lineage::build(manifest);
MdlContext {
parseable: true,
parse_error: None,
metrics,
dimensions,
time_dimensions,
models,
lineage,
lineage_diagnostics,
}
}
}
impl ContextLoader for MdlContext {
fn is_parseable(&self) -> bool {
self.parseable
}
fn parse_error(&self) -> Option<&str> {
self.parse_error.as_deref()
}
fn metrics(&self) -> &[MetricInfo] {
&self.metrics
}
fn dimensions(&self) -> &[DimensionInfo] {
&self.dimensions
}
fn time_dimensions(&self) -> &[DimensionInfo] {
&self.time_dimensions
}
fn models(&self) -> &[ModelInfo] {
&self.models
}
fn lineage(&self) -> &LineageGraph {
&self.lineage
}
fn lineage_diagnostics(&self) -> &[String] {
&self.lineage_diagnostics
}
}
/// Classify a measure's additivity from the leading aggregation of its expression.
///
/// Additive (delta attributes cleanly to per-dimension contributions that sum to the whole):
/// `SUM`, non-distinct `COUNT`. Everything else — `AVG`/`MIN`/`MAX`/`MEDIAN`/`STDDEV`/`VARIANCE`,
/// any `DISTINCT`, and any ratio (`/`) — is non-additive: decomposing it along dimensions can
/// mislead. Unrecognized aggregations are treated as non-additive (conservative: never green-light
/// a decomposition we cannot vouch for). `SemiAdditive` is not produced here — it needs
/// knowledge-layer input (a later vocabulary batch).
pub fn infer_additivity(expression: &str) -> Additivity {
let upper = expression.to_uppercase();
if upper.contains("DISTINCT") || upper.contains('/') {
return Additivity::NonAdditive;
}
match leading_function(&upper).as_deref() {
Some("SUM") | Some("COUNT") => Additivity::Additive,
_ => Additivity::NonAdditive,
}
}
/// The leading function name of an expression, e.g. `"SUM(amount)"` → `Some("SUM")`. Returns `None`
/// when the expression is not a `fn(...)` call (a bare column reference).
fn leading_function(upper_expr: &str) -> Option<String> {
let open = upper_expr.find('(')?;
let name = upper_expr[..open].trim();
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
Some(name.to_string())
}
fn normalize_type(t: &str) -> String {
// Strip any precision/args, e.g. DECIMAL(10,2) → DECIMAL; VARCHAR(255) → VARCHAR.
let base = t.split('(').next().unwrap_or(t).trim();
base.to_uppercase()
}
fn is_temporal_type(t: &str) -> bool {
let t = normalize_type(t);
t == "DATE" || t == "DATETIME" || t.starts_with("TIMESTAMP")
}
fn is_numeric_type(t: &str) -> bool {
matches!(
normalize_type(t).as_str(),
"INT"
| "INTEGER"
| "BIGINT"
| "SMALLINT"
| "TINYINT"
| "DECIMAL"
| "NUMERIC"
| "DOUBLE"
| "FLOAT"
| "REAL"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn additivity_heuristic() {
assert_eq!(infer_additivity("SUM(amount)"), Additivity::Additive);
assert_eq!(infer_additivity("sum(amount)"), Additivity::Additive);
assert_eq!(infer_additivity("COUNT(*)"), Additivity::Additive);
assert_eq!(
infer_additivity("COUNT(DISTINCT customer_id)"),
Additivity::NonAdditive
);
assert_eq!(infer_additivity("AVG(amount)"), Additivity::NonAdditive);
assert_eq!(infer_additivity("MIN(amount)"), Additivity::NonAdditive);
assert_eq!(infer_additivity("MAX(amount)"), Additivity::NonAdditive);
// ratio
assert_eq!(infer_additivity("SUM(a) / SUM(b)"), Additivity::NonAdditive);
// unrecognized → conservative non-additive
assert_eq!(infer_additivity("weird(x)"), Additivity::NonAdditive);
assert_eq!(infer_additivity("bare_column"), Additivity::NonAdditive);
}
#[test]
fn cubeless_manifest_cannot_answer_metric_additive() {
use warble::ContextLoader;
use wren_core_base::mdl::manifest::Manifest;
// A cube-less manifest: numeric columns make has_metric hold, but no declared measure ⇒
// additivity is not expressible ⇒ can_answer("metric_additive") is false.
let json = r#"{
"catalog":"wren","schema":"public",
"models":[{"name":"orders","tableReference":{"schema":"main","table":"orders"},
"columns":[{"name":"amount","type":"DOUBLE"},{"name":"status","type":"TEXT"}]}],
"relationships":[],"cubes":[],"views":[]
}"#;
let manifest: Manifest = serde_json::from_str(json).unwrap();
let ctx = MdlContext::from_manifest(&manifest);
assert!(!ctx.metrics().is_empty(), "amount is an implicit metric");
assert!(
!ctx.can_answer("metric_additive"),
"no declared measure ⇒ additivity unanswerable"
);
assert!(ctx
.metrics()
.iter()
.all(|m| !m.declared && m.additivity.is_none()));
}
#[test]
fn type_classification() {
assert!(is_temporal_type("DATE"));
assert!(is_temporal_type("timestamp"));
assert!(is_temporal_type("TIMESTAMP WITH TIME ZONE"));
assert!(!is_temporal_type("TEXT"));
assert!(is_numeric_type("INT"));
assert!(is_numeric_type("BIGINT"));
assert!(is_numeric_type("DECIMAL(10,2)"));
assert!(!is_numeric_type("TEXT"));
assert!(!is_numeric_type("DATE"));
}
}
+42
View File
@@ -0,0 +1,42 @@
//! Reads a Wren MDL project into [`warble::ContextLoader`]'s projection.
//!
//! This crate is the Wren half of a deliberately one-directional relationship. Warble is a
//! context-neutral behavior framework: it declares *what* a data agent does and probes a bound
//! semantic layer through a narrow trait, without knowing any semantic format. Teaching it to read
//! MDL would put a Wren dependency inside the framework, so the adapter lives here instead —
//! Wren depends on Warble, never the reverse.
//!
//! Pipeline: read project files (host I/O) → [`assemble`] into a `wren-core-base` `Manifest` →
//! [`MdlContext::from_manifest`] projects it to Warble's Info types and builds the semantic
//! [`warble::LineageGraph`]. Parsing is pure and WASM-friendly (no DB, no async); query execution
//! never enters this layer.
//!
//! # Two ways to hand the result to Warble
//!
//! - **In-process**: [`MdlContext`] implements [`warble::ContextLoader`], so a host that links
//! Warble as a library passes it straight to a `ContextResolver`.
//! - **Across a process boundary**: [`prepared_document`] renders the same projection as a
//! prepared-context document, which `warble compile` reads via `kind: prepared`. This is the
//! route for a host that drives the `warble` binary as a subprocess and therefore cannot hand
//! over a Rust trait object.
//!
//! Both go through the same projection, so the two cannot disagree about what the project says.
mod consumers;
mod introspect;
mod lineage;
mod project;
pub use introspect::{infer_additivity, MdlContext};
pub use lineage::{cube_id, dashboard_id, dim_id, metric_id, model_id, query_id, rel_id, view_id};
pub use project::{assemble, KnowledgeRules, LoadError, LoadedProject, ProjectSources};
#[cfg(not(target_arch = "wasm32"))]
pub use project::{read_knowledge_rules, read_project_dir};
/// Render a project's Warble projection as a prepared-context document.
///
/// The serialization itself belongs to Warble ([`warble::prepared_document_from`]), so this crate
/// never hand-rolls the wire format and cannot drift from what `warble compile` reads.
pub fn prepared_document(context: &MdlContext) -> Result<String, serde_json::Error> {
warble::prepared_document_from(context)
}
+312
View File
@@ -0,0 +1,312 @@
//! Build Warble's semantic [`LineageGraph`] from a wren [`Manifest`], plus its extension with
//! **consumer nodes** from a project's saved artifacts.
//!
//! Edges are oriented **upstream → downstream** (`from` depended on, `to` dependent), so a node's
//! blast radius is its forward-reachable set (capability-model §7.1: `raw → models → relationships
//! → metrics/dimensions → views → consumers`). The graph is built from structural references —
//! relationships, cube base objects, view statements, consumer files — without executing SQL;
//! SQL *text* is parsed (sqlparser) only to discover which relations it references, falling back
//! to a whole-word scan (recorded in `diagnostics`) when a statement does not parse.
//!
//! Node id conventions (stable, queryable): `model:<name>`, `rel:<name>`, `cube:<name>`,
//! `metric:<cube>.<measure>`, `dim:<cube>.<dimension>`, `view:<name>`, `query:<slug>`,
//! `dashboard:<name>`.
use warble::{LineageEdge, LineageGraph, LineageKind, LineageNode};
use wren_core_base::mdl::manifest::Manifest;
use crate::consumers;
use crate::project::ProjectSources;
pub fn model_id(name: &str) -> String {
format!("model:{name}")
}
pub fn rel_id(name: &str) -> String {
format!("rel:{name}")
}
pub fn cube_id(name: &str) -> String {
format!("cube:{name}")
}
pub fn metric_id(cube: &str, measure: &str) -> String {
format!("metric:{cube}.{measure}")
}
pub fn dim_id(cube: &str, dimension: &str) -> String {
format!("dim:{cube}.{dimension}")
}
pub fn view_id(name: &str) -> String {
format!("view:{name}")
}
pub fn query_id(slug: &str) -> String {
format!("query:{slug}")
}
pub fn dashboard_id(name: &str) -> String {
format!("dashboard:{name}")
}
fn push_node(nodes: &mut Vec<LineageNode>, id: String, kind: LineageKind) {
if !nodes.iter().any(|n| n.id == id) {
nodes.push(LineageNode { id, kind });
}
}
fn push_edge(edges: &mut Vec<LineageEdge>, from: String, to: String) {
if !edges.iter().any(|e| e.from == from && e.to == to) {
edges.push(LineageEdge { from, to });
}
}
/// Build the lineage DAG from the manifest's structural references. References to models that do
/// not exist (a dangling relationship member, cube base object, or view table) still produce an
/// edge whose `from` endpoint is absent from the node set — so [`LineageGraph::is_resolvable`]
/// detects them.
///
/// The second return value is the degradation diagnostics: one entry per view whose statement did
/// not parse as SQL and was matched by whole-word scan instead (no silent caps).
pub fn build(manifest: &Manifest) -> (LineageGraph, Vec<String>) {
let mut nodes: Vec<LineageNode> = Vec::new();
let mut edges: Vec<LineageEdge> = Vec::new();
let mut diagnostics: Vec<String> = Vec::new();
let model_names: Vec<String> = manifest.models.iter().map(|m| m.name.clone()).collect();
for model in &manifest.models {
push_node(&mut nodes, model_id(&model.name), LineageKind::Model);
}
// Relationships depend on both joined models.
for rel in &manifest.relationships {
let rid = rel_id(&rel.name);
push_node(&mut nodes, rid.clone(), LineageKind::Relationship);
for member in &rel.models {
push_edge(&mut edges, model_id(member), rid.clone());
}
}
// Cubes depend on their base object (a model or view); measures/dimensions depend on the cube.
for cube in &manifest.cubes {
let cid = cube_id(&cube.name);
push_node(&mut nodes, cid.clone(), LineageKind::Cube);
push_edge(&mut edges, model_id(&cube.base_object), cid.clone());
for measure in &cube.measures {
let mid = metric_id(&cube.name, &measure.name);
push_node(&mut nodes, mid.clone(), LineageKind::Metric);
push_edge(&mut edges, cid.clone(), mid);
}
let dim_names = cube
.dimensions
.iter()
.map(|d| d.name.clone())
.chain(cube.time_dimensions.iter().map(|d| d.name.clone()));
for dim_name in dim_names {
let did = dim_id(&cube.name, &dim_name);
push_node(&mut nodes, did.clone(), LineageKind::Dimension);
push_edge(&mut edges, cid.clone(), did);
}
}
// Views depend on every model their statement references. Discovered by SQL parse; a
// statement that does not parse degrades to the whole-word scan, recorded honestly.
for view in &manifest.views {
let vid = view_id(&view.name);
push_node(&mut nodes, vid.clone(), LineageKind::View);
for model_name in referenced_names(&view.statement, &model_names, &vid, &mut diagnostics) {
push_edge(&mut edges, model_id(&model_name), vid.clone());
}
}
(LineageGraph { nodes, edges }, diagnostics)
}
/// Extend an already-built graph with **consumer nodes** from the project's saved artifacts:
/// `knowledge/sql/<slug>.md` confirmed queries (→ `query:<slug>`) and `dashboards.yml` specs
/// (→ `dashboard:<name>`). This is what makes a metric no longer a leaf — the gate can now say
/// "this change reaches N dashboards".
///
/// Reference semantics mirror the rest of the graph:
/// - **Discovered** references (SQL text, of a query or a `panels[].sql`) only bind to nodes that
/// exist — an unknown relation name (a CTE, a raw table) produces nothing.
/// - **Declared** references (`panels[].cube` + `measures`) always produce an edge; naming a cube
/// or measure that does not exist leaves a dangling edge that `is_resolvable` flags, exactly
/// like a dangling cube `base_object`.
///
/// A malformed consumer file is skipped, and every skip or SQL-parse fallback appends a
/// human-readable entry to `diagnostics` (no silent caps).
pub fn extend_with_consumers(
graph: &mut LineageGraph,
manifest: &Manifest,
sources: &ProjectSources,
diagnostics: &mut Vec<String>,
) {
let model_names: Vec<String> = manifest.models.iter().map(|m| m.name.clone()).collect();
let view_names: Vec<String> = manifest.views.iter().map(|v| v.name.clone()).collect();
for (slug, contents) in &sources.knowledge_sql_mds {
let qid = query_id(slug);
let sql = match consumers::knowledge_sql(contents) {
Ok(sql) => sql,
Err(reason) => {
diagnostics.push(format!("knowledge/sql/{slug}.md skipped: {reason}"));
continue;
}
};
push_node(&mut graph.nodes, qid.clone(), LineageKind::Query);
link_sql_consumer(
graph,
manifest,
&model_names,
&view_names,
&qid,
&sql,
diagnostics,
);
}
let Some(dashboards_yml) = &sources.dashboards_yml else {
return;
};
let parsed = match consumers::parse_dashboards(dashboards_yml) {
Ok(parsed) => parsed,
Err(reason) => {
diagnostics.push(format!("dashboards.yml skipped: {reason}"));
return;
}
};
for dashboard in &parsed.dashboards {
let did = dashboard_id(&dashboard.name);
push_node(&mut graph.nodes, did.clone(), LineageKind::Dashboard);
for (index, panel) in dashboard.panels.iter().enumerate() {
let panel_label = panel
.name
.clone()
.unwrap_or_else(|| format!("panel {}", index + 1));
if panel.sql.is_none() && panel.cube.is_none() {
diagnostics.push(format!(
"dashboard '{}' {panel_label} skipped: declares neither `sql` nor `cube`",
dashboard.name
));
continue;
}
if let Some(sql) = &panel.sql {
link_sql_consumer(
graph,
manifest,
&model_names,
&view_names,
&did,
sql,
diagnostics,
);
}
if let Some(cube) = &panel.cube {
// Declared references: emitted even when the target is missing (dangling → loud).
push_edge(&mut graph.edges, cube_id(cube), did.clone());
for measure in &panel.measures {
push_edge(&mut graph.edges, metric_id(cube, measure), did.clone());
}
}
}
}
}
/// Add edges from every semantic node a consumer's SQL references to the consumer. Relations are
/// matched against models, views, and cubes; a referenced cube additionally links the measures and
/// dimensions the SQL mentions (whole-word), so `metric → consumer` edges exist for cube queries.
fn link_sql_consumer(
graph: &mut LineageGraph,
manifest: &Manifest,
model_names: &[String],
view_names: &[String],
consumer: &str,
sql: &str,
diagnostics: &mut Vec<String>,
) {
let mut candidates: Vec<String> = Vec::new();
candidates.extend(model_names.iter().cloned());
candidates.extend(view_names.iter().cloned());
candidates.extend(manifest.cubes.iter().map(|c| c.name.clone()));
for name in referenced_names(sql, &candidates, consumer, diagnostics) {
if model_names.contains(&name) {
push_edge(&mut graph.edges, model_id(&name), consumer.to_string());
} else if view_names.contains(&name) {
push_edge(&mut graph.edges, view_id(&name), consumer.to_string());
} else if let Some(cube) = manifest.cubes.iter().find(|c| c.name == name) {
push_edge(&mut graph.edges, cube_id(&name), consumer.to_string());
for measure in &cube.measures {
if statement_references(sql, &measure.name) {
push_edge(
&mut graph.edges,
metric_id(&cube.name, &measure.name),
consumer.to_string(),
);
}
}
let dim_names = cube
.dimensions
.iter()
.map(|d| d.name.as_str())
.chain(cube.time_dimensions.iter().map(|d| d.name.as_str()));
for dim_name in dim_names {
if statement_references(sql, dim_name) {
push_edge(
&mut graph.edges,
dim_id(&cube.name, dim_name),
consumer.to_string(),
);
}
}
}
}
}
/// The subset of `candidates` that `sql` references as a relation. Parses the SQL and intersects
/// its relation names with `candidates`; when the SQL does not parse, degrades to the whole-word
/// scan over every candidate and records the degradation against `node` in `diagnostics`.
fn referenced_names(
sql: &str,
candidates: &[String],
node: &str,
diagnostics: &mut Vec<String>,
) -> Vec<String> {
match consumers::sql_table_names(sql) {
Some(relations) => candidates
.iter()
.filter(|name| relations.contains(*name))
.cloned()
.collect(),
None => {
diagnostics.push(format!(
"{node}: statement did not parse as SQL; matched tables by whole-word scan"
));
candidates
.iter()
.filter(|name| statement_references(sql, name))
.cloned()
.collect()
}
}
}
/// Naive whole-word containment check: does `statement` reference `name` as a standalone token?
/// Avoids the false positive where `orders` matches inside `raw_orders` by requiring
/// non-identifier boundaries on both sides. The fallback matcher when SQL does not parse.
fn statement_references(statement: &str, name: &str) -> bool {
let bytes = statement.as_bytes();
let name_bytes = name.as_bytes();
if name_bytes.is_empty() {
return false;
}
let is_ident = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
let mut start = 0;
while let Some(pos) = statement[start..].find(name) {
let abs = start + pos;
let before_ok = abs == 0 || !is_ident(bytes[abs - 1]);
let after_idx = abs + name_bytes.len();
let after_ok = after_idx >= bytes.len() || !is_ident(bytes[after_idx]);
if before_ok && after_ok {
return true;
}
start = abs + name_bytes.len();
}
false
}
+103
View File
@@ -0,0 +1,103 @@
//! Render a wren project as a Warble prepared-context document.
//!
//! ```text
//! wren-context-loader <project-dir> [-o <out.json>]
//! ```
//!
//! Writes to stdout when `-o` is omitted. A host runs this before `warble compile` and binds the
//! output with `kind: prepared`, which is how a Warble profile binds a wren project without Warble
//! itself depending on Wren.
//!
//! **An unreadable project is still a document.** Failing to assemble the MDL is a fact *about the
//! project* that Warble's `mdl_parseable` precondition exists to report, so it is written as
//! `parseable: false` with the assembly error attached. Only a genuinely unusable invocation — a
//! path that is not a directory, an unwritable output file — exits non-zero.
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use wren_context_loader::{prepared_document, read_project_dir, MdlContext};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let (project_dir, out_path) = match parse_args(&args) {
Ok(parsed) => parsed,
Err(message) => {
eprintln!("{message}");
eprintln!("usage: wren-context-loader <project-dir> [-o <out.json>]");
return ExitCode::from(2);
}
};
let context = match load(&project_dir) {
Ok(context) => context,
Err(message) => {
eprintln!("{message}");
return ExitCode::from(1);
}
};
let document = match prepared_document(&context) {
Ok(document) => document,
Err(e) => {
eprintln!("failed to serialize the context projection: {e}");
return ExitCode::from(1);
}
};
match out_path {
Some(path) => {
if let Err(e) = std::fs::write(&path, document) {
eprintln!("failed to write {}: {e}", path.display());
return ExitCode::from(1);
}
}
None => println!("{document}"),
}
ExitCode::SUCCESS
}
fn parse_args(args: &[String]) -> Result<(PathBuf, Option<PathBuf>), String> {
let mut project_dir: Option<PathBuf> = None;
let mut out_path: Option<PathBuf> = None;
let mut rest = args.iter();
while let Some(arg) = rest.next() {
match arg.as_str() {
"-o" | "--out" => {
let value = rest.next().ok_or_else(|| format!("{arg} needs a path"))?;
out_path = Some(PathBuf::from(value));
}
other if other.starts_with('-') => return Err(format!("unknown flag '{other}'")),
other => {
if project_dir.is_some() {
return Err(format!("unexpected second project directory '{other}'"));
}
project_dir = Some(PathBuf::from(other));
}
}
}
let project_dir = project_dir.ok_or_else(|| "a project directory is required".to_string())?;
Ok((project_dir, out_path))
}
/// Read and assemble the project, turning an assembly failure into an unparseable context rather
/// than an error: that distinction is what `mdl_parseable` reports downstream.
fn load(project_dir: &Path) -> Result<MdlContext, String> {
if !project_dir.is_dir() {
return Err(format!("{} is not a directory", project_dir.display()));
}
let sources = read_project_dir(project_dir)
.map_err(|e| format!("failed to read {}: {e}", project_dir.display()))?;
let Some(sources) = sources else {
// No `wren_project.yml`: not a wren project at all. Still a document — a profile bound
// here must fail its `mdl_parseable` precondition, not fail to compile.
return Ok(MdlContext::unparseable_with_error(Some(format!(
"{} holds no wren_project.yml",
project_dir.display()
))));
};
Ok(match MdlContext::try_from_sources(&sources) {
Ok(context) => context,
Err(e) => MdlContext::unparseable_with_error(Some(e.to_string())),
})
}
+517
View File
@@ -0,0 +1,517 @@
//! Assemble an on-disk **wren project** (the split-file, `schema_version: 2` layout: a
//! `wren_project.yml` + per-model `models/<name>/metadata.yml` + `relationships.yml` + optional
//! `cubes.yml` + `views/<name>/{metadata,sql}.yml`) into a canonical [`wren_core_base::mdl::Manifest`].
//!
//! The engine-consumed MDL format is the single camelCase `Manifest`; a wren *project* is the
//! authored, split, snake_case form the `wren` CLI compiles into that manifest. This loader does
//! the structural slice of that compilation the compiler needs for introspection (models, columns,
//! relationships, cubes, views) — it does not expand `ref_sql` or resolve calculated columns
//! (execution concerns that never enter the sans-IO front-end). Parse is pure over already-read
//! bytes; the host owns the actual file reads.
use serde::Deserialize;
use std::collections::BTreeMap;
use thiserror::Error;
use wren_core_base::mdl::manifest::Manifest;
/// A wren project assembled into a canonical MDL manifest.
pub struct LoadedProject {
pub manifest: Manifest,
}
#[derive(Debug, Error)]
pub enum LoadError {
#[error("{0}")]
Message(String),
}
fn msg(s: impl Into<String>) -> LoadError {
LoadError::Message(s.into())
}
// --- on-disk (authored, snake_case) shapes ------------------------------------------------------
#[derive(Debug, Deserialize)]
struct ProjectFile {
#[serde(default)]
catalog: Option<String>,
#[serde(default)]
schema: Option<String>,
#[serde(default)]
data_source: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ModelFile {
name: String,
#[serde(default)]
table_reference: Option<serde_yaml::Value>,
#[serde(default)]
base_object: Option<String>,
#[serde(default)]
ref_sql: Option<String>,
#[serde(default)]
primary_key: Option<serde_yaml::Value>,
#[serde(default)]
columns: Vec<ColumnFile>,
}
#[derive(Debug, Deserialize)]
struct ColumnFile {
name: String,
r#type: String,
#[serde(default)]
not_null: Option<bool>,
#[serde(default)]
relationship: Option<String>,
#[serde(default)]
is_calculated: Option<bool>,
#[serde(default)]
expression: Option<String>,
#[serde(default)]
is_hidden: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct RelationshipFile {
name: String,
models: Vec<String>,
join_type: String,
condition: String,
}
/// `relationships.yml` has two authored shapes: the original bare list, and the
/// `relationships:` keyed mapping that the wren CLI (project schema_version 5) scaffolds
/// and requires. Accept both; rejecting the keyed form would silently strip every join
/// from any project authored with the current CLI.
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RelationshipsDoc {
Bare(Vec<RelationshipFile>),
Keyed {
relationships: Vec<RelationshipFile>,
},
}
impl RelationshipsDoc {
fn into_vec(self) -> Vec<RelationshipFile> {
match self {
RelationshipsDoc::Bare(v) => v,
RelationshipsDoc::Keyed { relationships } => relationships,
}
}
}
#[derive(Debug, Deserialize)]
struct CubeFile {
name: String,
base_object: String,
#[serde(default)]
measures: Vec<CubeMemberFile>,
#[serde(default)]
dimensions: Vec<CubeMemberFile>,
#[serde(default)]
time_dimensions: Vec<CubeMemberFile>,
}
#[derive(Debug, Deserialize)]
struct CubeMemberFile {
name: String,
expression: String,
r#type: String,
}
#[derive(Debug, Deserialize)]
struct ViewMetaFile {
name: String,
}
#[derive(Debug, Deserialize)]
struct ViewSqlFile {
statement: String,
}
// --- native host convenience ---------------------------------------------------------------------
/// Read a wren project directory into [`ProjectSources`] (native host only; the pure `assemble`
/// path is what a WASM host feeds directly). Returns `None` if the directory has no
/// `wren_project.yml` — i.e. it is not a wren project — so the caller can build an unparseable
/// context. Model, cube, and view sub-directories are read in sorted order for deterministic
/// output.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_project_dir(dir: &std::path::Path) -> std::io::Result<Option<ProjectSources>> {
use std::fs;
let wren_project_path = dir.join("wren_project.yml");
if !wren_project_path.is_file() {
return Ok(None);
}
let wren_project_yml = fs::read_to_string(&wren_project_path)?;
let mut model_ymls = Vec::new();
let models_dir = dir.join("models");
if models_dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(&models_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
entries.sort();
for model_dir in entries {
let meta = model_dir.join("metadata.yml");
if meta.is_file() {
model_ymls.push(fs::read_to_string(meta)?);
}
}
}
let relationships_yml = read_if_exists(&dir.join("relationships.yml"))?;
let cubes_yml = read_if_exists(&dir.join("cubes.yml"))?;
let mut cube_ymls = Vec::new();
let cubes_dir = dir.join("cubes");
if cubes_dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(&cubes_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
entries.sort();
for cube_dir in entries {
let meta = cube_dir.join("metadata.yml");
if meta.is_file() {
cube_ymls.push(fs::read_to_string(meta)?);
}
}
}
let mut views = Vec::new();
let views_dir = dir.join("views");
if views_dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(&views_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
entries.sort();
for view_dir in entries {
let meta = view_dir.join("metadata.yml");
let sql = view_dir.join("sql.yml");
if meta.is_file() && sql.is_file() {
views.push((fs::read_to_string(meta)?, fs::read_to_string(sql)?));
}
}
}
let mut knowledge_sql_mds = Vec::new();
let knowledge_sql_dir = dir.join("knowledge").join("sql");
if knowledge_sql_dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(&knowledge_sql_dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
.collect();
entries.sort();
for md in entries {
let slug = md
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
knowledge_sql_mds.push((slug, fs::read_to_string(&md)?));
}
}
let dashboards_yml = read_if_exists(&dir.join("dashboards.yml"))?;
Ok(Some(ProjectSources {
wren_project_yml,
model_ymls,
relationships_yml,
cubes_yml,
cube_ymls,
views,
knowledge_sql_mds,
dashboards_yml,
}))
}
/// Read the business-rule slice used by `wren context instructions`, without invoking `wren`.
///
/// The native host owns these filesystem reads; dispatchers receive only the normalized string.
/// Current `knowledge/rules/*.md` files are sorted by path and concatenated first, followed by the
/// legacy root `instructions.md` when present. Empty files contribute nothing.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_knowledge_rules(dir: &std::path::Path) -> std::io::Result<KnowledgeRules> {
use std::fs;
let rules_dir = dir.join("knowledge").join("rules");
let mut paths = if rules_dir.is_dir() {
fs::read_dir(&rules_dir)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "md"))
.collect::<Vec<_>>()
} else {
Vec::new()
};
paths.sort();
let mut parts = Vec::new();
for path in paths {
let contents = fs::read_to_string(path)?;
if !contents.trim().is_empty() {
parts.push(contents.trim().to_string());
}
}
let legacy_path = dir.join("instructions.md");
let used_legacy = legacy_path.is_file();
if used_legacy {
let contents = fs::read_to_string(legacy_path)?;
if !contents.trim().is_empty() {
parts.push(contents.trim().to_string());
}
}
Ok(KnowledgeRules {
content: parts.join("\n\n"),
used_legacy,
})
}
/// Normalized business rules loaded by a native host for dispatch-time injection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnowledgeRules {
pub content: String,
pub used_legacy: bool,
}
#[cfg(not(target_arch = "wasm32"))]
fn read_if_exists(path: &std::path::Path) -> std::io::Result<Option<String>> {
if path.is_file() {
Ok(Some(std::fs::read_to_string(path)?))
} else {
Ok(None)
}
}
// --- assembly -----------------------------------------------------------------------------------
/// The raw file contents of a wren project, read by the host. `assemble` operates purely over
/// these bytes (WASM-friendly); the native [`read_project_dir`] convenience fills them from disk.
pub struct ProjectSources {
pub wren_project_yml: String,
pub model_ymls: Vec<String>,
pub relationships_yml: Option<String>,
pub cubes_yml: Option<String>,
/// Per-cube `cubes/<name>/metadata.yml` contents (wren CLI layout). Used only when
/// `cubes_yml` is `None` — a root `cubes.yml` takes precedence.
pub cube_ymls: Vec<String>,
/// Each view as `(metadata.yml, sql.yml)` contents.
pub views: Vec<(String, String)>,
/// Confirmed saved queries from the wren CLI's `knowledge/sql/<slug>.md` store, as
/// `(slug, file contents)`. Consumer sources: they never enter the MDL manifest — they only
/// enrich the lineage graph with `query:<slug>` consumer nodes.
pub knowledge_sql_mds: Vec<(String, String)>,
/// The root `dashboards.yml` contents (the minimal declarative dashboard-spec convention:
/// `dashboards[].name` + `panels[].sql` or `panels[].cube`+`measures`). Consumer source, like
/// `knowledge_sql_mds`.
pub dashboards_yml: Option<String>,
}
/// Assemble read project sources into a canonical [`Manifest`]. Returns a [`LoadError`] if any file
/// fails to parse or the assembled manifest is not a valid MDL manifest.
pub fn assemble(sources: &ProjectSources) -> Result<LoadedProject, LoadError> {
let project: ProjectFile = serde_yaml::from_str(&sources.wren_project_yml)
.map_err(|e| msg(format!("wren_project.yml: {e}")))?;
let mut models = Vec::with_capacity(sources.model_ymls.len());
for yml in &sources.model_ymls {
let m: ModelFile = serde_yaml::from_str(yml).map_err(|e| msg(format!("model: {e}")))?;
models.push(model_to_json(m)?);
}
let relationships = match &sources.relationships_yml {
Some(yml) => {
// A project with no relationships (e.g. a single-table onboarding scaffolded by the
// wren CLI) ships a comment-only / empty relationships.yml, which parses to YAML null
// and matches neither RelationshipsDoc variant. Treat an empty/null document as "no
// relationships" rather than failing the whole project's parse.
let is_empty = serde_yaml::from_str::<serde_yaml::Value>(yml)
.map(|v| v.is_null())
.unwrap_or(false);
if is_empty {
Vec::new()
} else {
let doc: RelationshipsDoc = serde_yaml::from_str(yml)
.map_err(|e| msg(format!("relationships.yml: {e}")))?;
doc.into_vec()
.into_iter()
.map(relationship_to_json)
.collect()
}
}
None => Vec::new(),
};
// A root `cubes.yml` (bare list) wins when present; otherwise fall back to the wren CLI's
// per-cube layout (`cubes/<name>/metadata.yml`, one cube per file). Never both, so a project
// that keeps a root mirror alongside the directory doesn't get every cube twice.
let cubes = match &sources.cubes_yml {
Some(yml) => {
let cubes: Vec<CubeFile> =
serde_yaml::from_str(yml).map_err(|e| msg(format!("cubes.yml: {e}")))?;
cubes.into_iter().map(cube_to_json).collect()
}
None => sources
.cube_ymls
.iter()
.map(|yml| {
let cube: CubeFile =
serde_yaml::from_str(yml).map_err(|e| msg(format!("cube metadata: {e}")))?;
Ok(cube_to_json(cube))
})
.collect::<Result<Vec<_>, LoadError>>()?,
};
let mut views = Vec::with_capacity(sources.views.len());
for (meta_yml, sql_yml) in &sources.views {
let meta: ViewMetaFile =
serde_yaml::from_str(meta_yml).map_err(|e| msg(format!("view metadata: {e}")))?;
let sql: ViewSqlFile =
serde_yaml::from_str(sql_yml).map_err(|e| msg(format!("view sql: {e}")))?;
views.push(serde_json::json!({ "name": meta.name, "statement": sql.statement }));
}
let mut manifest_json = serde_json::Map::new();
if let Some(c) = project.catalog {
manifest_json.insert("catalog".into(), serde_json::Value::String(c));
}
if let Some(s) = project.schema {
manifest_json.insert("schema".into(), serde_json::Value::String(s));
}
if let Some(ds) = project.data_source {
manifest_json.insert("dataSource".into(), serde_json::Value::String(ds));
}
manifest_json.insert("models".into(), serde_json::Value::Array(models));
manifest_json.insert(
"relationships".into(),
serde_json::Value::Array(relationships),
);
manifest_json.insert("cubes".into(), serde_json::Value::Array(cubes));
manifest_json.insert("views".into(), serde_json::Value::Array(views));
// `catalog`/`schema` are required by the Manifest schema; default them so a minimal project
// (test fixtures) still assembles rather than failing on a missing namespace field.
manifest_json
.entry("catalog")
.or_insert_with(|| serde_json::Value::String("wren".into()));
manifest_json
.entry("schema")
.or_insert_with(|| serde_json::Value::String("public".into()));
let manifest: Manifest = serde_json::from_value(serde_json::Value::Object(manifest_json))
.map_err(|e| msg(format!("assembled manifest is not valid MDL: {e}")))?;
manifest
.validate_layout_version()
.map_err(|e| msg(e.to_string()))?;
Ok(LoadedProject { manifest })
}
fn yaml_to_json(v: serde_yaml::Value) -> Result<serde_json::Value, LoadError> {
serde_json::to_value(v).map_err(|e| msg(format!("value conversion: {e}")))
}
/// Pad a partial `table_reference` mapping so `wren-core-base`'s `TableReference {
/// catalog: Option<String>, schema: Option<String>, table: Option<String> }` custom deserializer
/// accepts it.
///
/// The `wren generate-mdl` CLI emits file-backed sources (duckdb/csv/local_file) with a bare
/// `table_reference: { table: /abs/path.parquet }` — no `catalog`/`schema` keys at all. Even
/// though those fields are `Option<String>`, `wren-core-base`'s custom serde module has no
/// `#[serde(default)]` on them, so a wholly-absent key is a hard "missing field `catalog`"
/// deserialize error rather than defaulting to `None`. Filling in the missing keys as explicit
/// JSON `null` (which `Option<String>` always deserializes as `None`, with no `default` attribute
/// needed) makes a bare `{table: ...}` object parse the same as a fully-specified one.
///
/// Only a YAML *mapping* is padded. The CLI's other authored shapes for this field — a dotted
/// string (`catalog.schema.table`) or a bare table-name string — go through a different (string)
/// deserialization path on the `wren-core-base` side and never hit the missing-field case, so
/// they pass through [`yaml_to_json`] unchanged.
fn normalize_table_reference(tref: serde_yaml::Value) -> Result<serde_json::Value, LoadError> {
let mut json = yaml_to_json(tref)?;
if let serde_json::Value::Object(obj) = &mut json {
for key in ["catalog", "schema", "table"] {
obj.entry(key).or_insert(serde_json::Value::Null);
}
}
Ok(json)
}
fn model_to_json(m: ModelFile) -> Result<serde_json::Value, LoadError> {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), serde_json::Value::String(m.name));
if let Some(tref) = m.table_reference {
obj.insert("tableReference".into(), normalize_table_reference(tref)?);
}
if let Some(base) = m.base_object {
obj.insert("baseObject".into(), serde_json::Value::String(base));
}
if let Some(sql) = m.ref_sql {
obj.insert("refSql".into(), serde_json::Value::String(sql));
}
if let Some(pk) = m.primary_key {
obj.insert("primaryKey".into(), yaml_to_json(pk)?);
}
let columns: Vec<serde_json::Value> = m.columns.into_iter().map(column_to_json).collect();
obj.insert("columns".into(), serde_json::Value::Array(columns));
Ok(serde_json::Value::Object(obj))
}
fn column_to_json(c: ColumnFile) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("name".into(), serde_json::Value::String(c.name));
obj.insert("type".into(), serde_json::Value::String(c.r#type));
if let Some(b) = c.not_null {
obj.insert("notNull".into(), serde_json::Value::Bool(b));
}
if let Some(rel) = c.relationship {
obj.insert("relationship".into(), serde_json::Value::String(rel));
}
if let Some(b) = c.is_calculated {
obj.insert("isCalculated".into(), serde_json::Value::Bool(b));
}
if let Some(expr) = c.expression {
obj.insert("expression".into(), serde_json::Value::String(expr));
}
if let Some(b) = c.is_hidden {
obj.insert("isHidden".into(), serde_json::Value::Bool(b));
}
serde_json::Value::Object(obj)
}
fn relationship_to_json(r: RelationshipFile) -> serde_json::Value {
serde_json::json!({
"name": r.name,
"models": r.models,
"joinType": r.join_type,
"condition": r.condition,
})
}
fn cube_member_to_json(m: CubeMemberFile) -> serde_json::Value {
serde_json::json!({ "name": m.name, "expression": m.expression, "type": m.r#type })
}
fn cube_to_json(c: CubeFile) -> serde_json::Value {
// `hierarchies` is a BTreeMap in the Manifest; an empty map keeps deterministic output.
let empty: BTreeMap<String, Vec<String>> = BTreeMap::new();
serde_json::json!({
"name": c.name,
"baseObject": c.base_object,
"measures": c.measures.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
"dimensions": c.dimensions.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
"timeDimensions": c.time_dimensions.into_iter().map(cube_member_to_json).collect::<Vec<_>>(),
"hierarchies": empty,
})
}
@@ -0,0 +1,98 @@
//! Guards the `wren generate-mdl` CLI's file-backed-source authored shape: a model's
//! `table_reference` with only a `table` key — no `catalog`/`schema` at all — e.g.
//!
//! ```yaml
//! table_reference:
//! table: /abs/path/to/file.parquet
//! ```
//!
//! `wren-core-base`'s custom `table_reference` serde module requires all three keys present on
//! the authored mapping (its inner `TableReference` struct has no `#[serde(default)]` on
//! `catalog`/`schema`, even though they're `Option<String>`), so passing this object through
//! unpadded is a hard "missing field `catalog`" deserialize error — surfaced upstream only as the
//! generic `mdl_parseable` precondition failure. `project::model_to_json` pads the missing keys
//! with JSON `null` before handoff; this test proves a project authored this way still loads.
use std::fs;
use std::path::Path;
use warble::ContextLoader;
use wren_context_loader::{read_project_dir, MdlContext};
fn write(dir: &Path, rel: &str, contents: &str) {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
/// A minimal schema_version-2 (file-backed source) project: one model whose `table_reference`
/// carries only `table`, no `catalog`/`schema` — the exact shape `wren generate-mdl` emits for
/// duckdb/csv/local_file sources.
fn bare_tableref_project(dir: &Path) {
write(
dir,
"wren_project.yml",
"schema_version: 2\nname: filedemo\nversion: '1.0'\ncatalog: wren\nschema: public\ndata_source: local_file\n",
);
write(
dir,
"models/customers/metadata.yml",
r#"name: customers
table_reference:
table: /abs/path/to/customers.parquet
primary_key: customer_id
columns:
- { name: customer_id, type: INTEGER }
- { name: email, type: VARCHAR }
"#,
);
}
#[test]
fn bare_table_key_table_reference_still_parses() {
let tmp = tempfile::tempdir().unwrap();
bare_tableref_project(tmp.path());
let sources = read_project_dir(tmp.path())
.expect("read project dir")
.expect("fixture is a wren project");
let ctx = MdlContext::try_from_sources(&sources)
.expect("a table_reference with only `table` (no catalog/schema) must still assemble");
assert!(ctx.is_parseable());
let model = ctx
.model("customers")
.expect("customers model must be present");
assert!(model.columns.contains(&"customer_id".to_string()));
assert!(model.columns.contains(&"email".to_string()));
}
/// A single-table project (e.g. a one-CSV onboarding) has no relationships, and the wren CLI
/// scaffolds a comment-only `relationships.yml` for it. That parses to YAML null, which matches
/// neither `RelationshipsDoc` variant (`Bare` sequence / `Keyed` mapping) — before the fix this
/// failed the whole project's assembly and surfaced only as the generic `mdl_parseable`
/// precondition failure. An empty/null relationships document must be treated as "no
/// relationships", not a hard parse error.
#[test]
fn empty_relationships_yml_is_treated_as_no_relationships() {
let tmp = tempfile::tempdir().unwrap();
bare_tableref_project(tmp.path());
// Comment-only relationships.yml — exactly what the CLI writes for a project with no joins.
write(
tmp.path(),
"relationships.yml",
"# Relationships between models\n",
);
let sources = read_project_dir(tmp.path())
.expect("read project dir")
.expect("fixture is a wren project");
let ctx = MdlContext::try_from_sources(&sources)
.expect("a comment-only/empty relationships.yml must assemble as zero relationships");
assert!(ctx.is_parseable());
assert!(
ctx.model("customers").is_some(),
"the model must still load when relationships.yml is empty"
);
}
@@ -0,0 +1,176 @@
//! Integration tests for the wren CLI (project schema_version 5) authored shape:
//! `relationships.yml` as a `relationships:` keyed mapping (the CLI both scaffolds and
//! *requires* this form — it silently loads 0 relationships from a bare list), join types
//! in SCREAMING_SNAKE_CASE, and per-cube `cubes/<name>/metadata.yml` files instead of a
//! root `cubes.yml`. The original bare-list + root-cubes shape is covered by the
//! `jaffle_wren` tests; this file guards that both generations of project load identically.
use std::fs;
use std::path::Path;
use warble::{Additivity, ContextLoader};
use wren_context_loader::{read_project_dir, MdlContext};
fn write(dir: &Path, rel: &str, contents: &str) {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
/// A minimal schema_version-5 project: two models, keyed relationships (MANY_TO_ONE),
/// and a cube under `cubes/<name>/metadata.yml` — no root `cubes.yml`.
fn v5_project(dir: &Path) {
write(
dir,
"wren_project.yml",
"schema_version: 5\nname: v5demo\nversion: '1.0'\ncatalog: wren\nschema: public\ndata_source: duckdb\nprofile: v5demo\n",
);
write(
dir,
"models/customers/metadata.yml",
r#"name: customers
table_reference: { catalog: demo, schema: main, table: customers }
primary_key: id
columns:
- { name: id, type: INTEGER }
- { name: email, type: VARCHAR }
"#,
);
write(
dir,
"models/orders/metadata.yml",
r#"name: orders
table_reference: { catalog: demo, schema: main, table: orders }
primary_key: id
columns:
- { name: id, type: INTEGER }
- { name: customer_id, type: INTEGER }
- { name: total, type: "DECIMAL(12,2)" }
- { name: placed_at, type: TIMESTAMP }
"#,
);
write(
dir,
"relationships.yml",
r#"relationships:
- name: orders_customers
models: [orders, customers]
join_type: MANY_TO_ONE
condition: "orders.customer_id = customers.id"
"#,
);
write(
dir,
"cubes/order_metrics/metadata.yml",
r#"name: order_metrics
base_object: orders
measures:
- { name: total_value, expression: "SUM(total)", type: DOUBLE }
- { name: avg_value, expression: "AVG(total)", type: DOUBLE }
dimensions: []
time_dimensions:
- { name: placed_at, expression: placed_at, type: TIMESTAMP }
"#,
);
}
fn load(dir: &Path) -> MdlContext {
let sources = read_project_dir(dir)
.expect("read project dir")
.expect("fixture is a wren project");
MdlContext::try_from_sources(&sources).expect("fixture assembles into a valid manifest")
}
#[test]
fn keyed_relationships_are_loaded_not_silently_dropped() {
let tmp = tempfile::tempdir().unwrap();
v5_project(tmp.path());
let ctx = load(tmp.path());
let lineage = ctx.lineage();
assert!(
lineage.contains("rel:orders_customers"),
"keyed `relationships:` form must load the relationship"
);
assert!(
lineage
.edges
.iter()
.any(|e| e.from == "model:orders" && e.to == "rel:orders_customers"),
"relationship must produce lineage edges"
);
}
#[test]
fn screaming_snake_case_join_type_is_accepted() {
let tmp = tempfile::tempdir().unwrap();
v5_project(tmp.path());
// try_from_sources already fails on an invalid manifest; loading at all proves
// MANY_TO_ONE deserialized into wren-core-base's JoinType.
let ctx = load(tmp.path());
assert!(ctx.is_parseable());
}
#[test]
fn cubes_directory_is_loaded_when_no_root_cubes_yml() {
let tmp = tempfile::tempdir().unwrap();
v5_project(tmp.path());
let ctx = load(tmp.path());
assert!(
ctx.can_answer("metric_additive"),
"the cube from cubes/<name>/metadata.yml makes additivity answerable"
);
assert_eq!(
ctx.metric_additivity("total_value"),
Some(Additivity::Additive)
);
assert_eq!(
ctx.metric_additivity("avg_value"),
Some(Additivity::NonAdditive)
);
assert!(ctx.lineage().contains("cube:order_metrics"));
}
#[test]
fn root_cubes_yml_wins_over_cubes_directory() {
let tmp = tempfile::tempdir().unwrap();
v5_project(tmp.path());
// A root mirror that declares a *different* cube name: if both sources were merged,
// the project would carry two cubes; precedence means only the root one loads.
write(
tmp.path(),
"cubes.yml",
r#"- name: root_metrics
base_object: orders
measures:
- { name: root_total, expression: "SUM(total)", type: DOUBLE }
dimensions: []
time_dimensions: []
"#,
);
let ctx = load(tmp.path());
assert!(ctx.lineage().contains("cube:root_metrics"));
assert!(
!ctx.lineage().contains("cube:order_metrics"),
"cubes/ directory must be ignored when a root cubes.yml exists (no double-loading)"
);
}
#[test]
fn bare_list_relationships_still_load() {
let tmp = tempfile::tempdir().unwrap();
v5_project(tmp.path());
write(
tmp.path(),
"relationships.yml",
r#"- name: orders_customers
models: [orders, customers]
join_type: many_to_one
condition: "orders.customer_id = customers.id"
"#,
);
let ctx = load(tmp.path());
assert!(ctx.lineage().contains("rel:orders_customers"));
}
@@ -0,0 +1,303 @@
//! Consumer-node lineage: `knowledge/sql/<slug>.md` confirmed queries and `dashboards.yml` specs
//! become `query:`/`dashboard:` nodes downstream of the semantic layer, so a metric is no longer a
//! leaf — the blast-radius gate can finally say "this change reaches N dashboards". Also covers
//! the degradation paths: unparseable consumer SQL falls back to a whole-word scan and is recorded
//! in `lineage_diagnostics` (no silent caps), and a project with no consumer files behaves exactly
//! as before consumers existed.
use std::fs;
use std::path::Path;
use warble::{ContextLoader, Severity};
use wren_context_loader::{read_project_dir, MdlContext};
fn write(dir: &Path, rel: &str, contents: &str) {
let path = dir.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, contents).unwrap();
}
/// The same minimal v5-shape project as `cli_v5_project.rs`: two models, one relationship, one
/// cube — the semantic layer consumers will point at.
fn base_project(dir: &Path) {
write(
dir,
"wren_project.yml",
"schema_version: 5\nname: consumerdemo\nversion: '1.0'\ncatalog: wren\nschema: public\ndata_source: duckdb\nprofile: consumerdemo\n",
);
write(
dir,
"models/customers/metadata.yml",
r#"name: customers
table_reference: { catalog: demo, schema: main, table: customers }
primary_key: id
columns:
- { name: id, type: INTEGER }
- { name: country, type: VARCHAR }
"#,
);
write(
dir,
"models/orders/metadata.yml",
r#"name: orders
table_reference: { catalog: demo, schema: main, table: orders }
primary_key: id
columns:
- { name: id, type: INTEGER }
- { name: customer_id, type: INTEGER }
- { name: total, type: "DECIMAL(12,2)" }
- { name: placed_at, type: TIMESTAMP }
"#,
);
write(
dir,
"relationships.yml",
r#"relationships:
- name: orders_customers
models: [orders, customers]
join_type: MANY_TO_ONE
condition: "orders.customer_id = customers.id"
"#,
);
write(
dir,
"cubes/order_metrics/metadata.yml",
r#"name: order_metrics
base_object: orders
measures:
- { name: total_value, expression: "SUM(total)", type: DOUBLE }
dimensions: []
time_dimensions:
- { name: placed_at, expression: placed_at, type: TIMESTAMP }
"#,
);
}
fn load(dir: &Path) -> MdlContext {
let sources = read_project_dir(dir)
.expect("read project dir")
.expect("fixture is a wren project");
MdlContext::try_from_sources(&sources).expect("fixture assembles into a valid manifest")
}
#[test]
fn no_consumer_files_means_no_consumer_nodes_and_no_diagnostics() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
let ctx = load(tmp.path());
assert!(ctx.lineage_diagnostics().is_empty());
assert!(
!ctx.lineage()
.nodes
.iter()
.any(|n| n.id.starts_with("query:") || n.id.starts_with("dashboard:")),
"a project without consumer artifacts must not grow consumer nodes"
);
// A metric is still a leaf here — the pre-consumer behavior, unchanged.
let radius = ctx
.lineage()
.blast_radius("metric:order_metrics.total_value");
assert!(radius.downstream.is_empty());
}
#[test]
fn confirmed_query_over_a_cube_makes_the_metric_reach_the_query() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
write(
tmp.path(),
"knowledge/sql/total-by-day.md",
"---\nnl: total order value per day\nsql: SELECT placed_at, total_value FROM order_metrics GROUP BY placed_at\nsource: user\n---\n",
);
let ctx = load(tmp.path());
assert!(ctx.lineage_diagnostics().is_empty(), "clean parse expected");
assert!(ctx.lineage().contains("query:total-by-day"));
// The query references the cube as a relation → cube edge; it mentions the measure and the
// time dimension by name → metric/dim edges.
let radius = ctx
.lineage()
.blast_radius("metric:order_metrics.total_value");
assert_eq!(
radius.downstream,
vec!["query:total-by-day".to_string()],
"the declared metric is no longer a leaf"
);
assert_eq!(
radius.severity,
Severity::Semantic,
"hitting a consumer is a silent number shift for the end user"
);
assert!(ctx
.lineage()
.blast_radius("dim:order_metrics.placed_at")
.downstream
.contains(&"query:total-by-day".to_string()));
}
#[test]
fn confirmed_query_over_models_hangs_off_each_referenced_model() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
write(
tmp.path(),
"knowledge/sql/orders-per-country.md",
"---\nsql: SELECT c.country, COUNT(*) FROM orders o JOIN customers c ON o.customer_id = c.id GROUP BY 1\n---\n",
);
let ctx = load(tmp.path());
for model in ["model:orders", "model:customers"] {
assert!(
ctx.lineage()
.blast_radius(model)
.downstream
.contains(&"query:orders-per-country".to_string()),
"{model} must reach the confirmed query"
);
}
}
#[test]
fn dashboard_declared_cube_panel_links_cube_and_measures() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
write(
tmp.path(),
"dashboards.yml",
r#"dashboards:
- name: exec-weekly
panels:
- name: totals
cube: order_metrics
measures: [total_value]
- name: by-country
sql: SELECT country, COUNT(*) FROM customers GROUP BY country
"#,
);
let ctx = load(tmp.path());
assert!(ctx.lineage_diagnostics().is_empty());
assert!(ctx.lineage().contains("dashboard:exec-weekly"));
assert!(ctx.lineage().is_resolvable());
// Declared cube panel: metric → dashboard.
let radius = ctx
.lineage()
.blast_radius("metric:order_metrics.total_value");
assert_eq!(radius.downstream, vec!["dashboard:exec-weekly".to_string()]);
assert_eq!(radius.severity, Severity::Semantic);
// SQL panel: model → dashboard. And the full chain: the base model reaches the dashboard
// through the cube AND directly counts it once (deduplicated edges).
assert!(ctx
.lineage()
.blast_radius("model:customers")
.downstream
.contains(&"dashboard:exec-weekly".to_string()));
}
#[test]
fn dashboard_naming_a_missing_cube_is_a_dangling_edge_not_a_silent_skip() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
write(
tmp.path(),
"dashboards.yml",
r#"dashboards:
- name: ghost
panels:
- { name: broken, cube: no_such_cube, measures: [nope] }
"#,
);
let ctx = load(tmp.path());
// A declared reference to a missing target dangles, exactly like a bad cube base_object —
// `lineage_resolvable` (and the components gated on it) go loud instead of quietly ignoring.
assert!(!ctx.lineage().is_resolvable());
}
#[test]
fn unparseable_consumer_sql_falls_back_to_whole_word_scan_and_is_recorded() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
// Not SQL sqlparser can parse — but it names `orders` as a whole word.
write(
tmp.path(),
"knowledge/sql/broken.md",
"---\nsql: \"SELCT * FRM ((( orders\"\n---\n",
);
let ctx = load(tmp.path());
assert!(
ctx.lineage()
.blast_radius("model:orders")
.downstream
.contains(&"query:broken".to_string()),
"fallback whole-word scan must still bind the query to `orders`"
);
let diagnostics = ctx.lineage_diagnostics();
assert!(
diagnostics
.iter()
.any(|d| d.contains("query:broken") && d.contains("whole-word")),
"the degradation must be recorded honestly; diagnostics were: {diagnostics:?}"
);
}
#[test]
fn knowledge_file_without_sql_is_skipped_with_a_diagnostic() {
let tmp = tempfile::tempdir().unwrap();
base_project(tmp.path());
write(
tmp.path(),
"knowledge/sql/note-only.md",
"---\nnl: just a note\n---\n",
);
let ctx = load(tmp.path());
assert!(!ctx.lineage().contains("query:note-only"));
let diagnostics = ctx.lineage_diagnostics();
assert!(
diagnostics.iter().any(|d| d.contains("note-only")),
"the skip must be recorded; diagnostics were: {diagnostics:?}"
);
}
/// The real `examples/driftwood-wren` fixtures, end to end: ≥2 confirmed queries + 1 dashboard
/// spec, and the acceptance chain — `blast_radius("metric:mrr_metrics.mrr")` reaches both a
/// `query:` and a `dashboard:` node at Semantic severity.
#[test]
fn driftwood_metric_reaches_its_query_and_dashboard() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/driftwood-wren");
let ctx = load(&dir);
assert!(
ctx.lineage_diagnostics().is_empty(),
"driftwood consumer fixtures parse cleanly"
);
let radius = ctx.lineage().blast_radius("metric:mrr_metrics.mrr");
assert!(
radius.downstream.contains(&"query:mrr-trend".to_string()),
"downstream was: {:?}",
radius.downstream
);
assert!(
radius
.downstream
.contains(&"dashboard:exec-weekly".to_string()),
"downstream was: {:?}",
radius.downstream
);
assert_eq!(radius.severity, Severity::Semantic);
// The SQL panel binds the dashboard to the models it queries.
assert!(ctx
.lineage()
.blast_radius("model:orders")
.downstream
.contains(&"dashboard:exec-weekly".to_string()));
// And the whole graph still resolves (no dangling consumer references in the fixtures).
assert!(ctx.lineage().is_resolvable());
}
@@ -0,0 +1,214 @@
# driftwood-wren — a deliberately messy semantic-layer project
Driftwood Outfitters is a synthetic outdoor-gear e-commerce company built as an eval
substrate: a schema where cheap and strong LLMs genuinely disagree, and where the semantic
layer (MDL + knowledge) measurably closes the gap. Where `jaffle-wren` is the clean
minimal example, driftwood is the adversarial one.
**Narrative:** launched on a self-built legacy platform in 2019, migrated platforms during
2023 (dual-write window — migrated orders exist in BOTH systems), launched a subscription
product in 2024, sells in the US and Europe (multi-currency), and runs a February-start
fiscal year. Every dirty detail is a consequence of that history, not injected noise.
## The 15 traps
Each trap maps 1:1 to a golden tag (`eval/golden/driftwood/cases.yaml`) and to a canonical
rule in `knowledge/rules/` — that pairing is the point (see "eval design" below).
`TRAPS.md` documents each with a validation SQL + the expected value.
| # | tag | trap |
| --- | --- | --- |
| T1 | cross-system-union | historical metrics span legacy + new platform |
| T2 | dedup-migration | migrated orders exist in both systems |
| T3 | unit-cents | `legacy_orders.amt_c` is integer cents |
| T4 | same-name-diff-meaning | `payments.amount` (net) vs `orders.order_total` (gross) |
| T5 | refund-double-count | refunds exist as a `refunds` row AND a negative payment |
| T6 | test-and-deleted | `is_test` / `deleted_at` inflate naive counts |
| T7 | enum-drift | multiple spellings per canonical channel/status value |
| T8 | semi-additive | MRR / inventory snapshots must not be summed across time |
| T9 | fiscal-calendar | fiscal year starts Feb 1 |
| T10 | timezone | legacy timestamps are naive America/Los_Angeles local time |
| T11 | currency | multi-currency sums need FX; `fx_rates` is weekdays-only |
| T12 | sentinel-null | legacy `ship_dt` uses a `1970-01-01` sentinel |
| T13 | identity-dedup | same humans in both systems; xref covers only ~87% |
| T14 | grain-mismatch | header total ≠ Σ line items on ~8% of orders |
| T15 | returns-vs-refunds | merchandise returns ≠ monetary refunds |
## Generating the data
The DuckDB is **not** committed (22 MB); the generator is, and it is deterministic:
```sh
cd examples/driftwood-wren
uv run generate.py # ~4 min → driftwood.duckdb (18 tables, ~693k rows, seed 42)
```
Same seed → identical query results across runs (stdlib `random.Random(42)` only — no
faker, no `datetime.now()`; "today" is pinned to 2026-06-30). File bytes may differ;
golden truths depend on query results, which do not.
### Pinned eval fixture
The expensive live eval does not regenerate those ~693k rows. A synthetic clean base is published
as the dedicated GitHub Release asset pinned by `fixture.lock.json`; `fixture.py` verifies its exact
size and SHA-256 and stores it under a content-addressed local cache:
```sh
just driftwood-fixture
# or choose an isolated cache/output explicitly
python3 fixture.py fetch --cache-dir /tmp/warble-fixtures --output /tmp/driftwood.duckdb
```
A cache hit performs no network request. A missing release asset, bad lock, wrong size, or checksum
mismatch fails loudly — it never falls back to generation. The base fixture is synthetic and
derived; `generate.py`, `BASE_FIXTURE_VERSION`, and the committed lock remain the authority.
Refreshing is deliberately explicit:
1. Change base-generation semantics and bump `BASE_FIXTURE_VERSION` (injection-only/tooling changes
do not require a refresh).
2. Generate the clean database locally with `uv run generate.py`; verify table count, total rows,
and the maximum `subscription_snapshots.snapshot_date`.
3. Publish it under a new `eval-fixtures-vN` GitHub Release/tag and asset name.
4. Update every identity/checksum/expectation field in `fixture.lock.json` in the same PR, then run
`uv run test_fixture.py` and the fixture-backed injection smoke below.
Do not replace an existing release asset in place: immutable tag + asset + checksum identity makes
old commits reproducible and prevents a cache from silently changing underneath them.
To query it through the `wren` CLI, register a duckdb profile whose `url` is this
directory (the project binds `profile: driftwood` in `wren_project.yml`), then
`wren context build`.
## Fault injection
`generate.py` can also inject a deterministic anomaly into a *second* copy of the same
seed-42 dataset, for building/testing `monitor_freshness`-style detectors:
```sh
uv run generate.py --inject stopped_updates # → driftwood-stopped_updates.duckdb + .manifest.yaml
uv run generate.py --inject sudden_drop --out /tmp/x.duckdb
uv run generate.py --verify # self-check suite; writes nothing
```
To reuse the pinned/generated clean base rather than regenerate it:
```sh
base=$(python3 fixture.py fetch)
uv run generate.py --inject stopped_updates --base "$base" --out /tmp/injected.duckdb
```
`--base` copies the database and reads/rewrites only the tables touched by the selected scenario;
the source fixture is never modified. For `stopped_updates`, that means only
`subscription_snapshots`, so preparation takes seconds instead of rebuilding the full dataset.
- `--inject <scenario>` generates the clean `driftwood.duckdb` as usual (or accepts an existing
clean database through `--base`), then applies one of the four scenarios below and writes it to
`driftwood-<scenario>.duckdb` (override with `--out`), alongside a
`driftwood-<scenario>.manifest.yaml` (or `<out>` with a `.manifest.yaml` suffix)
describing exactly what was mutated.
- `--verify` runs the self-check suite (reproducibility, manifest shape, oracle sanity)
and exits nonzero on failure.
- Scenario logic never touches the shared `random.Random(42)` sequence: mutations use
fixed literals, so `--inject` is reproducible by construction, and the clean db's rows
are byte-for-byte the same whether or not `--inject` is also passed.
Both the clean and the injected db are built from the *same* seeded run — the clean db is
the false-alarm baseline (a competent detector must **not** flag anything in it); the
injected db is the true-positive target. Scoring only the injected db can't measure false
alarms — you need the pair.
### The four scenarios
| scenario | table.column | mechanism |
| --- | --- | --- |
| `stopped_updates` | `subscription_snapshots.snapshot_date` | drop every month-end snapshot after 2026-03-31 — the feed silently stopped |
| `sudden_drop` | `orders.order_total` | multiply the header total by 0.3 for every order placed in 2026-03 — a level shift, not gradual |
| `drift` | `fx_rates.usd_rate` (EUR) | compound the rate upward ~0.15%/day over 2026-01-01..2026-06-30 — a stale/broken FX feed |
| `duplicates` | `orders` + `order_items` + `payments` | re-insert orders 1000-1004 (and their items/payments) verbatim under new ids — a retried batch job |
Only `stopped_updates` produces a genuine freshness verdict (`expected_fresh` /
`expected_severity`), computed with the exact heuristic `monitor_freshness` uses
(`hub/components/monitor_freshness/steps/assess_severity.md`): `fresh = lag_hours <=
cadence_hours`; otherwise `warn` if `lag_hours <= 2 * cadence_hours` else `critical`
(assumed cadence: 730h, a month-end snapshot table). The other three scenarios are
magnitude anomalies, not freshness ones — their `expected_severity` is a self-authored,
documented-in-`generate.py` heuristic (or `null` for `duplicates`, a content anomaly with
no natural magnitude axis).
### Manifest schema
`seed, base, injected, reference_now, dataset, scenario, injections[]`, where each
injection has `id, kind, entity, location{table,column,row_id_range,timestamp_cutoff,
date_window}, magnitude{...}, expected_verdict, expected_fresh, expected_severity,
expected_cause, attribution_keywords[]`, plus extra fields for scorer-checkability
(`affected_row_ids`, `new_row_ids`, `pre_mutation_summary`, `post_mutation_summary`). A scenario's `injections`
list usually has one entry, but can have more — `duplicates` emits three (one per touched
table), since it genuinely spans more than one entity.
### The oracle
`score_detections(manifest, detections)` scores a hand-authored *detection set* — a list
of `{entity, verdict, cause}` dicts covering both the injected entities and some clean ones
— against a manifest, producing four metrics:
- **recall** — fraction of real injections the detector flagged. Counted **per injection**,
not per scenario: `duplicates` emits three injections (`orders`, `order_items`,
`payments`) for what is conceptually one event, so a detector that flags only `orders`
scores 1/3 recall, not 1/1 — intentional, it rewards flagging every affected entity.
- **precision** — fraction of the detector's `anomaly` claims that were real. Counted **per
detection** (unaffected by how many injections one scenario emits).
- **false_alarm_rate** — the headline metric: fraction of *clean* entities the detector
wrongly flagged, also per detection. This is why the clean/injected pair matters — false
alarms aren't measurable from the injected db alone.
- **attribution_accuracy** — of the true positives, the fraction whose stated `cause`
keyword-overlaps (≥50%) the short canonical `attribution_keywords` set serialized on each
injection (the phenomenon verb + the table/column name), not
the full `expected_cause` sentence (which embeds ids/dates a paraphrasing detector won't
quote). A detector's own words only need to name the same table/column/phenomenon — it
never has to reproduce the manifest text. `--verify` checks all four scenarios: each
injection's own `expected_cause` self-matches at 1.0, a realistic hand-written paraphrase
scores ≥ 0.5, and an unrelated cause scores < 0.5.
`score_detections` assumes at most one detection per entity in `detections` (raises
`ValueError` otherwise) — with two detections for the same entity, precision and
false_alarm_rate could otherwise exceed 1.0.
`--verify` demonstrates this on hand-authored perfect (recall = precision = 1.0,
false_alarm_rate = 0.0) and imperfect (misses one injection, false-alarms on one clean
entity) detection sets for `stopped_updates`, asserting the hand-computed expected numbers.
## Eval design: MDL vs knowledge is the experiment axis
- **MDL column descriptions carry only *local* facts** — units (cents), timezones,
sentinels, net-vs-gross, snapshot grain. Regenerate with `uv run scaffold_models.py`
(descriptions are embedded there and cross-checked against `schema_dump.csv`).
- **Global business rules live only in `knowledge/rules/`** — canonical revenue formula,
cross-system dedup, test-account exclusion, UTC reporting timezone, fiscal calendar,
units-sold definition.
The `schema-only` control injects the compiled schema without `knowledge/rules/`; the
`schema+knowledge` treatment also injects those rules from this Wren project. The suite now
contains 53 goldens; the historical 43-case measurement was run with `warble eval run`
(`answer_query` via headless Claude Code):
| accuracy (cost) | schema-only | schema+knowledge |
| --- | --- | --- |
| **haiku (cheap)** | 0.23 ($1.60) | **0.60** ($2.38) |
| **sonnet (strong)** | 0.44 ($8.36) | **0.93** ($7.10) |
A cheap model with knowledge beats a strong model without it — and unlike on jaffle
(where every tier scores 100%), the tier gap here is real, so the eval loop produces a
non-trivial tier decision.
**Golden discipline learned the hard way:** golden truths must apply the project's own
knowledge rules (the v1 goldens didn't; the stronger model followed the rules and was
marked wrong). Any new golden's truth SQL must honor every canonical rule, and top-N
goldens must be checked for rank ties.
## Project shape (wren CLI v5)
This project is authored in the current wren CLI project shape — keyed `relationships:`
mapping and per-cube `cubes/<name>/metadata.yml` (with a root `cubes.yml` mirror) — so it
also serves as the in-repo integration fixture for that adapter path (`jaffle-wren`
covers the older bare-list shape).
@@ -0,0 +1,357 @@
# Driftwood trap catalogue (T1-T15)
Each trap below is: a short description of the mess and why it exists in the
Driftwood Outfitters narrative, one validation SQL query (runnable via
`duckdb driftwood.duckdb`), and the actual value(s) computed against the
generated database (seed 42). Numbers were captured by running
`_validate.sql`-equivalent queries against `driftwood.duckdb` after
`uv run generate.py`; re-running the generator reproduces them exactly
(see README.md "Determinism").
---
### T1 — `cross-system-union`
Revenue and order volume span two systems: the legacy platform (2019-01 to
2023-08) and the new platform (2023-03 onward). A query that only touches
`orders` silently drops every order placed before the migration window
started (2019-01 to 2023-02) and undercounts historical volume.
```sql
SELECT
(SELECT COUNT(*) FROM orders) AS new_platform_orders_only,
(SELECT COUNT(*) FROM orders)
+ (SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NULL) AS deduped_union,
(SELECT COUNT(*) FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE NOT c.is_test AND o.legacy_ord_id IS NULL)
+ (SELECT COUNT(*) FROM legacy_orders) AS canonical_all_time_orders;
```
**Result:** `new_platform_orders_only = 60,000` vs `deduped_union =
102,174` — the naive query misses ~42% of all-time order volume. The
**canonical** answer additionally applies the knowledge rules (test-account
exclusion on every business metric): `canonical_all_time_orders = 100,994`,
which is what golden `g03_total_orders_all_time` expects.
---
### T2 — `dedup-migration`
Legacy orders placed during the 2023-03→2023-08 migration window with
status paid/shipped/delivered were dual-written: the original row stays in
`legacy_orders` (flagged via `migrated_at`) and a duplicate is inserted into
`orders` (flagged via `legacy_ord_id`). A naive `UNION` of the two tables
double-counts every migrated order.
```sql
SELECT
(SELECT COUNT(*) FROM orders WHERE legacy_ord_id IS NOT NULL) AS migrated_dupes_in_orders,
(SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NOT NULL) AS migrated_flagged_in_legacy;
```
**Result:** `2,826` = `2,826` — the two flags agree exactly, confirming
the fix (`WHERE legacy_ord_id IS NULL` or `WHERE migrated_at IS NULL`) is
correct.
---
### T3 — `unit-cents`
`legacy_orders.amt_c` is an integer in **cents**, USD only. Summing it
directly and treating the result as dollars overstates revenue by 100x.
```sql
SELECT SUM(amt_c) AS naive_sum_treated_as_dollars, SUM(amt_c) / 100.0 AS correct_sum_usd
FROM legacy_orders;
```
**Result:** `1,437,820,899` (naive) vs `14,378,208.99` (correct) — exactly
100x apart.
---
### T4 — `same-name-diff-meaning`
`payments.amount` is the **net** amount captured (gross minus processor
fee); `orders.order_total` is the **gross** order value. They share no
column name collision but are frequently confused because both represent
"how much this order is worth." Recognized revenue must reconstruct gross
as `amount + fee_amount`.
```sql
SELECT
SUM(p.amount) AS naive_net_sum,
SUM(p.amount + p.fee_amount) AS gross_reconstructed_from_payments,
(SELECT SUM(o.order_total) FROM orders o WHERE o.id IN (SELECT order_id FROM payments WHERE amount > 0))
AS order_total_sum_for_paid_orders
FROM payments p WHERE p.method != 'refund_reversal';
```
**Result:** `naive_net_sum = 15,709,036.14` vs
`gross_reconstructed_from_payments = 16,193,909.41`, which matches
`order_total_sum_for_paid_orders = 16,193,909.41` exactly — using the net
figure alone understates recognized revenue by ~$484,873 (fees).
---
### T5 — `refund-double-count`
Every refund produces **two** artifacts: a positive-gross row in `refunds`
*and* a negative-amount row in `payments` (`method = 'refund_reversal'`).
Subtracting both from revenue double-counts the refund.
```sql
SELECT
(SELECT COUNT(*) FROM payments WHERE amount < 0) AS negative_payment_rows,
(SELECT COUNT(*) FROM refunds) AS refund_rows,
(SELECT SUM(-amount) FROM payments WHERE amount < 0) AS negative_payment_total,
(SELECT SUM(amount) FROM refunds) AS refund_total;
```
**Result:** `negative_payment_rows = 3,845` = `refund_rows = 3,845`;
`negative_payment_total = 892,028.20` = `refund_total = 892,028.20` — the
two representations are exact duplicates of each other, by construction.
---
### T6 — `test-and-deleted`
`customers.is_test` (internal QA accounts) and `deleted_at` (soft deletes)
both inflate a naive `COUNT(*)` on `customers`.
```sql
SELECT
(SELECT COUNT(*) FROM customers) AS all_customers,
(SELECT COUNT(*) FROM customers WHERE is_test = false AND deleted_at IS NULL) AS clean_customers,
(SELECT COUNT(*) FROM customers WHERE is_test = true) AS test_customers,
(SELECT COUNT(*) FROM customers WHERE deleted_at IS NOT NULL) AS deleted_customers;
```
**Result:** `all_customers = 8,000` vs `clean_customers = 7,508`
(`test_customers = 164`, `deleted_customers = 332`) — the naive count is
inflated by ~6.6%.
---
### T7 — `enum-drift`
`customers.marketing_channel` has ten raw values that collapse into four
canonical buckets (Facebook: `fb`/`facebook`/`Facebook Ads`; Google:
`google`/`adwords`/`Google`; Email: `email`/`newsletter`; Organic; NULL). A
naive `GROUP BY` fragments what should be one bucket into three or more
rows. Independently, `orders.status` has `'paid'` spelled three ways.
```sql
SELECT marketing_channel, COUNT(*) AS n FROM customers GROUP BY marketing_channel ORDER BY n DESC;
SELECT status, COUNT(*) AS n FROM orders WHERE lower(status) = 'paid' GROUP BY status ORDER BY n DESC;
```
**Result (channel):** `NULL=1,574`, `organic=1,572`, `google=958`,
`fb=834`, `email=784`, `facebook=616`, `adwords=514`,
`Facebook Ads=426`, `newsletter=386`, `Google=336` — canonical Facebook
total = 834+616+426 = **1,876**; canonical Google total =
958+514+336 = **1,808**; canonical Email total = 784+386 = **1,170**, none
of which is visible without the canonical mapping.
**Result (status):** `paid=18,650`, `PAID=275`, `Paid=259` — the case
variants are 534/19,184 ≈ **2.8%** of all paid-family rows (target ~3%).
---
### T8 — `semi-additive`
`subscription_snapshots.mrr_amount` and `inventory_levels.units_on_hand`
are month-end **snapshots**. Both are semi-additive: they must never be
summed across time (only across the entity dimension, at a fixed month).
```sql
SELECT
(SELECT SUM(mrr_amount) FROM subscription_snapshots) AS naive_sum_all_months_wrong,
(SELECT SUM(mrr_amount) FROM subscription_snapshots
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM subscription_snapshots)) AS correct_latest_month_mrr;
SELECT
(SELECT SUM(units_on_hand) FROM inventory_levels) AS naive_sum_all_snapshots_wrong,
(SELECT SUM(units_on_hand) FROM inventory_levels
WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM inventory_levels)) AS correct_latest_snapshot_total;
```
**Result (MRR):** naive sum across 30 month-end snapshots =
`560,005.51` vs correct current MRR = `28,687.92` — off by ~19.5x.
**Result (inventory):** naive sum across all monthly snapshots =
`5,981,264` vs correct current on-hand = `150,807` — off by ~40x.
---
### T9 — `fiscal-calendar`
Driftwood's fiscal year starts **February 1**. `fiscal_calendar` maps every
calendar date to `(fiscal_year, fiscal_quarter)`; a query using calendar
quarters for "Q3 revenue" answers the wrong question.
```sql
SELECT date, fiscal_year, fiscal_quarter FROM fiscal_calendar
WHERE date IN ('2024-01-31','2024-02-01','2025-01-31','2025-02-01') ORDER BY date;
```
**Result:**
| date | fiscal_year | fiscal_quarter |
| --- | --- | --- |
| 2024-01-31 | 2023 | FY2023-Q4 |
| 2024-02-01 | 2024 | FY2024-Q1 |
| 2025-01-31 | 2024 | FY2024-Q4 |
| 2025-02-01 | 2025 | FY2025-Q1 |
A single calendar-day boundary (Jan 31 → Feb 1) flips both the fiscal year
and the quarter.
---
### T10 — `timezone`
`legacy_orders.ord_dt` is a **naive** local-time string in
`America/Los_Angeles`, generated from a true UTC instant. Treating the
string as if it were already UTC shifts the calendar day for any order
placed near midnight LA time.
```sql
SELECT
COUNT(*) AS total_legacy_orders,
COUNT(*) FILTER (
-- timezone('UTC', timezone('America/Los_Angeles', ts)) = interpret the naive string
-- as LA local, then express it as naive UTC — independent of the session timezone
-- (date_trunc on a TIMESTAMPTZ would silently truncate in the session zone instead).
WHERE strftime(timezone('UTC', timezone('America/Los_Angeles', ord_dt::TIMESTAMP)), '%Y-%m-%d')
!= substr(ord_dt, 1, 10)
) AS rows_where_naive_utc_reading_shifts_the_calendar_day
FROM legacy_orders;
```
**Result:** `13,736` of `45,000` rows (~30.5%) land on a different UTC
calendar day than their naive local date — consistent with a 7-8 hour
offset (≈ 7.5/24 of uniformly-drawn timestamps sit within the offset of a
day boundary). Any day/month/year bucketing that misreads the naive string
as UTC misplaces those rows.
---
### T11 — `currency`
`orders.currency` is USD/EUR/GBP. Summing `order_total` across currencies
without conversion produces a meaningless number. `fx_rates` only has
**weekday** rows (no Sat/Sun), so a same-day join needs a last-value
carry-forward for weekend-captured payments.
```sql
SELECT currency, COUNT(*) AS n, SUM(order_total) AS naive_meaningless_sum FROM orders GROUP BY currency ORDER BY currency;
SELECT
(SELECT COUNT(*) FROM fx_rates WHERE currency='EUR') AS eur_days_with_rate,
(SELECT COUNT(*) FROM generate_series(DATE '2023-01-01', DATE '2026-06-30', INTERVAL 1 DAY)) AS total_calendar_days_in_range;
```
**Result:** `EUR: n=14,417, sum=4,576,032.18` / `GBP: n=5,680,
sum=1,751,345.71` / `USD: n=39,903, sum=12,774,201.62` — adding these three
sums together is not a valid USD total. `fx_rates` covers `912` of `1,277`
calendar days (~71.4%, i.e. exactly the weekday fraction) — any date-join
must fall back to the most recent prior rate on weekends.
---
### T12 — `sentinel-null`
`legacy_orders.ship_dt` uses the epoch-zero sentinel
`'1970-01-01 00:00:00'` to mean "never shipped," instead of `NULL`. Treated
as a real date, it silently becomes the oldest "ship date" in the dataset.
```sql
SELECT
COUNT(*) FILTER (WHERE ship_dt = '1970-01-01 00:00:00') AS sentinel_unshipped_rows,
COUNT(*) FILTER (WHERE ship_dt != '1970-01-01 00:00:00') AS real_ship_dt_rows,
COUNT(*) AS total
FROM legacy_orders;
```
**Result:** `sentinel_unshipped_rows = 19,333` / `real_ship_dt_rows =
25,667` out of `45,000` — 43% of legacy orders would appear to have
shipped on 1970-01-01 unless the sentinel is filtered out.
---
### T13 — `identity-dedup`
~3,000 humans exist in both `customers` and `legacy_customers` under
casing/whitespace email variants (e.g. `Jane.Doe@gmail.com` vs
`jane.doe@gmail.com `). `customer_xref` links only ~87% of them explicitly;
the rest are only resolvable by normalizing and joining on email.
```sql
SELECT
(SELECT COUNT(*) FROM legacy_customers lc JOIN customers c
ON lower(trim(lc.email)) = lower(trim(c.email))) AS true_overlap_by_email,
(SELECT COUNT(*) FROM customer_xref) AS xref_rows,
(SELECT COUNT(*) FROM legacy_customers l
WHERE EXISTS (SELECT 1 FROM customers c
WHERE lower(trim(c.email)) = lower(trim(l.email)))
AND NOT EXISTS (SELECT 1 FROM customer_xref x
WHERE x.cust_ref = l.cust_ref)) AS overlap_missed_by_xref;
```
**Result:** `true_overlap_by_email = 2,936` vs `xref_rows = 2,550`, and
`overlap_missed_by_xref = 442` (golden `g38_xref_gap`). Note the gap is
**not** `2,936 − 2,550 = 386`: the two sets only partially overlap — 56 of
the 2,550 xref rows map legacy customers whose emails do *not* normalize to
a new-platform match, so a set difference, not a count difference, is
required. A naive customer count that trusts `customer_xref` alone misses
442 real overlaps (~15%) that only surface via `lower(trim(email))`
matching.
---
### T14 — `grain-mismatch`
`orders.order_total` (header grain) and `SUM(order_items)` (line grain)
mostly agree exactly, but ~8% of orders carry a small rounding delta
(±$0.01-$0.99) between the two representations.
```sql
SELECT
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) <= 0.005) AS exact_match,
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) AS mismatched,
ROUND(100.0 * COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) / COUNT(*), 2) AS pct_mismatched
FROM orders o
JOIN (SELECT order_id, SUM(quantity*unit_price - discount_amount) AS items_sum FROM order_items GROUP BY order_id) i
ON i.order_id = o.id;
```
**Result:** `total_orders = 60,000`, `exact_match = 55,172`,
`mismatched = 4,828` → **8.05%** mismatched (target ~8%) — a reconciliation
report keyed on `order_total` alone will disagree with one built from
`order_items` for roughly 1 in 12 orders.
---
### T15 — `returns-vs-refunds`
`returns` (RMA / physical product returns) and `refunds` (monetary refunds)
are independent concepts with only partial overlap — a returned item
doesn't always trigger a refund, and a refund doesn't require a physical
return. Using one table to answer a question about the other undercounts
or overcounts depending on direction.
```sql
SELECT
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id) AS orders_with_returns,
(SELECT COUNT(DISTINCT order_id) FROM refunds) AS orders_with_refunds,
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id
WHERE oi.order_id IN (SELECT order_id FROM refunds)) AS orders_with_both;
```
**Result:** `orders_with_returns = 1,977`, `orders_with_refunds = 3,845`,
`orders_with_both = 142` — only ~4% of returns orders also show up in
refunds (and vice versa), confirming the two are mostly disjoint
populations that must not be conflated.
@@ -0,0 +1,111 @@
.mode line
.print '=== T1 cross-system-union ==='
SELECT
(SELECT COUNT(*) FROM orders) AS new_platform_orders_only,
(SELECT COUNT(*) FROM orders) + (SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NULL) AS correct_historical_order_count;
.print '=== T2 dedup-migration ==='
SELECT
(SELECT COUNT(*) FROM orders WHERE legacy_ord_id IS NOT NULL) AS migrated_dupes_in_orders,
(SELECT COUNT(*) FROM legacy_orders WHERE migrated_at IS NOT NULL) AS migrated_flagged_in_legacy;
.print '=== T3 unit-cents ==='
SELECT
SUM(amt_c) AS naive_sum_treated_as_dollars,
SUM(amt_c) / 100.0 AS correct_sum_usd
FROM legacy_orders;
.print '=== T4 same-name-diff-meaning ==='
SELECT
SUM(p.amount) AS naive_net_sum,
SUM(p.amount + p.fee_amount) AS gross_reconstructed_from_payments,
(SELECT SUM(o.order_total) FROM orders o WHERE o.id IN (SELECT order_id FROM payments WHERE amount > 0)) AS order_total_sum_for_paid_orders
FROM payments p WHERE p.method != 'refund_reversal';
.print '=== T5 refund-double-count ==='
SELECT
(SELECT COUNT(*) FROM payments WHERE amount < 0) AS negative_payment_rows,
(SELECT COUNT(*) FROM refunds) AS refund_rows,
(SELECT SUM(-amount) FROM payments WHERE amount < 0) AS negative_payment_total,
(SELECT SUM(amount) FROM refunds) AS refund_total;
.print '=== T6 test-and-deleted ==='
SELECT
(SELECT COUNT(*) FROM customers) AS all_customers,
(SELECT COUNT(*) FROM customers WHERE is_test = false AND deleted_at IS NULL) AS clean_customers,
(SELECT COUNT(*) FROM customers WHERE is_test = true) AS test_customers,
(SELECT COUNT(*) FROM customers WHERE deleted_at IS NOT NULL) AS deleted_customers;
.print '=== T7 enum-drift (marketing_channel) ==='
SELECT marketing_channel, COUNT(*) AS n FROM customers GROUP BY marketing_channel ORDER BY n DESC;
.print '=== T7 enum-drift (order status paid family) ==='
SELECT status, COUNT(*) AS n FROM orders WHERE lower(status) = 'paid' GROUP BY status ORDER BY n DESC;
.print '=== T8 semi-additive (subscriptions MRR) ==='
SELECT
(SELECT SUM(mrr_amount) FROM subscription_snapshots) AS naive_sum_all_months_wrong,
(SELECT SUM(mrr_amount) FROM subscription_snapshots WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM subscription_snapshots)) AS correct_latest_month_mrr,
(SELECT COUNT(DISTINCT snapshot_date) FROM subscription_snapshots) AS n_months;
.print '=== T8 semi-additive (inventory) ==='
SELECT
(SELECT SUM(units_on_hand) FROM inventory_levels) AS naive_sum_all_snapshots_wrong,
(SELECT SUM(units_on_hand) FROM inventory_levels WHERE snapshot_date = (SELECT MAX(snapshot_date) FROM inventory_levels)) AS correct_latest_snapshot_total;
.print '=== T9 fiscal-calendar ==='
SELECT date, fiscal_year, fiscal_quarter FROM fiscal_calendar WHERE date IN ('2024-01-31','2024-02-01','2025-01-31','2025-02-01') ORDER BY date;
.print '=== T10 timezone ==='
SELECT
COUNT(*) AS total_legacy_orders,
COUNT(*) FILTER (
-- naive LA local -> naive UTC, independent of the session timezone
WHERE strftime(timezone('UTC', timezone('America/Los_Angeles', ord_dt::TIMESTAMP)), '%Y-%m-%d')
!= substr(ord_dt, 1, 10)
) AS rows_where_naive_utc_reading_shifts_the_calendar_day
FROM legacy_orders;
.print '=== T11 currency ==='
SELECT currency, COUNT(*) AS n, SUM(order_total) AS naive_meaningless_sum FROM orders GROUP BY currency ORDER BY currency;
.print '=== T11 currency (fx coverage / weekend gap) ==='
SELECT
(SELECT COUNT(*) FROM fx_rates WHERE currency='EUR') AS eur_days_with_rate,
(SELECT COUNT(*) FROM generate_series(DATE '2023-01-01', DATE '2026-06-30', INTERVAL 1 DAY)) AS total_calendar_days_in_range;
.print '=== T12 sentinel-null ==='
SELECT
COUNT(*) FILTER (WHERE ship_dt = '1970-01-01 00:00:00') AS sentinel_unshipped_rows,
COUNT(*) FILTER (WHERE ship_dt != '1970-01-01 00:00:00') AS real_ship_dt_rows,
COUNT(*) AS total
FROM legacy_orders;
.print '=== T13 identity-dedup ==='
SELECT
(SELECT COUNT(*) FROM legacy_customers lc JOIN customers c
ON lower(trim(lc.email)) = lower(trim(c.email))) AS true_overlap_by_email,
(SELECT COUNT(*) FROM customer_xref) AS xref_rows,
(SELECT COUNT(DISTINCT c.id)
FROM customers c
LEFT JOIN customer_xref x ON x.customer_id = c.id
WHERE x.customer_id IS NULL
AND EXISTS (SELECT 1 FROM legacy_customers lc WHERE lower(trim(lc.email)) = lower(trim(c.email)))
) AS overlap_customers_missed_by_xref_alone;
.print '=== T14 grain-mismatch ==='
SELECT
COUNT(*) AS total_orders,
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) <= 0.005) AS exact_match,
COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) AS mismatched,
ROUND(100.0 * COUNT(*) FILTER (WHERE ABS(o.order_total - i.items_sum) > 0.005) / COUNT(*), 2) AS pct_mismatched
FROM orders o
JOIN (SELECT order_id, SUM(quantity*unit_price - discount_amount) AS items_sum FROM order_items GROUP BY order_id) i
ON i.order_id = o.id;
.print '=== T15 returns-vs-refunds ==='
SELECT
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id) AS orders_with_returns,
(SELECT COUNT(DISTINCT order_id) FROM refunds) AS orders_with_refunds,
(SELECT COUNT(DISTINCT oi.order_id) FROM returns r JOIN order_items oi ON oi.id = r.order_item_id
WHERE oi.order_id IN (SELECT order_id FROM refunds)) AS orders_with_both;
@@ -0,0 +1,75 @@
# Mirror of cubes/<name>/metadata.yml for consumers that read a root cubes.yml
# (warble bindings/mdl-context). Keep in sync with the cubes/ directory.
- name: mrr_metrics
base_object: subscription_snapshots
measures:
# Semi-additive: additive across subscriptions AT one snapshot_date, never across dates.
- name: mrr
expression: SUM(mrr_amount)
type: DOUBLE
- name: active_subscriptions
expression: COUNT(*)
type: BIGINT
dimensions:
- name: status
expression: status
type: VARCHAR
time_dimensions:
- name: snapshot_date
expression: snapshot_date
type: DATE
properties:
description: "Point-in-time MRR from month-end snapshots. ALWAYS pin one snapshot_date (or take an average of month-end totals); summing across snapshot dates is meaningless."
- name: order_metrics
base_object: orders
measures:
- name: order_count
expression: COUNT(*)
type: BIGINT
# Additive across orders; header-grain (see knowledge: line-item grain is canonical for merchandise value).
- name: total_order_value
expression: SUM(order_total)
type: DOUBLE
# Non-additive: an average of parts is not the average of the whole.
- name: avg_order_value
expression: AVG(order_total)
type: DOUBLE
dimensions:
- name: status
expression: status
type: VARCHAR
- name: currency
expression: currency
type: VARCHAR
time_dimensions:
- name: placed_at
expression: placed_at
type: TIMESTAMP
properties:
description: "New-platform order counts and header-grain value in order currency. Includes migrated legacy copies (legacy_ord_id NOT NULL) — dedup per cross-system rules for all-time counts."
- name: payment_metrics
base_object: payments
measures:
# Additive: gross captured money movement (excl. refund reversals — filter in queries).
- name: gross_captured
expression: SUM(amount + fee_amount)
type: DOUBLE
- name: net_captured
expression: SUM(amount)
type: DOUBLE
- name: payment_count
expression: COUNT(*)
type: BIGINT
dimensions:
- name: method
expression: method
type: VARCHAR
- name: currency
expression: currency
type: VARCHAR
time_dimensions:
- name: captured_at
expression: captured_at
type: TIMESTAMP
properties:
description: "Captured payment volume in transaction currency. gross = amount + fee_amount; excludes nothing by itself — refund reversals (method='refund_reversal') must be filtered out for revenue, and non-USD amounts converted via fx_rates."
@@ -0,0 +1,20 @@
name: mrr_metrics
base_object: subscription_snapshots
measures:
# Semi-additive: additive across subscriptions AT one snapshot_date, never across dates.
- name: mrr
expression: SUM(mrr_amount)
type: DOUBLE
- name: active_subscriptions
expression: COUNT(*)
type: BIGINT
dimensions:
- name: status
expression: status
type: VARCHAR
time_dimensions:
- name: snapshot_date
expression: snapshot_date
type: DATE
properties:
description: "Point-in-time MRR from month-end snapshots. ALWAYS pin one snapshot_date (or take an average of month-end totals); summing across snapshot dates is meaningless."
@@ -0,0 +1,27 @@
name: order_metrics
base_object: orders
measures:
- name: order_count
expression: COUNT(*)
type: BIGINT
# Additive across orders; header-grain (see knowledge: line-item grain is canonical for merchandise value).
- name: total_order_value
expression: SUM(order_total)
type: DOUBLE
# Non-additive: an average of parts is not the average of the whole.
- name: avg_order_value
expression: AVG(order_total)
type: DOUBLE
dimensions:
- name: status
expression: status
type: VARCHAR
- name: currency
expression: currency
type: VARCHAR
time_dimensions:
- name: placed_at
expression: placed_at
type: TIMESTAMP
properties:
description: "New-platform order counts and header-grain value in order currency. Includes migrated legacy copies (legacy_ord_id NOT NULL) — dedup per cross-system rules for all-time counts."
@@ -0,0 +1,26 @@
name: payment_metrics
base_object: payments
measures:
# Additive: gross captured money movement (excl. refund reversals — filter in queries).
- name: gross_captured
expression: SUM(amount + fee_amount)
type: DOUBLE
- name: net_captured
expression: SUM(amount)
type: DOUBLE
- name: payment_count
expression: COUNT(*)
type: BIGINT
dimensions:
- name: method
expression: method
type: VARCHAR
- name: currency
expression: currency
type: VARCHAR
time_dimensions:
- name: captured_at
expression: captured_at
type: TIMESTAMP
properties:
description: "Captured payment volume in transaction currency. gross = amount + fee_amount; excludes nothing by itself — refund reversals (method='refund_reversal') must be filtered out for revenue, and non-USD amounts converted via fx_rates."
@@ -0,0 +1,16 @@
# Declarative dashboard specs — the git-native consumer convention Warble's lineage reads
# (`docs/spec/blast-radius.md` §3). Each dashboard becomes a `dashboard:<name>` lineage node;
# a panel references the semantic layer either by raw SQL (references discovered by parsing)
# or by declared `cube` + `measures`.
dashboards:
- name: exec-weekly
title: Executive weekly
panels:
- name: mrr-trend
cube: mrr_metrics
measures: [mrr]
- name: revenue-by-country
sql: >
SELECT c.country, SUM(o.order_total) AS revenue
FROM orders o JOIN customers c ON o.customer_id = c.id
GROUP BY c.country
@@ -0,0 +1,20 @@
{
"format_version": 1,
"id": "driftwood-seed42-v1",
"repository": "Canner/Warble",
"release_tag": "eval-fixtures-v1",
"asset_name": "driftwood-seed42-2026-06-30-v1.duckdb",
"sha256": "3adbf0d7d7cb82331667a2cb2c37d66bc1a46f8f3bb4f5658df70a1449a277dd",
"size_bytes": 22032384,
"generator": {
"base_fixture_version": 1,
"seed": 42,
"reference_date": "2026-06-30",
"source_commit": "cdd42438af417bd2ccba854aac87a0617c9a47d1"
},
"expectations": {
"table_count": 18,
"total_rows": 693531,
"subscription_snapshots_max_date": "2026-06-30"
}
}
@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Fetch and verify the pinned synthetic Driftwood base fixture.
The release asset is immutable input for ordinary eval preparation. Updating
it is an explicit refresh operation: publish a new asset/tag, then update the
committed lock. A missing asset or checksum mismatch always fails; this helper
never falls back to the expensive generator.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
from typing import BinaryIO
import urllib.error
import urllib.request
HERE = Path(__file__).resolve().parent
DEFAULT_LOCK = HERE / "fixture.lock.json"
CHUNK_SIZE = 1024 * 1024
HTTP_TIMEOUT_SECONDS = 120
class FixtureError(RuntimeError):
pass
def load_lock(path: Path) -> dict:
try:
lock = json.loads(path.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise FixtureError(f"cannot read fixture lock {path}: {exc}") from exc
required = {
"format_version",
"id",
"repository",
"release_tag",
"asset_name",
"sha256",
"size_bytes",
"generator",
"expectations",
}
missing = required - set(lock)
if missing:
raise FixtureError(f"fixture lock is missing fields: {sorted(missing)}")
if lock["format_version"] != 1:
raise FixtureError(f"unsupported fixture lock version: {lock['format_version']}")
if len(lock["sha256"]) != 64:
raise FixtureError("fixture lock sha256 must contain 64 hexadecimal characters")
try:
int(lock["sha256"], 16)
except ValueError as exc:
raise FixtureError("fixture lock sha256 is not hexadecimal") from exc
return lock
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(CHUNK_SIZE), b""):
digest.update(chunk)
return digest.hexdigest()
def verify_file(path: Path, lock: dict) -> None:
try:
size = path.stat().st_size
except OSError as exc:
raise FixtureError(f"fixture is unavailable at {path}: {exc}") from exc
if size != lock["size_bytes"]:
raise FixtureError(
f"fixture size mismatch for {path}: expected {lock['size_bytes']}, found {size}"
)
actual = sha256_file(path)
if actual != lock["sha256"]:
raise FixtureError(
f"fixture checksum mismatch for {path}: expected {lock['sha256']}, found {actual}"
)
def default_cache_root() -> Path:
explicit = os.environ.get("WARBLE_EVAL_FIXTURE_CACHE")
if explicit:
return Path(explicit).expanduser()
xdg = os.environ.get("XDG_CACHE_HOME")
base = Path(xdg).expanduser() if xdg else Path.home() / ".cache"
return base / "warble" / "eval-fixtures"
def _auth_token() -> str | None:
token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
if token:
return token
if shutil.which("gh") is None:
return None
result = subprocess.run(
["gh", "auth", "token"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
token = result.stdout.strip()
return token or None
def _request(url: str, token: str | None, accept: str) -> urllib.request.Request:
headers = {
"Accept": accept,
"User-Agent": "warble-eval-fixture/1",
"X-GitHub-Api-Version": "2022-11-28",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return urllib.request.Request(url, headers=headers)
def _copy_response(response: BinaryIO, destination: Path) -> None:
with destination.open("wb") as output:
while True:
chunk = response.read(CHUNK_SIZE)
if not chunk:
break
output.write(chunk)
def _download_release_asset(lock: dict, destination: Path) -> None:
token = _auth_token()
release_url = (
f"https://api.github.com/repos/{lock['repository']}/releases/tags/"
f"{lock['release_tag']}"
)
try:
with urllib.request.urlopen(
_request(release_url, token, "application/vnd.github+json"),
timeout=HTTP_TIMEOUT_SECONDS,
) as response:
release = json.load(response)
asset = next(
(item for item in release.get("assets", []) if item.get("name") == lock["asset_name"]),
None,
)
if asset is None:
raise FixtureError(
f"release {lock['repository']}@{lock['release_tag']} has no asset "
f"named {lock['asset_name']}"
)
with urllib.request.urlopen(
_request(asset["url"], token, "application/octet-stream"),
timeout=HTTP_TIMEOUT_SECONDS,
) as response:
_copy_response(response, destination)
except urllib.error.HTTPError as exc:
raise FixtureError(
f"fixture download failed with HTTP {exc.code}; verify release/tag access"
) from exc
except urllib.error.URLError as exc:
raise FixtureError(f"fixture download failed: {exc.reason}") from exc
def cached_path(lock: dict, cache_root: Path) -> Path:
cache_key = f"{lock['id']}-{lock['sha256']}"
return cache_root / cache_key / lock["asset_name"]
def fetch(lock: dict, cache_root: Path) -> tuple[Path, bool]:
target = cached_path(lock, cache_root)
if target.is_file():
try:
verify_file(target, lock)
return target, True
except FixtureError:
# A fresh verified download atomically replaces the corrupt entry.
pass
target.parent.mkdir(parents=True, exist_ok=True)
# Keep the temporary download on the cache filesystem so os.replace is
# atomic even when the system temp directory is a different mount.
with tempfile.TemporaryDirectory(prefix=".download-", dir=target.parent) as temp_dir:
downloaded = Path(temp_dir) / lock["asset_name"]
_download_release_asset(lock, downloaded)
verify_file(downloaded, lock)
os.replace(downloaded, target)
return target, False
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--lock", type=Path, default=DEFAULT_LOCK)
subparsers = parser.add_subparsers(dest="command", required=True)
fetch_parser = subparsers.add_parser("fetch", help="download once, verify, and reuse the cache")
fetch_parser.add_argument("--cache-dir", type=Path, default=None)
fetch_parser.add_argument("--output", type=Path, default=None)
verify_parser = subparsers.add_parser("verify", help="verify a local file against the lock")
verify_parser.add_argument("path", type=Path)
subparsers.add_parser("describe", help="print the committed fixture identity")
return parser
def main() -> int:
args = build_parser().parse_args()
try:
lock = load_lock(args.lock)
if args.command == "verify":
verify_file(args.path, lock)
print(args.path)
return 0
if args.command == "describe":
print(json.dumps(lock, indent=2, sort_keys=True))
return 0
cache_root = args.cache_dir or default_cache_root()
path, hit = fetch(lock, cache_root)
print(f"fixture cache {'hit' if hit else 'miss'}: {path}", file=sys.stderr)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(path, args.output)
print(args.output)
else:
print(path)
return 0
except FixtureError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
schema_version: 1
@@ -0,0 +1,36 @@
# Canonical metric definitions (Driftwood)
These are the company's authoritative definitions. When a question asks for one of these
metrics, use exactly this definition — do not improvise an alternative.
## Recognized revenue / gross captured volume
- **Gross captured payment volume** = `SUM(payments.amount + payments.fee_amount)` over
payment rows with `method <> 'refund_reversal'`. (`payments.amount` alone is NET of
processor fees; `orders.order_total` is a header estimate, not money movement.)
- **Refunds are subtracted from the `refunds` table only.** Every refund exists twice:
as a `refunds` row (positive, gross) AND as a negative `payments` row
(`method = 'refund_reversal'`). Counting both double-subtracts. Convention: exclude
`refund_reversal` rows from payment sums; take refund totals from `refunds`.
- **Net recognized revenue** = gross captured volume − `SUM(refunds.amount)`, both in USD.
## Currency conversion
- Reporting currency is **USD**. Convert EUR/GBP amounts at the `fx_rates` rate for the
transaction date; `fx_rates` has business days only — for weekend dates use the **latest
prior business day's** rate (ASOF/last-value join on `fx_rates.date <= txn_date` per currency).
## Merchandise value (order value at line grain)
- Canonical order/merchandise value is computed at **line-item grain**:
`SUM(order_items.quantity * order_items.unit_price - order_items.discount_amount)`.
- `orders.order_total` disagrees with the line-item sum on ~8% of orders (rounding,
partial-refund adjustments). For reconciliation-sensitive answers, line items win.
## MRR (monthly recurring revenue)
- MRR is a **point-in-time balance**, read from `subscription_snapshots` at a single
`snapshot_date` (month-end): `SUM(mrr_amount) WHERE snapshot_date = <month end>`.
- **Never sum mrr_amount across snapshot dates.** "MRR for December 2025" means the
2025-12-31 snapshot. "Average MRR over a year" means the average of the 12 month-end
totals. The same point-in-time rule applies to `inventory_levels.units_on_hand`.
@@ -0,0 +1,59 @@
# Reporting conventions (Driftwood)
## Default filters
- **Exclude test accounts from EVERY business metric** — customer counts, order counts,
revenue, units sold, returns, subscriptions — unless the question explicitly asks about
internal/test accounts. Test accounts are `customers.is_test = TRUE`; apply the exclusion
wherever a customer link exists (orders/payments/refunds/returns join back to the
customer). Legacy tables carry no test flag, so no exclusion is possible there.
- **Soft deletes**: `customers.deleted_at IS NOT NULL` rows still exist in the table.
"Active customers" excludes them; counts of accounts/customers "we have (ever) had"
include them (but still exclude test accounts).
## Reporting timezone
All date bucketing and calendar filters (year/quarter/month/day) are in **UTC**.
New-platform timestamps are already UTC. Legacy naive America/Los_Angeles timestamps must
be converted to UTC before any bucketing — including for legacy-only questions.
## Units sold
"Units sold" = the sum of order-line quantities, **excluding cancelled orders** (they were
never fulfilled). Refunded orders still count as sold — the money side is handled via
refunds, not by un-counting the sale.
## Marketing channel canonicalization
`customers.marketing_channel` contains historical variants. Canonical grouping:
| canonical | raw values |
| --- | --- |
| Facebook | 'fb', 'facebook', 'Facebook Ads' |
| Google | 'google', 'adwords', 'Google' |
| Email | 'email', 'newsletter' |
| Organic | 'organic' |
| Unknown | NULL |
Any channel question must aggregate over the canonical groups, not the raw strings.
## Order status normalization
`orders.status` is lowercase except a small share of historical mixed-case rows
('PAID', 'Paid'). Always compare case-insensitively: `lower(status) = 'paid'`.
## Fiscal calendar
The fiscal year starts **February 1** and is labeled by its starting year
(FY2024 = 2024-02-01 → 2025-01-31; Q1 = Feb–Apr, Q2 = May–Jul, Q3 = Aug–Oct,
Q4 = Nov–Jan). Any "Qn" or "fiscal year" question must go through `fiscal_calendar`
— never assume calendar quarters.
## Returns vs refunds (two different things)
- **Returns** (`returns`) = physical merchandise coming back (RMA), measured in units
(`qty_returned`). Use for return rates and item counts.
- **Refunds** (`refunds`) = money given back, measured in currency. Use for refund
amounts.
- They overlap only partially (a refund can happen without a return and vice versa);
never join or substitute one for the other.
@@ -0,0 +1,35 @@
# Cross-system rules: legacy platform vs new platform
Driftwood migrated platforms in 2023. Legacy (`legacy_*` tables) covers 2019-01 → 2023-08;
the new platform (`orders`, `customers`, …) covers 2023-03 → present. Any all-time or
pre-2023 question must combine both systems under these rules.
## Order dedup (the migration copied orders)
- Legacy orders from the migration window were **copied into `orders`**: the copy carries
`orders.legacy_ord_id = legacy_orders.ord_id`, and the source carries
`legacy_orders.migrated_at IS NOT NULL`.
- **Counting rule**: all-time order counts/sums = all of `legacy_orders` + only
`orders WHERE legacy_ord_id IS NULL` (or equivalently: all of `orders` + only
`legacy_orders WHERE migrated_at IS NULL`). Never a plain UNION of both tables.
## Legacy amounts and timestamps
- `legacy_orders.amt_c` is integer **US cents** — divide by 100.0. Legacy is USD-only.
- `legacy_orders.ord_dt` / `ship_dt` are naive **America/Los_Angeles local time** strings.
Convert to UTC before any date bucketing or comparison — the reporting timezone is
always UTC (see conventions), including for legacy-only questions.
- `legacy_orders.ship_dt = '1970-01-01 00:00:00'` is a sentinel meaning **never shipped**
— treat it as NULL, never as a real date.
- Legacy statuses are numeric codes: decode via `legacy_status_codes`
(1 pending, 2 paid, 3 shipped, 4 delivered, 5 partial, 7 refunded, 9 cancelled).
## Customer identity across systems
- Many legacy customers are the **same person** as a new-platform customer.
`customer_xref` maps them but is **incomplete** (~87% of the true overlap).
- **Identity rule**: resolve identity by normalized email —
`lower(trim(email))` matches across `legacy_customers` and `customers` — and use
`customer_xref` only as a supplement. Unique-customer counts = new-platform customers
(excluding test accounts) + legacy customers whose normalized email has no
new-platform match.
@@ -0,0 +1,3 @@
# Business rules
Add custom rules or guidelines for LLM-based query generation here.
@@ -0,0 +1,7 @@
---
nl: Monthly MRR trend from month-end snapshots
sql: SELECT snapshot_date, mrr FROM mrr_metrics GROUP BY snapshot_date ORDER BY snapshot_date
source: user
tags:
- source:enrich
---
@@ -0,0 +1,7 @@
---
nl: Order count by status on the new platform
sql: SELECT status, COUNT(*) AS order_count FROM orders GROUP BY status
source: user
tags:
- source:enrich
---
@@ -0,0 +1,16 @@
name: customer_xref
properties:
description: "Cross-reference mapping legacy customers to new-platform customers. INCOMPLETE: covers only ~87% of the true overlap; email normalization finds the rest."
table_reference:
catalog: driftwood
schema: main
table: customer_xref
columns:
- name: cust_ref
type: VARCHAR
properties:
description: "FK to legacy_customers.cust_ref."
- name: customer_id
type: INTEGER
properties:
description: "FK to customers.id."
@@ -0,0 +1,41 @@
name: customers
properties:
description: "New-platform customers (2023-03 onward). One row per account, including soft-deleted and internal test accounts."
table_reference:
catalog: driftwood
schema: main
table: customers
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique customer id (new platform)."
- name: email
type: VARCHAR
properties:
description: "Account email as entered; casing/whitespace not normalized."
- name: full_name
type: VARCHAR
properties:
description: "Customer display name."
- name: country
type: VARCHAR
properties:
description: "ISO-ish country code: US, DE, FR, GB."
- name: created_at
type: TIMESTAMP
properties:
description: "Account creation time (UTC)."
- name: deleted_at
type: TIMESTAMP
properties:
description: "Soft-delete timestamp (UTC); NULL = active. Deleted rows remain in the table."
- name: is_test
type: BOOLEAN
properties:
description: "TRUE for internal test accounts (emails with '+test' or @driftwood.example)."
- name: marketing_channel
type: VARCHAR
properties:
description: "Free-text acquisition channel; contains historical spelling variants (e.g. 'fb' / 'facebook' / 'Facebook Ads' are the same channel) and NULLs."
@@ -0,0 +1,21 @@
name: fiscal_calendar
properties:
description: "Company fiscal calendar. The fiscal year starts Feb 1 and is labeled by its starting year (FY2024 = 2024-02-01 through 2025-01-31)."
table_reference:
catalog: driftwood
schema: main
table: fiscal_calendar
primary_key: date
columns:
- name: date
type: DATE
properties:
description: "Calendar date."
- name: fiscal_year
type: INTEGER
properties:
description: "Fiscal year label (year in which the FY starts)."
- name: fiscal_quarter
type: VARCHAR
properties:
description: "Fiscal quarter label, e.g. 'FY2024-Q3' (Q1 = Feb-Apr)."
@@ -0,0 +1,20 @@
name: fx_rates
properties:
description: "Daily FX rates to USD, BUSINESS DAYS ONLY (no weekend rows) — for a weekend date use the latest prior business day."
table_reference:
catalog: driftwood
schema: main
table: fx_rates
columns:
- name: date
type: DATE
properties:
description: "Rate date (weekdays only)."
- name: currency
type: VARCHAR
properties:
description: "Quoted currency: EUR or GBP."
- name: usd_rate
type: "DECIMAL(12,6)"
properties:
description: "USD per 1 unit of the quoted currency."
@@ -0,0 +1,24 @@
name: inventory_levels
properties:
description: "Month-end inventory snapshots per warehouse and product. units_on_hand is a BALANCE as of snapshot_date — not additive across dates."
table_reference:
catalog: driftwood
schema: main
table: inventory_levels
columns:
- name: snapshot_date
type: DATE
properties:
description: "Snapshot date; always the last day of a calendar month."
- name: warehouse_id
type: INTEGER
properties:
description: "FK to warehouses.id."
- name: product_id
type: INTEGER
properties:
description: "FK to products.id."
- name: units_on_hand
type: INTEGER
properties:
description: "Units in stock at snapshot time (point-in-time balance)."
@@ -0,0 +1,25 @@
name: legacy_customers
properties:
description: "Customers from the legacy platform. Many are the same people as new-platform customers, with email casing/whitespace variants; customer_xref maps only part of them."
table_reference:
catalog: driftwood
schema: main
table: legacy_customers
primary_key: cust_ref
columns:
- name: cust_ref
type: VARCHAR
properties:
description: "Legacy customer key, e.g. 'C-04217'."
- name: email
type: VARCHAR
properties:
description: "Email as entered in the legacy system; casing/whitespace may differ from the same person's new-platform email."
- name: signup_dt
type: DATE
properties:
description: "Legacy signup date."
- name: region
type: VARCHAR
properties:
description: "Legacy sales region: west, east, or eu."
@@ -0,0 +1,37 @@
name: legacy_orders
properties:
description: "Orders from the legacy platform (2019-01 to 2023-08, USD only). Orders in the 2023 migration window may ALSO exist as copies in the new orders table (see migrated_at)."
table_reference:
catalog: driftwood
schema: main
table: legacy_orders
primary_key: ord_id
columns:
- name: ord_id
type: INTEGER
properties:
description: "Unique legacy order id."
- name: cust_ref
type: VARCHAR
properties:
description: "Legacy customer key (FK to legacy_customers.cust_ref), e.g. 'C-04217'."
- name: ord_dt
type: VARCHAR
properties:
description: "Order timestamp as a NAIVE LOCAL-TIME string ('YYYY-MM-DD HH:MM:SS') in America/Los_Angeles — convert to UTC before comparing with new-platform timestamps."
- name: amt_c
type: INTEGER
properties:
description: "Order amount in integer US CENTS (divide by 100.0 for dollars). USD only."
- name: stat
type: INTEGER
properties:
description: "Numeric status code; decode via legacy_status_codes."
- name: ship_dt
type: VARCHAR
properties:
description: "Ship timestamp string; the sentinel '1970-01-01 00:00:00' means never shipped (the legacy system did not use NULL)."
- name: migrated_at
type: TIMESTAMP
properties:
description: "Non-NULL when this order was copied into the new platform's orders table during the 2023 migration (the copy carries legacy_ord_id = this ord_id)."
@@ -0,0 +1,17 @@
name: legacy_status_codes
properties:
description: "Lookup for legacy_orders.stat numeric codes. Code 5 ('partial') was historically undocumented."
table_reference:
catalog: driftwood
schema: main
table: legacy_status_codes
primary_key: code
columns:
- name: code
type: INTEGER
properties:
description: "Numeric status code used by legacy_orders.stat."
- name: label
type: VARCHAR
properties:
description: "Human-readable status label."
@@ -0,0 +1,33 @@
name: order_items
properties:
description: "Order line items (new platform). One row per product line within an order."
table_reference:
catalog: driftwood
schema: main
table: order_items
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Surrogate line-item id."
- name: order_id
type: INTEGER
properties:
description: "FK to orders.id."
- name: product_id
type: INTEGER
properties:
description: "FK to products.id."
- name: quantity
type: INTEGER
properties:
description: "Units ordered on this line."
- name: unit_price
type: "DECIMAL(12,2)"
properties:
description: "Per-unit price in the order's currency."
- name: discount_amount
type: "DECIMAL(12,2)"
properties:
description: "Line-level discount in the order's currency (0 if none)."
@@ -0,0 +1,41 @@
name: orders
properties:
description: "New-platform orders (2023-03 onward). One row per order header. Includes copies of legacy orders migrated during 2023 (marked by legacy_ord_id)."
table_reference:
catalog: driftwood
schema: main
table: orders
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique order id (new platform)."
- name: customer_id
type: INTEGER
properties:
description: "FK to customers.id."
- name: currency
type: VARCHAR
properties:
description: "Order currency: USD, EUR, or GBP."
- name: order_total
type: "DECIMAL(12,2)"
properties:
description: "Header-level gross order total (incl. tax) in the order's currency. Can differ slightly from the sum of line items on a small share of orders."
- name: status
type: VARCHAR
properties:
description: "Order status: pending, paid, shipped, delivered, cancelled, refunded. A small share of historical rows use mixed case (e.g. 'PAID', 'Paid')."
- name: placed_at
type: TIMESTAMP
properties:
description: "Order placement time (UTC)."
- name: shipped_at
type: TIMESTAMP
properties:
description: "Ship time (UTC); NULL = not shipped."
- name: legacy_ord_id
type: INTEGER
properties:
description: "Non-NULL when this row is a migrated copy of a legacy order (FK to legacy_orders.ord_id). Such orders exist in BOTH tables."
@@ -0,0 +1,37 @@
name: payments
properties:
description: "Captured payments (new platform). One row per capture attempt; refund reversals appear as negative-amount rows."
table_reference:
catalog: driftwood
schema: main
table: payments
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique payment id."
- name: order_id
type: INTEGER
properties:
description: "FK to orders.id."
- name: amount
type: "DECIMAL(12,2)"
properties:
description: "Payment amount NET of processor fees, in the payment currency. NOTE: different meaning from orders.order_total (gross). Refund reversals are negative amounts with method='refund_reversal' and also exist in the refunds table."
- name: fee_amount
type: "DECIMAL(12,2)"
properties:
description: "Processor fee for this capture; gross captured = amount + fee_amount."
- name: currency
type: VARCHAR
properties:
description: "Payment currency (matches the order's currency)."
- name: captured_at
type: TIMESTAMP
properties:
description: "Capture time (UTC)."
- name: method
type: VARCHAR
properties:
description: "card, paypal, apple_pay, or refund_reversal (negative rows)."
@@ -0,0 +1,41 @@
name: products
properties:
description: "Product catalog. One row per SKU."
table_reference:
catalog: driftwood
schema: main
table: products
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique product id."
- name: sku
type: VARCHAR
properties:
description: "Stock keeping unit code."
- name: name
type: VARCHAR
properties:
description: "Product display name."
- name: category
type: VARCHAR
properties:
description: "Hierarchical category as a single string, e.g. 'Camping > Tents'."
- name: current_price
type: "DECIMAL(12,2)"
properties:
description: "Current list price in USD."
- name: cost
type: "DECIMAL(12,2)"
properties:
description: "Unit cost in USD."
- name: introduced_at
type: TIMESTAMP
properties:
description: "When the product entered the catalog (UTC)."
- name: discontinued_at
type: TIMESTAMP
properties:
description: "Non-NULL = discontinued at this time (UTC)."
@@ -0,0 +1,29 @@
name: refunds
properties:
description: "Money refunds (new platform). One row per refund, gross amount, positive sign. Each refund is ALSO mirrored as a negative payments row."
table_reference:
catalog: driftwood
schema: main
table: refunds
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique refund id."
- name: order_id
type: INTEGER
properties:
description: "FK to orders.id."
- name: amount
type: "DECIMAL(12,2)"
properties:
description: "Refunded amount, GROSS and positive, in the order's currency."
- name: reason
type: VARCHAR
properties:
description: "Free-text refund reason."
- name: refunded_at
type: TIMESTAMP
properties:
description: "Refund time (UTC)."
@@ -0,0 +1,29 @@
name: returns
properties:
description: "Merchandise returns (RMA) at the order-line level. Distinct from refunds: returns track physical items coming back; refunds track money."
table_reference:
catalog: driftwood
schema: main
table: returns
primary_key: rma_id
columns:
- name: rma_id
type: INTEGER
properties:
description: "Unique return authorization id."
- name: order_item_id
type: INTEGER
properties:
description: "FK to order_items.id."
- name: qty_returned
type: INTEGER
properties:
description: "Units physically returned on this RMA."
- name: received_at
type: TIMESTAMP
properties:
description: "When the returned items were received (UTC)."
- name: disposition
type: VARCHAR
properties:
description: "restock, damaged, or disposed."
@@ -0,0 +1,24 @@
name: subscription_snapshots
properties:
description: "Month-end point-in-time snapshots of active subscriptions. mrr_amount is a BALANCE as of snapshot_date — it is not additive across dates."
table_reference:
catalog: driftwood
schema: main
table: subscription_snapshots
columns:
- name: snapshot_date
type: DATE
properties:
description: "Snapshot date; always the last day of a calendar month."
- name: subscription_id
type: INTEGER
properties:
description: "FK to subscriptions.id."
- name: mrr_amount
type: "DECIMAL(12,2)"
properties:
description: "Monthly recurring revenue attributed to this subscription as of the snapshot date (USD). Point-in-time balance; never sum across snapshot dates."
- name: status
type: VARCHAR
properties:
description: "Subscription status at snapshot time."
@@ -0,0 +1,33 @@
name: subscriptions
properties:
description: "Driftwood+ gear-rental subscriptions (launched 2024-01). One row per subscription."
table_reference:
catalog: driftwood
schema: main
table: subscriptions
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique subscription id."
- name: customer_id
type: INTEGER
properties:
description: "FK to customers.id."
- name: plan
type: VARCHAR
properties:
description: "plus_monthly or plus_annual."
- name: started_at
type: TIMESTAMP
properties:
description: "Subscription start (UTC)."
- name: canceled_at
type: TIMESTAMP
properties:
description: "Cancellation time (UTC); NULL = still active."
- name: monthly_price
type: "DECIMAL(12,2)"
properties:
description: "Monthly recurring price in USD (annual plans stored as the monthly-equivalent amount)."
@@ -0,0 +1,25 @@
name: warehouses
properties:
description: "Fulfillment warehouses."
table_reference:
catalog: driftwood
schema: main
table: warehouses
primary_key: id
columns:
- name: id
type: INTEGER
properties:
description: "Unique warehouse id."
- name: name
type: VARCHAR
properties:
description: "Warehouse name."
- name: country
type: VARCHAR
properties:
description: "Warehouse country (US or DE)."
- name: tz
type: VARCHAR
properties:
description: "IANA timezone of the warehouse."
@@ -0,0 +1,29 @@
name: web_events
properties:
description: "Website behavioral events. High-volume append-only log."
table_reference:
catalog: driftwood
schema: main
table: web_events
primary_key: id
columns:
- name: id
type: BIGINT
properties:
description: "Unique event id."
- name: event_time
type: BIGINT
properties:
description: "Event timestamp as EPOCH MILLISECONDS (BIGINT, UTC)."
- name: customer_id
type: INTEGER
properties:
description: "FK to customers.id; NULL for anonymous sessions."
- name: event_type
type: VARCHAR
properties:
description: "page_view, add_to_cart, checkout, or search."
- name: session_id
type: VARCHAR
properties:
description: "Browser session identifier."
@@ -0,0 +1,82 @@
# Driftwood relationships. Note: fx_rates joins on (date, currency) with a business-day
# fill convention — not expressible as an FK equality here; see knowledge/rules/.
relationships:
- name: orders_customers
models: [orders, customers]
join_type: MANY_TO_ONE
condition: "orders.customer_id = customers.id"
- name: order_items_orders
models: [order_items, orders]
join_type: MANY_TO_ONE
condition: "order_items.order_id = orders.id"
- name: order_items_products
models: [order_items, products]
join_type: MANY_TO_ONE
condition: "order_items.product_id = products.id"
- name: payments_orders
models: [payments, orders]
join_type: MANY_TO_ONE
condition: "payments.order_id = orders.id"
- name: refunds_orders
models: [refunds, orders]
join_type: MANY_TO_ONE
condition: "refunds.order_id = orders.id"
- name: subscriptions_customers
models: [subscriptions, customers]
join_type: MANY_TO_ONE
condition: "subscriptions.customer_id = customers.id"
- name: subscription_snapshots_subscriptions
models: [subscription_snapshots, subscriptions]
join_type: MANY_TO_ONE
condition: "subscription_snapshots.subscription_id = subscriptions.id"
- name: web_events_customers
models: [web_events, customers]
join_type: MANY_TO_ONE
condition: "web_events.customer_id = customers.id"
- name: inventory_levels_warehouses
models: [inventory_levels, warehouses]
join_type: MANY_TO_ONE
condition: "inventory_levels.warehouse_id = warehouses.id"
- name: inventory_levels_products
models: [inventory_levels, products]
join_type: MANY_TO_ONE
condition: "inventory_levels.product_id = products.id"
- name: legacy_orders_legacy_customers
models: [legacy_orders, legacy_customers]
join_type: MANY_TO_ONE
condition: "legacy_orders.cust_ref = legacy_customers.cust_ref"
- name: legacy_orders_status_codes
models: [legacy_orders, legacy_status_codes]
join_type: MANY_TO_ONE
condition: "legacy_orders.stat = legacy_status_codes.code"
- name: orders_legacy_orders
models: [orders, legacy_orders]
join_type: ONE_TO_ONE
condition: "orders.legacy_ord_id = legacy_orders.ord_id"
- name: customer_xref_customers
models: [customer_xref, customers]
join_type: MANY_TO_ONE
condition: "customer_xref.customer_id = customers.id"
- name: customer_xref_legacy_customers
models: [customer_xref, legacy_customers]
join_type: MANY_TO_ONE
condition: "customer_xref.cust_ref = legacy_customers.cust_ref"
- name: returns_order_items
models: [returns, order_items]
join_type: MANY_TO_ONE
condition: "returns.order_item_id = order_items.id"
@@ -0,0 +1,299 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""Emit models/<table>/metadata.yml for the driftwood wren project.
Column types come from schema_dump.csv (authoritative, introspected from the DB);
descriptions live in the DESC dict below. Fails loudly if any column in the dump
lacks a description (or vice versa), so the two can't drift.
Description policy (matters for the eval design): MDL descriptions carry truthful
*local* facts only (units, timezone, sentinels, net-vs-gross, snapshot grain).
Cross-cutting *business rules* (canonical revenue formula, dedup rule, fiscal
calendar usage, identity resolution) deliberately live in knowledge/rules/ —
that split is the schema-only vs schema+knowledge injection axis for this Wren fixture.
"""
import csv
import sys
from pathlib import Path
HERE = Path(__file__).parent
# table -> (table_description, primary_key or None, {column: description})
DESC: dict[str, tuple[str, str | None, dict[str, str]]] = {
"customers": (
"New-platform customers (2023-03 onward). One row per account, including "
"soft-deleted and internal test accounts.",
"id",
{
"id": "Unique customer id (new platform).",
"email": "Account email as entered; casing/whitespace not normalized.",
"full_name": "Customer display name.",
"country": "ISO-ish country code: US, DE, FR, GB.",
"created_at": "Account creation time (UTC).",
"deleted_at": "Soft-delete timestamp (UTC); NULL = active. Deleted rows remain in the table.",
"is_test": "TRUE for internal test accounts (emails with '+test' or @driftwood.example).",
"marketing_channel": "Free-text acquisition channel; contains historical spelling variants "
"(e.g. 'fb' / 'facebook' / 'Facebook Ads' are the same channel) and NULLs.",
},
),
"orders": (
"New-platform orders (2023-03 onward). One row per order header. Includes copies of "
"legacy orders migrated during 2023 (marked by legacy_ord_id).",
"id",
{
"id": "Unique order id (new platform).",
"customer_id": "FK to customers.id.",
"currency": "Order currency: USD, EUR, or GBP.",
"order_total": "Header-level gross order total (incl. tax) in the order's currency. "
"Can differ slightly from the sum of line items on a small share of orders.",
"status": "Order status: pending, paid, shipped, delivered, cancelled, refunded. "
"A small share of historical rows use mixed case (e.g. 'PAID', 'Paid').",
"placed_at": "Order placement time (UTC).",
"shipped_at": "Ship time (UTC); NULL = not shipped.",
"legacy_ord_id": "Non-NULL when this row is a migrated copy of a legacy order "
"(FK to legacy_orders.ord_id). Such orders exist in BOTH tables.",
},
),
"order_items": (
"Order line items (new platform). One row per product line within an order.",
"id",
{
"id": "Surrogate line-item id.",
"order_id": "FK to orders.id.",
"product_id": "FK to products.id.",
"quantity": "Units ordered on this line.",
"unit_price": "Per-unit price in the order's currency.",
"discount_amount": "Line-level discount in the order's currency (0 if none).",
},
),
"payments": (
"Captured payments (new platform). One row per capture attempt; refund reversals "
"appear as negative-amount rows.",
"id",
{
"id": "Unique payment id.",
"order_id": "FK to orders.id.",
"amount": "Payment amount NET of processor fees, in the payment currency. "
"NOTE: different meaning from orders.order_total (gross). Refund reversals are "
"negative amounts with method='refund_reversal' and also exist in the refunds table.",
"fee_amount": "Processor fee for this capture; gross captured = amount + fee_amount.",
"currency": "Payment currency (matches the order's currency).",
"captured_at": "Capture time (UTC).",
"method": "card, paypal, apple_pay, or refund_reversal (negative rows).",
},
),
"refunds": (
"Money refunds (new platform). One row per refund, gross amount, positive sign. "
"Each refund is ALSO mirrored as a negative payments row.",
"id",
{
"id": "Unique refund id.",
"order_id": "FK to orders.id.",
"amount": "Refunded amount, GROSS and positive, in the order's currency.",
"reason": "Free-text refund reason.",
"refunded_at": "Refund time (UTC).",
},
),
"products": (
"Product catalog. One row per SKU.",
"id",
{
"id": "Unique product id.",
"sku": "Stock keeping unit code.",
"name": "Product display name.",
"category": "Hierarchical category as a single string, e.g. 'Camping > Tents'.",
"current_price": "Current list price in USD.",
"cost": "Unit cost in USD.",
"introduced_at": "When the product entered the catalog (UTC).",
"discontinued_at": "Non-NULL = discontinued at this time (UTC).",
},
),
"subscriptions": (
"Driftwood+ gear-rental subscriptions (launched 2024-01). One row per subscription.",
"id",
{
"id": "Unique subscription id.",
"customer_id": "FK to customers.id.",
"plan": "plus_monthly or plus_annual.",
"started_at": "Subscription start (UTC).",
"canceled_at": "Cancellation time (UTC); NULL = still active.",
"monthly_price": "Monthly recurring price in USD (annual plans stored as the "
"monthly-equivalent amount).",
},
),
"subscription_snapshots": (
"Month-end point-in-time snapshots of active subscriptions. mrr_amount is a BALANCE "
"as of snapshot_date — it is not additive across dates.",
None,
{
"snapshot_date": "Snapshot date; always the last day of a calendar month.",
"subscription_id": "FK to subscriptions.id.",
"mrr_amount": "Monthly recurring revenue attributed to this subscription as of the "
"snapshot date (USD). Point-in-time balance; never sum across snapshot dates.",
"status": "Subscription status at snapshot time.",
},
),
"web_events": (
"Website behavioral events. High-volume append-only log.",
"id",
{
"id": "Unique event id.",
"event_time": "Event timestamp as EPOCH MILLISECONDS (BIGINT, UTC).",
"customer_id": "FK to customers.id; NULL for anonymous sessions.",
"event_type": "page_view, add_to_cart, checkout, or search.",
"session_id": "Browser session identifier.",
},
),
"inventory_levels": (
"Month-end inventory snapshots per warehouse and product. units_on_hand is a BALANCE "
"as of snapshot_date — not additive across dates.",
None,
{
"snapshot_date": "Snapshot date; always the last day of a calendar month.",
"warehouse_id": "FK to warehouses.id.",
"product_id": "FK to products.id.",
"units_on_hand": "Units in stock at snapshot time (point-in-time balance).",
},
),
"warehouses": (
"Fulfillment warehouses.",
"id",
{
"id": "Unique warehouse id.",
"name": "Warehouse name.",
"country": "Warehouse country (US or DE).",
"tz": "IANA timezone of the warehouse.",
},
),
"legacy_orders": (
"Orders from the legacy platform (2019-01 to 2023-08, USD only). Orders in the 2023 "
"migration window may ALSO exist as copies in the new orders table (see migrated_at).",
"ord_id",
{
"ord_id": "Unique legacy order id.",
"cust_ref": "Legacy customer key (FK to legacy_customers.cust_ref), e.g. 'C-04217'.",
"ord_dt": "Order timestamp as a NAIVE LOCAL-TIME string ('YYYY-MM-DD HH:MM:SS') in "
"America/Los_Angeles — convert to UTC before comparing with new-platform timestamps.",
"amt_c": "Order amount in integer US CENTS (divide by 100.0 for dollars). USD only.",
"stat": "Numeric status code; decode via legacy_status_codes.",
"ship_dt": "Ship timestamp string; the sentinel '1970-01-01 00:00:00' means never shipped "
"(the legacy system did not use NULL).",
"migrated_at": "Non-NULL when this order was copied into the new platform's orders table "
"during the 2023 migration (the copy carries legacy_ord_id = this ord_id).",
},
),
"legacy_customers": (
"Customers from the legacy platform. Many are the same people as new-platform customers, "
"with email casing/whitespace variants; customer_xref maps only part of them.",
"cust_ref",
{
"cust_ref": "Legacy customer key, e.g. 'C-04217'.",
"email": "Email as entered in the legacy system; casing/whitespace may differ from the "
"same person's new-platform email.",
"signup_dt": "Legacy signup date.",
"region": "Legacy sales region: west, east, or eu.",
},
),
"legacy_status_codes": (
"Lookup for legacy_orders.stat numeric codes. Code 5 ('partial') was historically "
"undocumented.",
"code",
{
"code": "Numeric status code used by legacy_orders.stat.",
"label": "Human-readable status label.",
},
),
"customer_xref": (
"Cross-reference mapping legacy customers to new-platform customers. INCOMPLETE: covers "
"only ~87% of the true overlap; email normalization finds the rest.",
None,
{
"cust_ref": "FK to legacy_customers.cust_ref.",
"customer_id": "FK to customers.id.",
},
),
"fx_rates": (
"Daily FX rates to USD, BUSINESS DAYS ONLY (no weekend rows) — for a weekend date use "
"the latest prior business day.",
None,
{
"date": "Rate date (weekdays only).",
"currency": "Quoted currency: EUR or GBP.",
"usd_rate": "USD per 1 unit of the quoted currency.",
},
),
"fiscal_calendar": (
"Company fiscal calendar. The fiscal year starts Feb 1 and is labeled by its starting "
"year (FY2024 = 2024-02-01 through 2025-01-31).",
"date",
{
"date": "Calendar date.",
"fiscal_year": "Fiscal year label (year in which the FY starts).",
"fiscal_quarter": "Fiscal quarter label, e.g. 'FY2024-Q3' (Q1 = Feb-Apr).",
},
),
"returns": (
"Merchandise returns (RMA) at the order-line level. Distinct from refunds: returns track "
"physical items coming back; refunds track money.",
"rma_id",
{
"rma_id": "Unique return authorization id.",
"order_item_id": "FK to order_items.id.",
"qty_returned": "Units physically returned on this RMA.",
"received_at": "When the returned items were received (UTC).",
"disposition": "restock, damaged, or disposed.",
},
),
}
def main() -> None:
cols: dict[str, list[tuple[str, str]]] = {}
with open(HERE / "schema_dump.csv", newline="") as f:
for row in csv.DictReader(f):
cols.setdefault(row["table_name"], []).append((row["column_name"], row["data_type"]))
if set(cols) != set(DESC):
sys.exit(f"table mismatch: db-only={set(cols) - set(DESC)} desc-only={set(DESC) - set(cols)}")
def q(s: str) -> str:
return '"' + s.replace('"', '\\"') + '"'
for table, columns in cols.items():
table_desc, pk, col_desc = DESC[table]
db_cols = {c for c, _ in columns}
if db_cols != set(col_desc):
sys.exit(
f"{table}: column mismatch: db-only={db_cols - set(col_desc)} "
f"desc-only={set(col_desc) - db_cols}"
)
lines = [
f"name: {table}",
"properties:",
f" description: {q(table_desc)}",
"table_reference:",
" catalog: driftwood",
" schema: main",
f" table: {table}",
]
if pk:
lines.append(f"primary_key: {pk}")
lines.append("columns:")
for cname, ctype in columns:
lines += [
f" - name: {cname}",
f" type: {q(ctype) if '(' in ctype else ctype}",
" properties:",
f" description: {q(col_desc[cname])}",
]
out = HERE / "models" / table / "metadata.yml"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines) + "\n")
print(f"wrote {out.relative_to(HERE)} ({len(columns)} cols)")
if __name__ == "__main__":
main()
@@ -0,0 +1,110 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["duckdb", "pyyaml", "pytest"]
# ///
"""Tests for the pinned Driftwood fixture lock and local cache helper."""
from __future__ import annotations
import hashlib
import importlib.util
import json
from pathlib import Path
import sys
import pytest
HERE = Path(__file__).resolve().parent
def load_module(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
fixture = load_module("driftwood_fixture", HERE / "fixture.py")
generate = load_module("driftwood_generate", HERE / "generate.py")
def test_committed_lock_matches_base_identity():
lock = fixture.load_lock(HERE / "fixture.lock.json")
assert lock["id"] == generate.BASE_FIXTURE_ID
assert lock["generator"]["base_fixture_version"] == generate.BASE_FIXTURE_VERSION
assert lock["generator"]["seed"] == generate.SEED
assert lock["generator"]["reference_date"] == generate.TODAY.isoformat()
assert lock["expectations"]["subscription_snapshots_max_date"] == generate.TODAY.isoformat()
def fake_lock(payload: bytes) -> dict:
return {
"format_version": 1,
"id": "test-fixture-v1",
"repository": "example/test",
"release_tag": "fixtures-v1",
"asset_name": "fixture.bin",
"sha256": hashlib.sha256(payload).hexdigest(),
"size_bytes": len(payload),
"generator": {},
"expectations": {},
}
def test_fetch_downloads_once_then_reuses_content_addressed_cache(tmp_path, monkeypatch):
payload = b"deterministic fixture payload"
lock = fake_lock(payload)
calls = []
def download(_lock, destination):
calls.append(destination)
destination.write_bytes(payload)
monkeypatch.setattr(fixture, "_download_release_asset", download)
first, first_hit = fixture.fetch(lock, tmp_path)
second, second_hit = fixture.fetch(lock, tmp_path)
assert first == second
assert first.read_bytes() == payload
assert first_hit is False
assert second_hit is True
assert len(calls) == 1
def test_corrupt_cache_is_replaced_and_never_accepted(tmp_path, monkeypatch):
payload = b"correct"
lock = fake_lock(payload)
target = fixture.cached_path(lock, tmp_path)
target.parent.mkdir(parents=True)
target.write_bytes(b"corrupt")
monkeypatch.setattr(
fixture,
"_download_release_asset",
lambda _lock, destination: destination.write_bytes(payload),
)
path, hit = fixture.fetch(lock, tmp_path)
assert hit is False
assert path.read_bytes() == payload
fixture.verify_file(path, lock)
def test_bad_lock_and_bad_file_fail_loudly(tmp_path):
bad_lock = tmp_path / "bad.json"
bad_lock.write_text(json.dumps({"format_version": 1}))
with pytest.raises(fixture.FixtureError, match="missing fields"):
fixture.load_lock(bad_lock)
payload = b"expected"
lock = fake_lock(payload)
wrong = tmp_path / "wrong.bin"
wrong.write_bytes(b"wrong")
with pytest.raises(fixture.FixtureError, match="size mismatch"):
fixture.verify_file(wrong, lock)
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"] + sys.argv[1:]))
@@ -0,0 +1,198 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["duckdb", "pyyaml", "pytest"]
# ///
"""
pytest companion to `generate.py --verify`. Same 5 checks, as importable
assertions rather than a print-and-exit CLI. Run directly (this script
carries its own PEP 723 header, same convention as generate.py):
uv run test_generate.py
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import random
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent))
import generate as gen # noqa: E402
SEED = gen.SEED
def test_clean_generation_is_reproducible():
d1 = gen.generate(random.Random(SEED))
d2 = gen.generate(random.Random(SEED))
assert d1.keys() == d2.keys()
for table in d1:
assert d1[table] == d2[table], f"table {table} differs across two fresh Random(SEED) runs"
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
def test_injection_is_reproducible(scenario):
base1 = gen.generate(random.Random(SEED))
base2 = gen.generate(random.Random(SEED))
mutated1, inj1 = gen.SCENARIOS[scenario](base1)
mutated2, inj2 = gen.SCENARIOS[scenario](base2)
assert mutated1.keys() == mutated2.keys()
for table in mutated1:
assert mutated1[table] == mutated2[table], f"table {table} differs across two independent {scenario} runs"
assert gen._plain(inj1) == gen._plain(inj2)
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
def test_diff_matches_manifest(scenario):
ok, detail = gen._check_diff_matches_manifest(scenario)
assert ok, detail
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
def test_manifest_well_formed(scenario):
base = gen.generate(random.Random(SEED))
_, injections = gen.SCENARIOS[scenario](base)
manifest = gen.build_manifest(scenario, "driftwood.duckdb", f"driftwood-{scenario}.duckdb", injections)
ok, detail = gen._check_manifest_well_formed(manifest)
assert ok, detail
def test_oracle_sanity_stopped_updates():
ok, detail = gen._check_oracle_sanity()
assert ok, detail
def test_oracle_hand_computed_numbers():
"""
Hand-computed expectations (stopped_updates manifest: 1 real injection,
3 clean entities checked — orders, web_events, fx_rates):
perfect = flags the real one correctly, calls all 3 clean ones clean
-> recall 1/1, precision 1/1, false_alarm 0/3, attribution 1/1
imperfect = misses the real one, false-alarms on 1 of the 3 clean ones
-> recall 0/1, precision 0/1, false_alarm 1/3, attribution 0/0 -> 0.0
"""
base = gen.generate(random.Random(SEED))
_, injections = gen.SCENARIOS["stopped_updates"](base)
manifest = gen.build_manifest(
"stopped_updates", "driftwood.duckdb", "driftwood-stopped_updates.duckdb", injections
)
sets = gen._hand_authored_detection_sets(manifest)
perfect = gen.score_detections(manifest, sets["perfect"])
assert perfect["recall"] == 1.0
assert perfect["precision"] == 1.0
assert perfect["false_alarm_rate"] == 0.0
assert perfect["attribution_accuracy"] == 1.0
imperfect = gen.score_detections(manifest, sets["imperfect"])
assert imperfect["recall"] == 0.0
assert imperfect["precision"] == 0.0
assert abs(imperfect["false_alarm_rate"] - (1 / 3)) < 1e-9
assert imperfect["attribution_accuracy"] == 0.0
def test_attribution_paraphrase_all_scenarios():
ok, detail = gen._check_attribution_paraphrase()
assert ok, detail
@pytest.mark.parametrize("scenario", gen.SCENARIO_NAMES)
def test_attribution_scores_per_injection(scenario):
"""Per-injection attribution scores for every scenario (not just
stopped_updates): the injection's own expected_cause must self-match at
1.0, a realistic paraphrase (never the manifest's verbatim sentence,
no ids/dates) must clear the 0.5 threshold, and an unrelated cause must
fall below it — proving the fuzzy match works for real paraphrasing
rather than only for a verbatim quote of the manifest."""
base = gen.generate(random.Random(SEED))
_, injections = gen.SCENARIOS[scenario](base)
assert injections, f"{scenario} produced no injections"
for inj in injections:
verbatim_score = gen._attribution_score(inj["expected_cause"], inj)
assert verbatim_score == 1.0, f"{inj['id']}: verbatim scored {verbatim_score}"
paraphrase = gen.PARAPHRASE_CAUSES[inj["id"]]
paraphrase_score = gen._attribution_score(paraphrase, inj)
assert paraphrase_score >= 0.5, f"{inj['id']}: paraphrase scored {paraphrase_score}"
unrelated_score = gen._attribution_score(gen.UNRELATED_CAUSE, inj)
assert unrelated_score < 0.5, f"{inj['id']}: unrelated cause scored {unrelated_score}"
def test_manifest_serializes_the_canonical_attribution_oracle():
injection = {
"id": "stopped_updates_subscription_snapshots",
"kind": "stopped_updates",
"entity": "subscription_snapshots",
}
manifest = gen.build_manifest(
"stopped_updates", "driftwood.duckdb", "driftwood-stopped_updates.duckdb", [injection]
)
serialized = manifest["injections"][0]
assert serialized["attribution_keywords"] == ["snapshots", "stopped", "subscription"]
assert gen._attribution_score("stopped updates", serialized) == pytest.approx(1 / 3)
assert not gen._cause_matches("stopped updates", serialized)
def test_score_detections_rejects_duplicate_entities():
base = gen.generate(random.Random(SEED))
_, injections = gen.SCENARIOS["stopped_updates"](base)
manifest = gen.build_manifest(
"stopped_updates", "driftwood.duckdb", "driftwood-stopped_updates.duckdb", injections
)
dup_detections = [
{"entity": "orders", "verdict": "no_anomaly", "cause": ""},
{"entity": "orders", "verdict": "anomaly", "cause": "duplicate row"},
]
with pytest.raises(ValueError):
gen.score_detections(manifest, dup_detections)
def test_stopped_updates_can_inject_from_a_clean_duckdb(tmp_path):
base = tmp_path / "clean.duckdb"
injected = tmp_path / "injected.duckdb"
rows = [
(date(2026, 3, 31), 1, Decimal("10.00"), "active"),
(date(2026, 4, 30), 1, Decimal("10.00"), "active"),
(date(2026, 5, 31), 1, Decimal("10.00"), "active"),
(date(2026, 6, 30), 1, Decimal("10.00"), "active"),
]
con = gen.duckdb.connect(str(base))
con.execute(
"CREATE TABLE subscription_snapshots "
"(snapshot_date DATE, subscription_id INTEGER, mrr_amount DECIMAL(12,2), status VARCHAR)"
)
con.executemany(gen.INSERT_SQL["subscription_snapshots"], rows)
con.close()
gen.run_injection_from_base("stopped_updates", str(base), str(injected))
clean_con = gen.duckdb.connect(str(base), read_only=True)
injected_con = gen.duckdb.connect(str(injected), read_only=True)
assert clean_con.execute("SELECT max(snapshot_date) FROM subscription_snapshots").fetchone()[0] == gen.TODAY
assert injected_con.execute("SELECT max(snapshot_date) FROM subscription_snapshots").fetchone()[0] == date(2026, 3, 31)
clean_con.close()
injected_con.close()
manifest = gen.yaml.safe_load(injected.with_suffix(".manifest.yaml").read_text())
assert manifest["base"] == "clean.duckdb"
assert manifest["scenario"] == "stopped_updates"
assert manifest["injections"][0]["expected_severity"] == "critical"
def test_injection_from_base_refuses_to_overwrite_the_fixture(tmp_path):
base = tmp_path / "clean.duckdb"
base.write_bytes(b"not opened because paths are rejected first")
with pytest.raises(ValueError, match="different files"):
gen.run_injection_from_base("stopped_updates", str(base), str(base))
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"] + sys.argv[1:]))
@@ -0,0 +1,7 @@
schema_version: 5
name: driftwood
version: '1.0'
catalog: wren
schema: public
data_source: duckdb
profile: driftwood
@@ -0,0 +1,14 @@
# Jaffle Wren eval fixture
This directory is the authored Wren project used by the accuracy eval. Its raw models, views,
relationships, cubes, instructions, DuckDB fixture, and `wren_project.yml` are checked in.
`target/mdl.json` is generated by `wren context build` and is not committed.
`wren_project.yml` is authored project metadata. It deliberately does not name a Wren CLI connection
`profile`: named profiles live in each user's `~/.wren/profiles.yml` and are not portable project
metadata. Use `eval/build-jaffle-context.sh` to compile this fixture; the helper builds a temporary
copy so any CLI normalization stays outside the tracked project.
The manifest's `catalog: wren` and `schema: public` identify this MDL project inside Wren Engine.
They are not the DuckDB database's catalog or schema. Database catalog and schema values belong in
each model's `table_reference`.
@@ -0,0 +1,15 @@
# Declared analytical cube over the orders mart. Added in Phase 2 so the semantic layer carries
# real, named metrics whose additivity is *decidable* — which is what lets `metric_additive` be
# evaluated at compile time (a declared additive measure → pass; a non-additive one → fail) rather
# than being unanswerable, as it is on a cube-less project.
- name: revenue
base_object: orders
measures:
# Additive: a sum-of-parts equals the whole → safe to decompose along dimensions.
- { name: total_revenue, expression: "SUM(amount)", type: DOUBLE }
# Non-additive: the average of parts is not the average of the whole → decomposition misleads.
- { name: avg_order_value, expression: "AVG(amount)", type: DOUBLE }
dimensions:
- { name: status, expression: "status", type: TEXT }
time_dimensions:
- { name: order_date, expression: "order_date", type: DATE }
@@ -0,0 +1,3 @@
# User Instructions
Add custom rules or guidelines for LLM-based query generation here.
@@ -0,0 +1,38 @@
name: customers
properties:
description: "Final customers mart. One row per customer, enriched with order history aggregates (first order date, most recent order date, total order count, lifetime value)."
table_reference:
catalog: jaffle_shop
schema: main
table: customers
primary_key: customer_id
columns:
- name: customer_id
type: INT
not_null: true
properties:
description: "Unique customer identifier."
- name: first_name
type: TEXT
properties:
description: "Customer's first name."
- name: last_name
type: TEXT
properties:
description: "Customer's last name."
- name: first_order
type: DATE
properties:
description: "Date of the customer's first order."
- name: most_recent_order
type: DATE
properties:
description: "Date of the customer's most recent order."
- name: number_of_orders
type: BIGINT
properties:
description: "Total number of orders placed by this customer."
- name: customer_lifetime_value
type: DOUBLE
properties:
description: "Sum of all completed order amounts for this customer."
@@ -0,0 +1,22 @@
# Example model — replace with your actual table
name: example
# table_reference points to the ACTUAL database table location
table_reference:
catalog: "" # your database catalog (empty if N/A)
schema: public # your database schema
table: example # your database table name
columns:
- name: id
type: INTEGER
is_calculated: false
not_null: true
is_primary_key: true
properties: {}
- name: name
type: VARCHAR
is_calculated: false
not_null: false
properties: {}
primary_key: id
cached: false
properties: {}
@@ -0,0 +1,46 @@
name: orders
properties:
description: "Final orders mart. One row per order, with payment amounts broken out by method (credit card, coupon, bank transfer, gift card) plus the total order amount."
table_reference:
catalog: jaffle_shop
schema: main
table: orders
primary_key: order_id
columns:
- name: order_id
type: INT
not_null: true
properties:
description: "Unique order identifier."
- name: customer_id
type: INT
properties:
description: "Foreign key to customers.customer_id."
- name: order_date
type: DATE
properties:
description: "Date the order was placed."
- name: status
type: TEXT
properties:
description: "Order status: placed, shipped, completed, return_pending, or returned."
- name: credit_card_amount
type: DOUBLE
properties:
description: "Amount paid by credit card for this order."
- name: coupon_amount
type: DOUBLE
properties:
description: "Amount paid with coupons for this order."
- name: bank_transfer_amount
type: DOUBLE
properties:
description: "Amount paid by bank transfer for this order."
- name: gift_card_amount
type: DOUBLE
properties:
description: "Amount paid with gift cards for this order."
- name: amount
type: DOUBLE
properties:
description: "Total order amount (sum of all payment methods)."
@@ -0,0 +1,22 @@
name: raw_customers
properties:
description: "Raw source customer records as loaded from the source system. Use the customers mart model for analytics."
table_reference:
catalog: jaffle_shop
schema: main
table: raw_customers
primary_key: id
columns:
- name: id
type: INT
not_null: true
properties:
description: "Source system customer ID."
- name: first_name
type: TEXT
properties:
description: "Customer's first name."
- name: last_name
type: TEXT
properties:
description: "Customer's last name."
@@ -0,0 +1,26 @@
name: raw_orders
properties:
description: "Raw source order records as loaded from the source system. Use the orders mart model for analytics."
table_reference:
catalog: jaffle_shop
schema: main
table: raw_orders
primary_key: id
columns:
- name: id
type: INT
not_null: true
properties:
description: "Source system order ID."
- name: user_id
type: INT
properties:
description: "Foreign key to raw_customers.id."
- name: order_date
type: DATE
properties:
description: "Date the order was placed."
- name: status
type: TEXT
properties:
description: "Order status from source system."
@@ -0,0 +1,26 @@
name: raw_payments
properties:
description: "Raw source payment records. Each row is a single payment transaction; one order can have multiple payments via different methods."
table_reference:
catalog: jaffle_shop
schema: main
table: raw_payments
primary_key: id
columns:
- name: id
type: INT
not_null: true
properties:
description: "Unique payment transaction ID."
- name: order_id
type: INT
properties:
description: "Foreign key to raw_orders.id."
- name: payment_method
type: TEXT
properties:
description: "Payment method used: credit_card, coupon, bank_transfer, or gift_card."
- name: amount
type: INT
properties:
description: "Payment amount in cents."
@@ -0,0 +1,21 @@
relationships:
- name: orders_customers
models:
- orders
- customers
join_type: many_to_one
condition: "orders.customer_id = customers.customer_id"
- name: raw_orders_raw_customers
models:
- raw_orders
- raw_customers
join_type: many_to_one
condition: "raw_orders.user_id = raw_customers.id"
- name: raw_payments_raw_orders
models:
- raw_payments
- raw_orders
join_type: many_to_one
condition: "raw_payments.order_id = raw_orders.id"
@@ -0,0 +1,4 @@
# Example view — replace with your actual view
name: example_view
description: "An example view"
properties: {}
@@ -0,0 +1,2 @@
statement: >
SELECT * FROM example LIMIT 100
@@ -0,0 +1,11 @@
schema_version: 2
name: jaffle_shop
version: "1.0"
# Wren Engine namespace (NOT your database's catalog/schema).
# These identify this MDL project within the engine.
# Your database's actual catalog/schema goes in each model's table_reference.
catalog: wren
schema: public
data_source: duckdb
@@ -0,0 +1,111 @@
//! Integration tests over the real `examples/jaffle-wren` project. It declares a `revenue` cube
//! (Phase 2), so `metric_additive` is answerable — `total_revenue` (SUM) is additive and
//! `avg_order_value` (AVG) is not. Existence predicates also hold via plain model columns. The
//! cube-*less* case (where `metric_additive` is unanswerable) is covered by a synthetic manifest in
//! the crate's unit tests.
use std::path::Path;
use warble::{Additivity, ContextLoader, Severity};
use wren_context_loader::{read_project_dir, MdlContext};
fn jaffle_wren() -> MdlContext {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/jaffle-wren");
let sources = read_project_dir(&dir)
.expect("read jaffle-wren")
.expect("jaffle-wren is a wren project");
MdlContext::try_from_sources(&sources).expect("jaffle-wren assembles into a valid manifest")
}
#[test]
fn parses_and_projects_models() {
let ctx = jaffle_wren();
assert!(ctx.is_parseable());
// Every jaffle model is projected.
let model_names: Vec<&str> = ctx.models().iter().map(|m| m.name.as_str()).collect();
for expected in [
"customers",
"orders",
"raw_customers",
"raw_orders",
"raw_payments",
] {
assert!(
model_names.contains(&expected),
"missing model {expected}: {model_names:?}"
);
}
// orders has a DATE column (order_date) → has_timestamp.
let orders = ctx.model("orders").expect("orders model");
assert!(orders.has_timestamp, "orders has a DATE column");
}
#[test]
fn existence_predicates_hold_via_columns() {
let ctx = jaffle_wren();
// Numeric columns (amount, customer_lifetime_value, …) → has_metric holds even with no cube.
assert!(
!ctx.metrics().is_empty(),
"numeric columns are queryable metrics"
);
// Textual columns (status, first_name, …) → groupable dimensions.
assert!(!ctx.dimensions().is_empty());
// DATE columns (order_date, first_order, …) → time dimensions.
assert!(!ctx.time_dimensions().is_empty());
}
#[test]
fn metric_additive_answerable_via_revenue_cube() {
let ctx = jaffle_wren();
// The revenue cube declares measures ⇒ additivity is expressible ⇒ answerable.
assert!(
ctx.can_answer("metric_additive"),
"the revenue cube makes metric_additive answerable"
);
// Additivity is inferred from each measure's aggregation.
assert_eq!(
ctx.metric_additivity("total_revenue"),
Some(Additivity::Additive),
"total_revenue = SUM(amount) is additive"
);
assert_eq!(
ctx.metric_additivity("avg_order_value"),
Some(Additivity::NonAdditive),
"avg_order_value = AVG(amount) is non-additive"
);
// Both declared (cube) and implicit (column) metrics are present.
assert!(ctx.metrics().iter().any(|m| m.declared));
assert!(ctx.metrics().iter().any(|m| !m.declared));
}
#[test]
fn lineage_is_resolvable() {
let ctx = jaffle_wren();
let lineage = ctx.lineage();
assert!(
lineage.is_resolvable(),
"no dangling references in jaffle-wren lineage"
);
// Relationships connect real models: orders → rel:orders_customers is an edge.
assert!(lineage.contains("model:orders"));
assert!(lineage.contains("rel:orders_customers"));
assert!(lineage
.edges
.iter()
.any(|e| e.from == "model:orders" && e.to == "rel:orders_customers"));
}
#[test]
fn blast_radius_of_orders_reaches_the_revenue_cube_and_is_semantic() {
let ctx = jaffle_wren();
let radius = ctx.lineage().blast_radius("model:orders");
// The revenue cube sits on orders, so changing orders reaches the cube + its measures.
assert!(radius.downstream.contains(&"cube:revenue".to_string()));
assert!(radius
.downstream
.contains(&"metric:revenue.total_revenue".to_string()));
// A downstream metric ⇒ the worst impact is a silent number shift ⇒ semantic.
assert_eq!(radius.severity, Severity::Semantic);
}
@@ -0,0 +1,31 @@
#![cfg(not(target_arch = "wasm32"))]
use std::fs;
use wren_context_loader::read_knowledge_rules;
#[test]
fn knowledge_rules_are_sorted_trimmed_and_followed_by_legacy_content() {
let project = tempfile::tempdir().unwrap();
let rules = project.path().join("knowledge/rules");
fs::create_dir_all(&rules).unwrap();
fs::write(rules.join("z-last.md"), "\n# Z\r\n\r\nlast\n").unwrap();
fs::write(rules.join("a-first.md"), "# A\n\nfirst\n").unwrap();
fs::write(rules.join("empty.md"), " \n").unwrap();
fs::write(project.path().join("instructions.md"), "\n# Legacy\n").unwrap();
let loaded = read_knowledge_rules(project.path()).unwrap();
assert_eq!(
loaded.content,
"# A\n\nfirst\n\n# Z\r\n\r\nlast\n\n# Legacy"
);
assert!(loaded.used_legacy);
}
#[test]
fn missing_rules_are_an_explicit_empty_payload() {
let project = tempfile::tempdir().unwrap();
let loaded = read_knowledge_rules(project.path()).unwrap();
assert_eq!(loaded.content, "");
assert!(!loaded.used_legacy);
}