Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion tests/unit/test_dv2_supplier_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,14 @@ def test_ru_inn10_check_digit_known_vector():


def test_tnved_headings_are_real_format():
assert len(TNVED_HEADINGS) >= 30
# 10 catalog categories (generator-spec.md §3), one split across two
# headings (vacuum sealers vs dryers) -> 11 heading entries.
assert len(TNVED_HEADINGS) == 11
allowed_headings = {"8516", "8509", "8423", "8422"}
for h in TNVED_HEADINGS:
assert len(h.heading) == 4
assert h.heading.isdigit()
assert h.heading in allowed_headings
assert h.code10 == f"{h.heading}000000"
assert len(h.code10) == 10
assert h.description.strip()
Expand Down
226 changes: 226 additions & 0 deletions tests/unit/test_generator_spec_invariants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
"""Machine-checkable consistency invariants from ``docs/generator-spec.md``
§12 — the definition of "цифры взаимно согласованы" for the B1 data rebuild.

Invariants #1-#10 are pure arithmetic over :mod:`legend`'s constants (no
warehouse needed). #5/#7/#8 are also checked against the actual
:func:`build_reference` output. #11/#12 (faux-PII locale rules, loyalty
gating) are structural regression guards over the seed SQL text — a live
ClickHouse/Postgres re-verify is B4's job, not this file's.
"""

from __future__ import annotations

from pathlib import Path

from warehouse.agentflow.dv2.reference import legend
from warehouse.agentflow.dv2.reference.generator import build_reference
from warehouse.agentflow.dv2.reference.gs1 import is_valid_gtin13
from warehouse.agentflow.dv2.reference.tnved import TNVED_HEADINGS

DV2_ROOT = Path(__file__).resolve().parents[2] / "warehouse" / "agentflow" / "dv2"

_MARKETPLACE_CHANNELS = {"marketplace_fbs"}
_D2C_CHANNELS = {"d2c_site"}
_B2B_CHANNELS = {"b2b_wholesale", "b2b_re_export", "b2b_eaeu"}


def _annual_revenue_rub() -> float:
daily = sum(orders * check for _, _, orders, check in legend.MASTER_MATRIX)
return daily * 365


def _orders_by_group() -> dict[str, int]:
totals = {"marketplace": 0, "d2c": 0, "b2b": 0}
for channel, _, orders, _ in legend.MASTER_MATRIX:
if channel in _MARKETPLACE_CHANNELS:
totals["marketplace"] += orders
elif channel in _D2C_CHANNELS:
totals["d2c"] += orders
else:
totals["b2b"] += orders
return totals


def _revenue_by_group() -> dict[str, float]:
totals = {"marketplace": 0.0, "d2c": 0.0, "b2b": 0.0}
for channel, _, orders, check in legend.MASTER_MATRIX:
revenue = orders * check
if channel in _MARKETPLACE_CHANNELS:
totals["marketplace"] += revenue
elif channel in _D2C_CHANNELS:
totals["d2c"] += revenue
else:
totals["b2b"] += revenue
return totals


# --- #1 annual revenue -------------------------------------------------------


def test_invariant_1_annual_revenue_in_corridor():
annual_b_rub = _annual_revenue_rub() / 1_000_000_000
assert 3.5 <= annual_b_rub <= 5.0


# --- #2 order-count mix ------------------------------------------------------


def test_invariant_2_order_count_mix():
totals = _orders_by_group()
grand_total = sum(totals.values())
marketplace_pct = 100 * totals["marketplace"] / grand_total
b2b_pct = 100 * totals["b2b"] / grand_total
d2c_pct = 100 * totals["d2c"] / grand_total
assert 88 <= marketplace_pct <= 90
assert 7 <= b2b_pct <= 9
assert 2 <= d2c_pct <= 4


# --- #3 revenue mix -----------------------------------------------------------


def test_invariant_3_revenue_mix():
totals = _revenue_by_group()
grand_total = sum(totals.values())
b2b_pct = 100 * totals["b2b"] / grand_total
marketplace_pct = 100 * totals["marketplace"] / grand_total
assert 65 <= b2b_pct <= 72
assert 27 <= marketplace_pct <= 33


# --- #4 bimodal AOV -----------------------------------------------------------


def test_invariant_4_bimodal_avg_checks_no_mass_in_gap():
# §1's master matrix pins dxb (re-export, "export pallets", thinner
# margin per §5) at a 90k avg check — outside the general [30k, 80k] B2B
# band the same table implies for the domestic + EAEU wholesale channels.
# Read narrowly: the [30k, 80k] band covers RU + ala wholesale; dxb is a
# documented, table-explicit outlier, not a spec violation.
domestic_b2b_checks = [
check
for channel, branch, _, check in legend.MASTER_MATRIX
if channel in _B2B_CHANNELS and branch != "dxb"
]
marketplace_checks = [
check for channel, _, _, check in legend.MASTER_MATRIX if channel in _MARKETPLACE_CHANNELS
]
assert all(30_000 <= c <= 80_000 for c in domestic_b2b_checks)
assert all(1_500 <= c <= 3_000 for c in marketplace_checks)
# no channel's avg check falls in the 10k-25k dead zone (holds for all
# channels, including dxb)
assert all(not (10_000 < check < 25_000) for _, _, _, check in legend.MASTER_MATRIX)


# --- #5 pricing ladder ---------------------------------------------------------


def test_invariant_5_pricing_ladder_bands_are_disjoint_and_ordered():
assert legend.FOB_PCT_RANGE[1] < legend.LANDED_PCT_RANGE[0]
assert legend.LANDED_PCT_RANGE[1] < legend.WHOLESALE_PCT_RANGE[0]
assert legend.WHOLESALE_PCT_RANGE[1] < legend.MARKETPLACE_NET_PCT
assert legend.MARKETPLACE_NET_PCT < legend.RRC_PCT


def test_invariant_5_pricing_ladder_holds_per_sku():
tables = build_reference()
rrc_by_sku = {p.product_bk: p.rrc_price for p in tables.products}
for sourcing in tables.sourcing:
rrc = rrc_by_sku[sourcing.product_bk]
fob_pct = sourcing.purchase_price / rrc
assert legend.FOB_PCT_RANGE[0] <= fob_pct <= legend.FOB_PCT_RANGE[1]


# --- #6 seasonal curves average to 1.0 -----------------------------------------


def test_invariant_6_seasonal_curves_average_to_one():
assert len(legend.SEASONAL_RETAIL) == 12
assert len(legend.SEASONAL_B2B) == 12
assert abs(sum(legend.SEASONAL_RETAIL) / 12 - 1.0) < 1e-9
assert abs(sum(legend.SEASONAL_B2B) / 12 - 1.0) < 1e-9


# --- #7 GTIN validity -----------------------------------------------------------


def test_invariant_7_every_gtin_valid_and_in_eaeu_range():
tables = build_reference()
for product in tables.products:
assert is_valid_gtin13(product.gtin)
assert 460 <= int(product.gtin[:3]) <= 469


# --- #8 tnved headings ----------------------------------------------------------


def test_invariant_8_every_tnved_code_matches_a_spec_heading():
allowed_headings = {"8516", "8509", "8423", "8422"}
assert {h.heading for h in TNVED_HEADINGS} <= allowed_headings
tables = build_reference()
for product in tables.products:
assert product.tnved_code.endswith("000000")
assert len(product.tnved_code) == 10
assert product.tnved_code[:4] in allowed_headings


# --- #9 dealer ordering frequency -> B2B orders/day -----------------------------


def test_invariant_9_dealer_frequency_yields_150_to_200_b2b_orders_per_day():
assert sum(count for _, count, _ in legend.DEALER_FREQUENCY_TIERS) == legend.DEALER_CUSTOMERS
weekly_orders = sum(count * freq for _, count, freq in legend.DEALER_FREQUENCY_TIERS)
daily_b2b_orders = weekly_orders / 7
assert 150 <= daily_b2b_orders <= 200


# --- #10 branch revenue shares ---------------------------------------------------


def test_invariant_10_branch_revenue_shares():
by_branch: dict[str, float] = {}
for _, branch, orders, check in legend.MASTER_MATRIX:
by_branch[branch] = by_branch.get(branch, 0.0) + orders * check
grand_total = sum(by_branch.values())
shares = {branch: 100 * revenue / grand_total for branch, revenue in by_branch.items()}
assert abs(sum(shares.values()) - 100) < 1e-6
assert 55 <= shares["msk"] <= 65


# --- #11 faux-PII locale rules (structural, over seed SQL text) -----------------


def _read_seed_sql(*names: str) -> str:
return "\n".join((DV2_ROOT / name).read_text(encoding="utf-8") for name in names)


def test_invariant_11_faux_pii_locale_rules_present_in_seed_sql():
seed_sql = _read_seed_sql("satellite_seed.sql", "satellite_seed_all_branches.sql")
# RU jurisdictions (§8): city phone prefixes + @example.test emails.
assert "+7495" in seed_sql # msk
assert "+7812" in seed_sql # spb
assert "+7343" in seed_sql # ekb
assert "@example.test" in seed_sql
# AE jurisdiction (dxb): +971 phones, latin transliteration names, .ae emails.
assert "+971" in seed_sql
assert "@example.ae" in seed_sql
# KZ jurisdiction (ala): +7727 phones, .kz emails.
assert "+7727" in seed_sql
assert "@example.kz" in seed_sql


# --- #12 loyalty gating -----------------------------------------------------------


def test_invariant_12_loyalty_points_cap_is_within_three_percent_of_min_quarterly_spend():
min_quarterly_spend = legend._TAIL_ORDERS_PER_WEEK * legend._RU_B2B_AVG_CHECK_RUB * 13
assert legend.LOYALTY_POINTS_MAX_RUB <= legend.LOYALTY_RETRO_BONUS_PCT * min_quarterly_spend


def test_invariant_12_loyalty_rows_only_for_eligible_branches_in_seed_sql():
assert legend.LOYALTY_ELIGIBLE_BRANCHES == ("msk", "spb", "ekb")
seed_sql = _read_seed_sql("satellite_seed.sql", "satellite_seed_all_branches.sql")
for branch in legend.LOYALTY_ELIGIBLE_BRANCHES:
assert f"sat_customer_loyalty__bitrix__{branch}" in seed_sql
for branch in ("dxb", "ala"):
assert f"sat_customer_loyalty__bitrix__{branch}" not in seed_sql
9 changes: 5 additions & 4 deletions warehouse/agentflow/dv2/cold_offload_seed.sql
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
-- deterministic hash of the customer number so the cold export has real
-- rows to ship without touching jurisdictional PII.
--
-- One row per msk customer (~800 rows). Re-runnable: hash_diff makes
-- the satellite ReplacingMergeTree-friendly even though it's MergeTree.
-- One row per msk customer: retail [0,2000) + dealer msk [2000,2190) —
-- 2,190 rows, matching hub_customer's msk band (synthetic_seed.sql header).
-- Re-runnable: hash_diff makes the satellite ReplacingMergeTree-friendly
-- even though it's MergeTree.

INSERT INTO rv.sat_customer_anon__1c__msk
(customer_hk, load_ts, hash_diff, record_source,
Expand Down Expand Up @@ -33,5 +35,4 @@ SELECT
'new'
) AS customer_segment,
0 AS is_deleted
FROM numbers(2000)
WHERE number % 100 < 40; -- msk slice = 40% of 2000 customers
FROM numbers(2190); -- msk slice: retail [0,2000) + dealer msk [2000,2190)
6 changes: 4 additions & 2 deletions warehouse/agentflow/dv2/postgres_oltp/seed.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
-- Hot-tier OLTP seed for the DV2.0 demo.
-- Hot-tier OLTP seed for the DV2.0 demo (own-brand kitchen-appliance
-- importer legend — see synthetic_seed.sql for the full customer/order
-- numbering this small sample mirrors at hot-tier scale).
-- Lives in Postgres 17, per-branch schema layout (ops_<branch>) so the
-- CDC bridge described in docs/dv2-multi-branch/architecture.md can route
-- straight by schema name. Each schema gets its own customers + orders table.
Expand Down Expand Up @@ -56,7 +58,7 @@ SELECT
(ARRAY['Anna','Boris','Dasha','Egor','Fedor','Galya','Ivan','Kira','Lena','Mark'])[(n % 10) + 1],
(ARRAY['Ivanov','Petrov','Sidorov','Smirnov','Volkov','Orlov','Lebedev','Sokolov'])[(n % 8) + 1],
'oltp' || n::text || '@example.test',
'+7916' || lpad((n * 137 % 10000000)::text, 7, '0')
'+7495' || lpad((n * 137 % 10000000)::text, 7, '0')
FROM generate_series(1, 50) AS n
ON CONFLICT (customer_id) DO NOTHING;

Expand Down
39 changes: 26 additions & 13 deletions warehouse/agentflow/dv2/reference/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# DV2 Supplier / Product Reference

A reproducible grocery **reference** (suppliers, products, GS1 marking codes,
product→supplier sourcing) for the AgentFlow DV2 raw vault. It fills the
catalog / `tnved_code` / GS1-marking slots that the transactional X5 feed
leaves empty, and is the project's genuine **cloud** component: the dataset is
published to a Hugging Face Dataset — real object storage, not a checkbox.
A reproducible small-kitchen-appliance **reference** (suppliers, products, GS1
marking codes, product→supplier sourcing) for the AgentFlow DV2 raw vault —
the own-brand importer legend in [`docs/domain.md`](../../../../docs/domain.md),
pinned to exact numbers in [`docs/generator-spec.md`](../../../../docs/generator-spec.md).
It fills the catalog / `tnved_code` / GS1-marking slots that the transactional
feeds leave empty, and is the project's genuine **cloud** component: the
dataset is published to a Hugging Face Dataset — real object storage, not a
checkbox.

It is a *reference* (master/dimension) feed, distinct from the `1c` / `wms` /
`wb` transactional sources. Provenance is explicit: every row carries
Expand All @@ -17,21 +20,31 @@ Kept deliberately honest — the value is in real storage + real standards
conformance, not in pretending the identities are real.

**Genuine (verifiable, pinned by tests):**
- ТН ВЭД ЕАЭС headings — real 4-digit HS-aligned customs headings with
descriptions close to the official Russian wording (`tnved.py`).
- ТН ВЭД ЕАЭС headings — real 4-digit HS-aligned customs headings (8516/8509/
8423/8422) with descriptions close to the official Russian wording
(`tnved.py`).
- GS1 **GTIN-13** and **GLN-13** check digits — published GS1 mod-10
algorithm (`gs1.py`).
- **RU INN-10** control digit — real algorithm for RU legal-entity tax ids.
- EAEU GS1 prefix range **460–469**.
- EAEU GS1 prefix range **460–469** — correct for this reference even though
manufacturing is contracted to China: GTINs belong to the RU brand owner
registered with GS1 RUS, regardless of where the goods are made.
- `gross_weight_g >= net_weight_g` packaging invariant.
- MD5 hash keys computed with the **same canonicalisation as the X5 loader**,
so reference hubs/links join byte-for-byte with vault data already loaded
from other sources (pinned in `tests/unit/test_dv2_supplier_reference.py`).
- Pricing-ladder ordering per SKU: FOB < landed < wholesale < marketplace-net
< RRC (generator-spec.md §5) — guaranteed by disjoint percentage bands.
- MD5 hash keys computed with the **same canonicalisation as the
transactional loader**, so reference hubs/links join byte-for-byte with
vault data already loaded from other sources (pinned in
`tests/unit/test_dv2_supplier_reference.py`).

**Synthetic but labelled:**
- supplier legal names and brand names;
- supplier legal names — **no brand token** anywhere in product data
(own-brand importer decision, generator-spec.md §3);
- CN USCC-18 tax ids — structurally shaped per GB 32100-2015, but the check
character is a labelled placeholder, not a verified mod-31 check digit
(`make_cn_uscc18`);
- the specific SKU ↔ GTIN ↔ supplier assignments;
- packaging dimensions and purchase prices;
- packaging dimensions, RRC and FOB purchase prices;
- GPC brick codes (illustrative);
- ТН ВЭД sub-position digits — the genuine heading is zero-padded to the
10-digit field (`<heading>000000`), i.e. heading granularity, **not** a
Expand Down
16 changes: 9 additions & 7 deletions warehouse/agentflow/dv2/reference/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import pandas as pd

from .generator import ReferenceTables, build_reference
from .legend import GENERATOR_SEED, TOTAL_PRODUCTS, TOTAL_SUPPLIERS
from .vault_mapping import RECORD_SOURCE, map_reference


Expand Down Expand Up @@ -60,15 +61,16 @@ def _manifest(tables: ReferenceTables, load_ts: datetime, vault: dict[str, pd.Da
"RU INN-10 control digit",
"EAEU GS1 prefix range 460-469",
"gross_weight_g >= net_weight_g invariant",
"MD5 hash keys join-compatible with the X5 / 1C vault feeds",
"pricing ladder ordering (FOB < landed < wholesale < marketplace-net < RRC)",
"MD5 hash keys join-compatible with the transactional vault feeds",
],
"synthetic_but_labelled": [
"supplier legal names",
"brand names",
"supplier legal names (no brand token anywhere in the data)",
"SKU <-> GTIN <-> supplier assignments",
"packaging dimensions and purchase prices",
"packaging dimensions, RRC and FOB purchase prices",
"GPC brick codes (illustrative)",
"ТН ВЭД sub-position digits (zero-padded; heading granularity)",
"CN USCC-18 check character (structurally shaped, not GB 32100-2015 verified)",
],
}

Expand All @@ -77,9 +79,9 @@ def _manifest(tables: ReferenceTables, load_ts: datetime, vault: dict[str, pd.Da
@click.option(
"--out-dir", default="reference/build", type=click.Path(file_okay=False, path_type=Path)
)
@click.option("--seed", default=20260626, type=int)
@click.option("--n-suppliers", default=40, type=int)
@click.option("--n-products", default=300, type=int)
@click.option("--seed", default=GENERATOR_SEED, type=int)
@click.option("--n-suppliers", default=TOTAL_SUPPLIERS, type=int)
@click.option("--n-products", default=TOTAL_PRODUCTS, type=int)
@click.option("--load-ts", default=None, help="Fixed UTC load timestamp (ISO-8601), else now.")
@click.option("--dry-run", is_flag=True, help="Build and summarize without writing files.")
def main(
Expand Down
Loading