learn-ship-ecommerce-enabler-with-phoebe / Analyst session 7 of 8
Ship It: Social Commerce Enablers · Analyst session 7 of 8

The metric registry as code

Take one real quarter of Olist orders and ask for its GMV. You get R$ 2,858,289.74, or R$ 3,332,156.97, or R$ 2,807,156.64, and every one of them is right. The problem is never the arithmetic; it is that a dashboard, a board pack and a brand statement each quietly picked a different one and called it the same word. Tonight you write the registry that stops that: one YAML entry per metric name, one Python reader that everything imports, and three tests that fail the moment somebody changes a definition without saying so.

🟠 Advanced Analysts and analytics engineers Python, YAML 45 min
0-3 · Welcome 3-19 · Three GMVs, the entry, the tests 19-41 · Build-along: the registry on Olist 41-45 · Q&A
Part 0

Where the registry sits on the track

Session 1 wrote an estate registry: every table, its domain, its grain, its owner. Tonight extends the same idea one layer up, from tables to the numbers people quote. Sessions 2 to 6 built the inputs (conformed orders, the identity gate, the live event spec, stage metrics, creator yield); every one of them ends in a number somebody will put on a slide. The metric registry is where that number gets a single name, a single formula and a history. The data is the real Olist file, Q2 2018; every value printed below is what the code printed on it.

🎫 Clear all three quiz questions to stamp your passport Live = presented in the 45 minutes Self-study = the full depth, after class ★ Build-along = run the code yourself

★ What you walk out with tonight. A metrics.yaml with three GMV entries and two active-seller entries, each answering the three questions a serious reader asks of a key metric; a registry.py that is the only thing allowed to read it; and a test_registry.py that passes on the approved definitions and fails, loudly, when one line of SQL changes without a version bump.

Part 1 · covers GMV variants on one real quarter

Three GMVs from one real quarter

6 min live

Q2 2018 on Olist, April to June, order lines by purchase date: 19,947 orders. Filter the same rows three ways and you get three totals, each with a sound argument behind it. The registry does not choose the "true" one. It gives each one its own name, so that nobody can ever again say "GMV" and mean two of them.

ONE QUARTER OF ORDER LINES, FILTERED THREE WAYS the filter the total, and the name the registry gives it Q2 2018 order lines Apr to Jun, by purchase date 19,947 orders delivered 19,646 · shipped 196 canceled 50 · invoiced 41 processing 14 real rows, Olist any status item price only any status item price + shipping delivered only item price only R$ 2,858,289.74 gmv_placed R$ 3,332,156.97 gmv_placed_with_shipping R$ 2,807,156.64 gmv_delivered Same rows, same quarter. The spread is R$ 525,000, and only a name says which total a report means.
🔍 Click to zoom - the middle total is the marketplace-headline style; the bottom one is closest to money that settles
LiveFive numbers, one quarter, all defensible4 min▶
DefinitionQ2 2018 on OlistThe argument for it
Placed, any status, items onlyR$ 2,858,289.74The demand the stores generated; what the trading team steers
Placed, any status, items + shippingR$ 3,332,156.97What the buyer was charged; matches a marketplace headline that counts shipping
Delivered only, items onlyR$ 2,807,156.64Closest to the money that settles; what a GMV-linked fee should rest on
Delivered only, items + shippingR$ 3,273,631.74Settled-ish, buyer-side view
Payments table, same ordersR$ 3,332,386.55What was actually collected; differs from items + shipping by R$ 229.58

Cancelled or unavailable orders in the quarter add up to only R$ 8,559.48 across 50 orders, so on this file the status filter moves the total far less than the shipping choice does. On an enabler's own estate, with returns and failed deliveries, the order flips. Either way, the size of the gap is not the point; the point is that a board pack and a brand statement built on two of these rows will disagree, and nobody will know why until somebody opens both queries.

Self-studyThe same spread, on Serai's constructed quarter3 min▶

The leader track uses one constructed Serai quarter to make the same point with fees on top. GMV placed including shipping $48.6M, placed excluding shipping $45.0M, settled net of cancellations and returns $39.2M. Divide the same $1,764,000 of retainers and GMV share by each and the take rate reads 3.63, 3.92 or 4.50 percent; drop the retainers and it is 2.00 percent. Every figure there is constructed, and your numbers will differ. The Olist quarter above is the real-rows version: the same spread, measured.

The definitions themselves (GMV, take rate, what counts as an order) are taught properly in the GMV equation session of the ecommerce metrics track. The enabler twist is that an enabler reports someone else's GMV, often on three marketplaces with three conventions, and bills against it.

Real world

A listed marketplace's GMV counts shipping and unsettled orders. Sea Limited's annual report for fiscal 2019 defines Shopee's GMV as the value of orders on the marketplace and says the calculation "includes shipping and other charges", and it counts each confirmed order regardless of whether the transaction is settled or the item returned. That is a clear, written choice, and it is the middle row of the table. An enabler whose own GMV is delivered-only will always look smaller than the marketplace's figure for the same stores, and that is fine, as long as both definitions are written down.

Part 2 · covers the registry entry as code

A registry entry is code

6 min live

A metric definition in a wiki page is a hope. The same definition as a YAML entry, read by the code that computes the number, is a contract: change the entry and every number built on it changes with it, in one commit, with a name and a date. Ten fields are enough, and each one has a reader.

ONE REGISTRY ENTRY, AND WHO READS EACH FIELD gmv_placed: descriptionitem value, any status, no shipping why_usefulthe demand the stores generated how_usedweekly review; base of the GMV fee grainorder line, by purchase date sqlSUM(price), items joined to orders sourcesorders.purchase_ts, items.price ownerfinance analytics version1 valid_from2018-01-01 change_logv1 2018-01-01 first definition the three questions computed, at the grain paged when a test fails drift and restatement Board pack what it is, why it helps, how it is used Dashboard tile runs the sql, never its own copy CI test fails on unversioned drift Every field has a reader. A field nobody reads is decoration; a reader with no field is a guess.
🔍 Click to zoom - the test is teal because it is the only reader that says no
LiveThe ten fields, and the three that answer the reader4 min▶
FieldWhat goes in itWho reads it
description + sql"A clear definition of the metric and how it is calculated"Board pack, every analyst
why_useful"The reasons why the metric provides useful information"Board pack, brand statements
how_used"How management uses the metric in managing or monitoring the performance of the business"Board pack, the owner
grainThe row the SQL sums over, and the date it keys onDashboard, anyone joining it
sourcesTable and column for every input: the lineageData engineering, when a source changes
ownerOne team, not a person's nameWhoever gets paged when a test fails
version, valid_from, change_logThe history: what changed, when, and whether prior periods were restatedThe CI test, auditors, next year's you

The quoted wording in the first three rows is the SEC's, below. Put the three questions in the schema as required fields and the reader loads nothing that cannot answer them.

Self-studyOne reader, and the spec sheet it replaces3 min▶

The YAML is half of it. The other half is that exactly one module reads it. If the dashboard parses metrics.yaml with its own code and the board pack script with another, you have two readers and, the first time somebody adds a field, two behaviours. registry.py below is thirty lines, and every consumer imports it: load() refuses an entry with a missing field, compute() runs the entry's own SQL. Nobody writes SUM(price) anywhere else.

How to write a good definition in the first place (the one-page spec, the edge cases, the sign-off) is taught in the metric spec sheet session of the ecommerce metrics track, and governance around it in its metric governance session. The registry is the spec sheet made executable: same content, but now a machine checks it on every change.

Real world

A public checklist for a key metric. The US SEC's 2020 guidance on key performance indicators in MD&A says the Commission would generally expect, alongside such a metric, a clear definition and how it is calculated, the reasons it provides useful information, and how management uses it. If the calculation changes, it says to consider disclosing the differences, the reasons and the effects on amounts previously reported. Read it here as what it is: a public example of the three questions any serious reader asks of GMV or active sellers, and a good schema for three required fields. What any exchange requires of a listing is a question for counsel, not for a YAML file.

Part 3 · covers tests on metric definitions

Tests that fail when a definition drifts

4 min live

Definitions drift for good reasons. Somebody decides cancelled orders should not count, adds one line to the SQL, and the quarter's GMV moves by R$ 8,559.48 without a meeting. The registry cannot stop the decision, and should not. It can refuse to let it happen silently. Three tests do it.

THE CI GATE ON THE REGISTRY pull request edits metrics.yaml golden numbers gmv_placed Q2 2018 = 2,858,289.74 reconciliation payments vs items + shipping: 229.58 definition change new sql needs v+1 and a restatement the gate all pass? yes merge dashboards read v2 blocked revert, or version no The gate does not judge the new definition. It refuses a silent one.
🔍 Click to zoom - a definition change is allowed; an unannounced one is the only thing blocked
LiveThree tests, three different failures3 min▶

Golden numbers. Freeze one past period and write down the approved value of each metric: gmv_placed for Q2 2018 is R$ 2,858,289.74, active_sellers_1000 is 552. The data for a closed quarter does not change, so if the computed value moves, the definition did. This is the test that catches the one-line SQL edit.

Reconciliation. Two independent routes to roughly the same money should agree within a tolerance you write down in advance. Items plus shipping for the quarter is R$ 3,332,156.97; the payments table for the same orders is R$ 3,332,386.55; the gap is R$ 229.58. The tolerance in the build-along is 0.01 percent of payments, R$ 333.24. When a join starts fanning out or a table is loaded twice, this test fails first.

Definition change. Store a fingerprint of each approved SQL next to its version. If the SQL changes and the version does not, or the change log has no entry that says prior periods were restated, fail. This is the test that turns "somebody tidied the query" into a reviewed decision.

Self-studyWhat a restatement is, in practice2 min▶

When gmv_placed moves to v2, three things happen in the same commit: the version goes up, the change log says what changed and why, and every prior period that anyone will compare against is recomputed on v2 and the golden value updated. Last year's board pack is not edited; next year's shows both, with a line saying which definition each column uses. A trend that crosses a definition change without restating is the most common way a chart lies with every number on it correct.

Real world

Constructed, and common: two take rates in one meeting. On Serai's constructed quarter, the finance deck divides fees by GMV placed excluding shipping and shows 3.92 percent; the brand team's deck divides by settled GMV and shows 4.50 percent. Both queries are correct and both are called "take rate". With a registry, the two numbers have two names, take_rate_placed and take_rate_settled, and the board sees one of them with its definition printed under it. Every figure in this story is constructed; your numbers will differ.

Build-along

Write the registry and test it on Olist

★ 22 min · everyone builds

Five entries, one reader, three tests, then one deliberate drift to watch the gate close. The code below ran on the full Olist file with Python 3, pandas 2.1 and PyYAML 6.0; every output line is pasted exactly as it printed. If you do not have PyYAML, save the registry as metrics.json and swap yaml.safe_load for json.load: the reader and the tests do not change.

REGISTRY, READER, METRICS, TESTS metrics.yaml 5 entries, 10 fields registry.py the one reader five numbers Q2 2018, via SQL test_registry.py golden, recon, version ALL PASS drift: 2 FAILED Olist CSVs 3 tables, real rows metrics.lock.json approved sql hashes Change one line of YAML and every number, and every test, moves with it. That is the point.
🔍 Click to zoom - the reader is teal because nothing else is allowed to parse the YAML

Point at the data and check the one dependency. Work in a scratch folder, not inside a repo. The CSVs are the kagglehub download from session 1; the path below is where kagglehub caches them.

★ Step 1 · setuppip install pyyaml pandas python3 -c "import yaml, pandas; print(yaml.__version__, pandas.__version__)" # 6.0.3 2.1.4 ls ~/.cache/kagglehub/datasets/olistbr/brazilian-ecommerce/versions/2/ # olist_orders_dataset.csv, olist_order_items_dataset.csv, olist_order_payments_dataset.csv, ...

Write the registry: three GMVs, two active-seller counts. Each entry answers the three reader questions (description, why_useful, how_used), carries its SQL, its grain, its lineage and its history. The dates are half-open, >= start and < end, so a quarter never double-counts its last second.

★ Step 2 · metrics.yaml# metrics.yaml - one entry per name, read by everything gmv_placed: description: Item value of every order placed in the period, any status, excluding shipping why_useful: The demand the stores generated, before cancellations and fulfilment how_used: Weekly trading review; the base of the GMV-linked fee grain: order line, by order purchase date sql: | SELECT SUM(i.price) FROM items i JOIN orders o USING (order_id) WHERE o.order_purchase_timestamp >= :start AND o.order_purchase_timestamp < :end sources: [olist_orders_dataset.order_purchase_timestamp, olist_order_items_dataset.price] owner: finance analytics version: 1 valid_from: 2018-01-01 change_log: - {version: 1, date: 2018-01-01, change: first definition} gmv_placed_with_shipping: description: Item value plus shipping of every order placed, any status why_useful: Comparable with a marketplace headline GMV that includes shipping how_used: Board pack, next to the marketplace's own figure grain: order line, by order purchase date sql: | SELECT SUM(i.price + i.freight_value) FROM items i JOIN orders o USING (order_id) WHERE o.order_purchase_timestamp >= :start AND o.order_purchase_timestamp < :end sources: [olist_orders_dataset.order_purchase_timestamp, olist_order_items_dataset.price, olist_order_items_dataset.freight_value] owner: finance analytics version: 1 valid_from: 2018-01-01 change_log: - {version: 1, date: 2018-01-01, change: first definition} gmv_delivered: description: Item value of orders placed in the period and delivered, excluding shipping why_useful: Closest to the money that actually settles how_used: Brand statements; the settled-GMV share grain: order line, by order purchase date sql: | SELECT SUM(i.price) FROM items i JOIN orders o USING (order_id) WHERE o.order_purchase_timestamp >= :start AND o.order_purchase_timestamp < :end AND o.order_status = 'delivered' sources: [olist_orders_dataset.order_status, olist_order_items_dataset.price] owner: finance analytics version: 1 valid_from: 2018-01-01 change_log: - {version: 1, date: 2018-01-01, change: first definition} active_sellers_any: description: Sellers with at least one order line placed in the period, any status why_useful: How many stores are trading at all how_used: Store ops coverage grain: seller, per period sql: | SELECT COUNT(DISTINCT i.seller_id) FROM items i JOIN orders o USING (order_id) WHERE o.order_purchase_timestamp >= :start AND o.order_purchase_timestamp < :end sources: [olist_orders_dataset.order_purchase_timestamp, olist_order_items_dataset.seller_id] owner: store ops version: 1 valid_from: 2018-01-01 change_log: - {version: 1, date: 2018-01-01, change: first definition} active_sellers_1000: description: Sellers with at least R$ 1,000 of delivered item value placed in the period why_useful: Stores trading at a level that pays for their service how_used: Board pack active-store count grain: seller, per period sql: | SELECT COUNT(*) FROM ( SELECT i.seller_id FROM items i JOIN orders o USING (order_id) WHERE o.order_purchase_timestamp >= :start AND o.order_purchase_timestamp < :end AND o.order_status = 'delivered' GROUP BY i.seller_id HAVING SUM(i.price) >= 1000) sources: [olist_orders_dataset.order_status, olist_order_items_dataset.seller_id, olist_order_items_dataset.price] owner: store ops version: 1 valid_from: 2018-01-01 change_log: - {version: 1, date: 2018-01-01, change: first definition}

Write the one reader. load() refuses any entry missing a required field, so an entry that cannot answer the three questions never reaches a dashboard. compute() runs the entry's own SQL with named parameters; SQLite is in the standard library, so the SQL in the YAML is exactly what runs.

★ Step 3 · registry.py# registry.py - the ONE reader; dashboards, board pack and tests import it import sqlite3, yaml, pandas as pd from pathlib import Path DATA = Path.home() / ".cache/kagglehub/datasets/olistbr/brazilian-ecommerce/versions/2" REQUIRED = ["description", "why_useful", "how_used", "grain", "sql", "sources", "owner", "version", "valid_from", "change_log"] def load(path="metrics.yaml"): reg = yaml.safe_load(Path(path).read_text()) for name, m in reg.items(): missing = [f for f in REQUIRED if f not in m] assert not missing, f"{name} is missing {missing}" return reg def connect(): db = sqlite3.connect(":memory:") for t, f in {"orders": "olist_orders_dataset.csv", "items": "olist_order_items_dataset.csv", "payments": "olist_order_payments_dataset.csv"}.items(): pd.read_csv(DATA / f).to_sql(t, db, index=False) return db def compute(reg, db, name, start, end): (value,) = db.execute(reg[name]["sql"], {"start": start, "end": end}).fetchone() return round(value, 2)

Compute every metric for Q2 2018. Five names, five numbers, and each one matches the table in Part 1. Note what the two seller counts say: 1,680 sellers traded at all, but only 552 sold R$ 1,000 of delivered goods. The file has 3,095 sellers, and 1,653 had a delivered order in the quarter. Four honest answers to "how many active sellers".

★ Step 4 · run.pyfrom registry import load, connect, compute reg, db = load(), connect() Q2 = ("2018-04-01", "2018-07-01") for name in reg: print(f"{name:<26} v{reg[name]['version']} {compute(reg, db, name, *Q2):>14,}") # prints: gmv_placed v1 2,858,289.74 gmv_placed_with_shipping v1 3,332,156.97 gmv_delivered v1 2,807,156.64 active_sellers_any v1 1,680 active_sellers_1000 v1 552

Lock the approved definitions. A short hash of each entry's SQL (whitespace normalised, so re-indenting is not a change) and its version, written to a file you commit next to the YAML. Re-run this only when a definition change has been reviewed.

★ Step 5 · lock.py# lock.py - run once when a definition is approved; commit metrics.lock.json with the YAML import json, hashlib from registry import load fp = lambda sql: hashlib.sha256(" ".join(sql.split()).encode()).hexdigest()[:12] lock = {n: {"version": m["version"], "sql": fp(m["sql"])} for n, m in load().items()} json.dump(lock, open("metrics.lock.json", "w"), indent=1) print(lock["gmv_placed"]) # prints: {'version': 1, 'sql': '9c82b797f930'}

Write the three tests, with plain asserts, and watch them pass. Golden values for the frozen quarter, the payments reconciliation with its tolerance stated in the code, and the definition-change check against the lock file. In CI this is the file that runs on every pull request touching metrics.yaml.

★ Step 6 · test_registry.pyimport json, hashlib from registry import load, connect, compute reg, db = load(), connect() Q2 = ("2018-04-01", "2018-07-01") fp = lambda sql: hashlib.sha256(" ".join(sql.split()).encode()).hexdigest()[:12] # 1 golden numbers: the approved value of each metric for one frozen quarter GOLDEN = {"gmv_placed": 2858289.74, "gmv_placed_with_shipping": 3332156.97, "gmv_delivered": 2807156.64, "active_sellers_any": 1680, "active_sellers_1000": 552} fails = [] for name, want in GOLDEN.items(): got = compute(reg, db, name, *Q2) ok = got == want print(f"golden {name:<26} {got:>14,} {'PASS' if ok else 'FAIL, want ' + format(want, ',')}") if not ok: fails.append(name) # 2 reconciliation: items + shipping against the payments table, same orders (paid,) = db.execute("""SELECT ROUND(SUM(p.payment_value), 2) FROM payments p WHERE p.order_id IN (SELECT o.order_id FROM orders o JOIN items i USING (order_id) WHERE o.order_purchase_timestamp >= ? AND o.order_purchase_timestamp < ?)""", Q2).fetchone() gap = round(paid - compute(reg, db, "gmv_placed_with_shipping", *Q2), 2) TOL = 0.0001 * paid # stated tolerance: 0.01 percent of payments print(f"recon payments {paid:,} gap {gap:,} tolerance {TOL:,.2f} {'PASS' if abs(gap) <= TOL else 'FAIL'}") if abs(gap) > TOL: fails.append("reconciliation") # 3 definition change: new SQL needs a version bump and a change_log entry that says restated lock = json.load(open("metrics.lock.json")) for name, m in reg.items(): changed = fp(m["sql"]) != lock[name]["sql"] bumped = m["version"] > lock[name]["version"] logged = any(e["version"] == m["version"] and "restated" in e["change"] for e in m["change_log"]) if changed and not (bumped and logged): print(f"version {name:<26} SQL changed, still v{m['version']}, no restatement: FAIL") fails.append(name + " version") print("ALL PASS" if not fails else f"{len(fails)} FAILED: {fails}") assert not fails # prints: golden gmv_placed 2,858,289.74 PASS golden gmv_placed_with_shipping 3,332,156.97 PASS golden gmv_delivered 2,807,156.64 PASS golden active_sellers_any 1,680 PASS golden active_sellers_1000 552 PASS recon payments 3,332,386.55 gap 229.58 tolerance 333.24 PASS ALL PASS

Now drift one definition, on purpose. Somebody decides cancelled orders should not count in gmv_placed, adds one reasonable line to its SQL, and does not touch the version. Run the tests again. The golden test catches the number moving by exactly the R$ 8,559.48 of cancelled and unavailable orders, and the version test names the missing bump. The script ends in an AssertionError, exit code 1, and CI stops the merge.

★ Step 7 · the drift# metrics.yaml, gmv_placed only: one line added, version still 1 sql: | SELECT SUM(i.price) FROM items i JOIN orders o USING (order_id) WHERE o.order_purchase_timestamp >= :start AND o.order_purchase_timestamp < :end AND o.order_status NOT IN ('canceled', 'unavailable') python3 test_registry.py # prints: golden gmv_placed 2,849,730.26 FAIL, want 2,858,289.74 golden gmv_placed_with_shipping 3,332,156.97 PASS golden gmv_delivered 2,807,156.64 PASS golden active_sellers_any 1,680 PASS golden active_sellers_1000 552 PASS recon payments 3,332,386.55 gap 229.58 tolerance 333.24 PASS version gmv_placed SQL changed, still v1, no restatement: FAIL 2 FAILED: ['gmv_placed', 'gmv_placed version'] # then: AssertionError (exit code 1)

Decide, then make the decision visible. Two honest ways out, and neither is "update the golden number and move on". Either the new filter is a different metric (add gmv_placed_net as its own entry and leave gmv_placed alone), or it replaces the old one (bump to version 2, add a change log entry that says prior periods were restated, recompute Q2 2018 on v2, update the golden value and re-run lock.py, all in one reviewed commit). Revert the drift before homework: python3 test_registry.py should print ALL PASS again.

Extend the session 1 registry, do not start a second one. The estate registry already knows every table's grain and owner; the metric registry's sources should point at those same table names, so a grain fix in one place reaches every metric built on it. Two registries that each describe items will disagree the first week somebody edits one.

Homework

Before session 8

30 min
  1. Find the three most-quoted numbers in your team's last board pack or brand review. For each, find every query that computes it. Write down how many distinct definitions you found, and which one the slide used.
  2. Write registry entries for those three, with all ten fields. Any field you cannot fill is a finding: an empty how_used means nobody decides anything with it, an empty owner means nobody will notice when it breaks.
  3. Pick one closed period and record golden values for all three. Add one reconciliation between two independent routes to the same money, and write the tolerance down before you run it.
  4. Drift one definition on a branch, as in Step 7, and confirm your tests fail. A test suite you have never seen fail is not yet a test suite.
Check yourself

Three questions

Clear all three and Analyst session 7 gets stamped in your passport.

1 · On Olist Q2 2018, one query returns R$ 2,858,289.74 and another returns R$ 3,332,156.97, both labelled GMV. What should the registry do?

Both are defensible; the first is item value, the second is what the buyer was charged, which is the marketplace-headline convention. The failure is one word covering two numbers. Two names end it.

2 · Which three fields map onto the three questions the SEC's 2020 guidance lists for a key metric?

The guidance expects a clear definition and calculation, the reasons the metric is useful, and how management uses it. The other fields matter too, for lineage and history, but those three are the reader's questions, and making them required means no entry loads without them.

3 · After a one-line SQL edit, the golden test reports gmv_placed as 2,849,730.26, want 2,858,289.74. What is the right response?

The data for a closed quarter cannot move, so the definition did, by exactly the R$ 8,559.48 of cancelled and unavailable orders. The test is working. The fix is to make the change a visible decision, not to make the test quiet.

Sources covered

What this session teaches, and where it came from

✓GMV variants on one real quarterBrazilian E-Commerce Public Dataset by Olist, Kaggle, CC BY-NC-SA 4.0; Q2 2018 totals, status counts and seller counts measured in this build; the Serai variants are constructed
✓The metric registry as codePractice: YAML entry, one reader, golden, reconciliation and definition-change tests; spec-sheet method linked to the ecommerce metrics track, not re-taught
◐SEC 2020 guidance on key metricsU.S. Securities and Exchange Commission, "Commission Guidance on Management's Discussion and Analysis of Financial Condition and Results of Operations", Release No. 33-10751, February 2020; read at source, used as a checklist, not legal teaching
✓Sea's GMV definitionSea Limited, Annual Report on Form 20-F for the fiscal year 2019, "Conventions" definitions; read at source

Every fact on this page, and its verification tier, is recorded in the course's source map.

Analyst session 7 cheat sheet · pin this

One name, one numberThree defensible GMVs get three names. "GMV" alone is not a metric.
Ten fieldsdescription, why_useful, how_used, grain, sql, sources, owner, version, valid_from, change_log.
Three reader questionsWhat it is and how it is calculated, why it is useful, how it is used. Required fields.
One readerEverything imports registry.py; nothing else parses the YAML or re-types the SQL.
Three testsGolden numbers on a frozen period, a reconciliation with a stated tolerance, a version check.
Drift is allowedSilent drift is not. Version, log, restate, re-lock, in one reviewed commit.