Food-Cost Diff Checks in CI/CD
Recipes, BOM edges, yield factors, and POS mapping tables are data that lives in version control, which means a change to any of them is a pull request — and a pull request can be tested. This guide, part of the reporting, CI/CD and delivery pipelines domain, isolates one sub-problem: how to make food cost a property that a continuous-integration pipeline verifies, so a change that removes a garnish, swaps a cheaper-looking supplier SKU with a worse yield, or fat-fingers a portion weight cannot merge without a human seeing exactly how much margin it moved. The failure this eliminates is the invisible edit — a recipe change that looks harmless in a diff but shifts a dish’s food-cost percentage by three points across a thousand locations.
Concept Definition and Data Contract
A food-cost diff check compares two theoretical costings of the same fixed basket: the one produced by the code and data on the proposed branch, and a committed baseline produced by the last approved state. The input contract has three parts: a sample basket (a versioned list of menu_item_ids representative of the menu, with the location_id or price profile to cost them against), the branch’s recipe and mapping data (the working tree under test), and the baseline (a checked-in file of expected food_cost_pct per basket item). The output contract is a set of per-item deltas plus a single verdict: pass, or fail with the list of regressions.
Critically, this tests theoretical cost, not actual — it is a pure function of committed data and needs no live sales feed. That is what makes it deterministic enough to gate a merge. It reuses the same roll-up the nightly job runs, sourced from the core architecture and cost mapping systems, so the CI number and the production number are computed by identical logic.
Architecture Decision Rationale
Fixed basket over full menu. Costing the entire menu on every pull request is slow and noisy — most items are untouched. A curated basket that exercises each area of the menu (a protein dish, a produce-heavy dish, a beverage, a combo) keeps the check fast and its failures legible, while still catching a change to a shared ingredient because that ingredient appears in several basket items.
Committed baseline over recomputed-on-main. Recomputing the “before” cost from the main branch at CI time doubles the work and makes the check depend on main being green. Committing the baseline as data means the diff is a simple load-and-compare, and a deliberate margin change is reviewed as a visible edit to the baseline file — the reviewer sees both the recipe change and the sanctioned cost movement in one pull request.
Tolerance bands over exact equality. Yield smoothing and rounding make exact cost equality brittle. A per-item tolerance (say one percentage point of food cost) distinguishes noise from a real regression, and the band itself is data a team can tune. Wiring these decisions into a runnable pipeline is covered in running food-cost diffs as GitHub Actions gates.
Phase 1 — Recompute the Basket on the Branch
The check recomputes theoretical cost for the basket using the branch’s data. This calls the same costing function production uses; the only CI-specific part is feeding it the fixed basket.
from __future__ import annotations
from decimal import Decimal
import pandas as pd
def cost_basket(basket: pd.DataFrame, recipes: pd.DataFrame,
ingredient_costs: pd.DataFrame) -> pd.DataFrame:
"""Compute theoretical food-cost pct per basket item from branch data."""
exploded = basket.merge(recipes, on="menu_item_id", how="left", validate="m:m")
exploded = exploded.merge(ingredient_costs, on="ingredient_sku", how="left")
exploded["line_cost"] = exploded.apply(
lambda r: Decimal(str(r["qty"])) * Decimal(str(r["unit_cost"])), axis=1
)
per_item = exploded.groupby("menu_item_id", as_index=False).agg(
theoretical_cost=("line_cost", "sum"),
menu_price=("menu_price", "first"),
)
per_item["food_cost_pct"] = per_item.apply(
lambda r: (r["theoretical_cost"] / r["menu_price"]).quantize(Decimal("0.0001")),
axis=1,
)
return per_item[["menu_item_id", "food_cost_pct"]]
A how="left" join surfaces a missing ingredient cost as a NaN rather than dropping the line — a change that references an unpriced SKU should fail the check loudly, not quietly undercount cost.
Phase 2 — Diff Against the Baseline and Route Failures
The recomputed costs diff against the committed baseline. Every regression beyond tolerance, and every basket item that appears or disappears, is collected into a failure set.
from decimal import Decimal
import pandas as pd
def diff_against_baseline(current: pd.DataFrame, baseline: pd.DataFrame,
tolerance: Decimal = Decimal("0.01")) -> pd.DataFrame:
merged = current.merge(
baseline, on="menu_item_id", suffixes=("_new", "_base"),
how="outer", indicator=True,
)
merged["delta"] = (
merged["food_cost_pct_new"].fillna(Decimal("0"))
- merged["food_cost_pct_base"].fillna(Decimal("0"))
)
failing = merged[
(merged["_merge"] != "both") | (merged["delta"].abs() > tolerance)
]
return failing[["menu_item_id", "food_cost_pct_base",
"food_cost_pct_new", "delta", "_merge"]]
An outer join with an indicator catches three failure classes at once: a regression on an existing item, a basket item that lost all cost (recipe deleted), and a new item with no baseline. Each is a reviewable event. Turning the resulting frame into a hard build failure with an operator-tunable threshold is the subject of failing a build on margin-regression thresholds.
Phase 3 — Verdict, Annotation, and Baseline Update
The final phase converts the failure set into a process exit code and a human-readable annotation on the pull request, and defines how the baseline is legitimately updated.
import sys
import pandas as pd
def emit_verdict(failing: pd.DataFrame) -> int:
if failing.empty:
print("food-cost diff: PASS — all basket items within tolerance")
return 0
print("food-cost diff: FAIL — regressions detected")
print(failing.to_markdown(index=False))
return 1
if __name__ == "__main__":
sys.exit(emit_verdict(compute_failures())) # compute_failures wires Phases 1-2
When a margin change is intentional — a genuine price or recipe decision — the fix is not to widen the tolerance but to update the committed baseline in the same pull request, so the reviewer approves both the recipe edit and the sanctioned cost movement together. Catching these regressions even earlier, before CI, is what pre-commit hooks for recipe cost validation provide.
Production Hardening
- Deterministic basket. Version the sample basket and its price profile in the repo so a CI run on the same commit always costs the same items the same way.
- Decimal exactness. Cost the basket in
Decimalexactly as production does, so a CI pass genuinely predicts the production number rather than approximating it. - Override auditing. A failed gate can be overridden by a maintainer, but the override — who, which PR, which regressions — is logged, so a bypassed margin regression is never silent.
- Fast feedback. Keep the basket small enough that the check runs in seconds; a slow gate gets disabled, and a disabled gate protects nothing.
- Baseline drift detection. Periodically recost the baseline against production data to confirm the checked-in numbers still match reality, so the gate does not slowly certify against a stale reference.
Failure Modes and Troubleshooting
| Symptom | Likely cause | Detection / fix |
|---|---|---|
| Gate passes but production margin moved | Basket does not exercise the changed ingredient | Add a basket item covering that ingredient; baskets must span the menu’s cost drivers. |
| Gate fails on every run with tiny deltas | Tolerance tighter than smoothing/rounding noise | Set the band above expected noise (e.g. 1 pt); tune as data, not by disabling the check. |
| A deleted recipe passes silently | Inner join dropped the missing item | Use an outer join with an indicator so vanished basket items become failures. |
| Intentional price change blocks merge forever | Baseline never updated | Update the committed baseline in the same PR; the diff then shows a sanctioned change. |
| CI number differs from nightly number | CI used different costing logic | Call the exact production roll-up in CI; never fork a “CI-only” cost function. |
FAQ
How can food cost be tested without live sales data?
Because the check tests theoretical cost, which is a pure function of committed recipe definitions, BOM edges, yield factors, and a fixed sample basket — none of which need live sales. Actual cost depends on what was sold and is measured nightly. The CI gate catches the subset of margin regressions introduced by a code or data change, deterministically, before they ship.
Why commit a baseline instead of comparing against the main branch?
A committed baseline makes the diff a simple load-and-compare and removes the dependency on main being green at CI time. It also makes an intentional margin change reviewable: the pull request shows both the recipe edit and the sanctioned change to the baseline file side by side, so the reviewer approves the cost movement explicitly rather than discovering it later.
What tolerance should the gate use?
Set it just above the noise floor introduced by yield smoothing and rounding — often around one percentage point of food cost — and store it as data the team can tune per item or per category. A tolerance tighter than the noise produces constant false failures and the gate gets disabled; too loose, and a real regression slips through.
Can a failing gate be overridden?
Yes, by a maintainer, but the override is audited — who bypassed it, on which pull request, and which regressions were waived. An override is sometimes correct (an emergency supplier substitution), but it must never be silent, so the audit trail records every waived regression alongside the CI verdict.
Related
- Up one level: Reporting, CI/CD & Delivery Pipelines
- Running Food-Cost Diffs as GitHub Actions Gates
- Failing a Build on Margin-Regression Thresholds
- Pre-Commit Hooks for Recipe Cost Validation
- Core Architecture & Cost Mapping Systems
For platform specifics, see the official GitHub Actions documentation.