Every dollar is a deterministic, tested function. AI reads photos in and phrases words out. If a model output ever appears in a dollar figure, that's a highest-severity bug.
Law 2 — The engine never returns a bare number.
Every answer is cost + ledger: the list of assumptions, sources, and fallbacks behind it. The ledger is the contract between the engine, the screens, and the agent.
01Where we are right now
In progress
Conversion engine
This week · blocker
Ledger session (90 min)
Next up
Catalog → Pricing
Mid-August
cost_recipe + margins
Sept 1
Beta kitchen opens
Build order = dependency order, and it doesn't bend: conversions → catalog & matching → pricing (yields inside it) → recipe costing → margins & batch → questions & memory. The finish line that counts: recompute a real customer's book and match ChefTec's own cached numbers (the three well-kept books QueryMan identified, 99%+ cache coverage). Clean diff = engine proven.
02Pricing: why our data, and when to use the web
There are three different "prices" for the same tomato, from three altitudes of the supply chain. We tested this live in July with Roma tomatoes — our database vs. USDA wholesale reports vs. retail:
Altitude
Roma tomatoes, July 2026
What it really is
Use for costing?
Terminal / commodity (USDA)
$0.52–1.44 /lb by region
What distributors pay at the wholesale market
No — two steps upstream of a kitchen
Settled prices (our DBs)
$1.16–1.63 /lb, delivered
What real kitchens actually paid, with vendor, date, place
Yes — this is the only altitude a recipe can honestly be costed at
Retail / web list
~$1.90–2.20 /lb
Sticker prices from the wrong channel
No — nobody pays this wholesale
Our numbers sat above terminal and below retail at exactly the expected markup ratios — three independent sources agreeing is how we know we're close. And the two sites' prices differed ($1.16 vs $1.63) while moving together (both peaked in May, both fell) — which is the whole corpus model in one example: levels are personal, trends are shared.
When we use ChefTec data: always, for any number a dish gets costed with. Their own invoices first; other kitchens' settled prices (anonymized, as a range) only to bridge cold-start and to power market comparisons.
When we use the web: never for costing. USDA-style commodity reports serve two supporting roles only — calibration (periodically check our medians sit in sane supply-chain order) and, later, trend context ("butter futures jumped — expect your dairy invoice up"). Always labeled, never inside a dollar figure.
The price ladder — how one ingredient's price resolves, in order:
This kitchen's own purchase history — their invoices, their exact prices. exact
Prices the chef told us — quotes, stated prices, ask-once answers. exact-ish
Market range from the corpus — median + range across nearby kitchens, recent only, junk excluded. Shown as "market estimate," never as their price. honest estimate
Nothing found → ask the chef. Never invent a number. The answer becomes rung 2 forever. question
Bulk pricing — measured, settled, done: we tested 669 cases of the same kitchen buying the same item at very different quantities. 80% flat. Volume discounts are baked into the account's contract, not applied per order. So: ingredient cost scales linearly in batch simulation — no bulk curve, ever. Labor is where per-plate cost genuinely falls with batch size. (The "bigger buyers pay less" effect is real but lives between accounts — that's a corpus insight for later, not a costing input.)
03Matching: the three-rail waterfall
Connecting "what the recipe says" and "what the invoices say" to one catalog identity is the engine's judgment layer. The data gave us three rails, each cheaper and more certain than the next. Resolution order:
0 · firstKitchen memory
This kitchen answered before → same answer forever. Never ask twice.
1Seed-ID + name
Low ItemIDs (<~2000) are a shared factory catalog across sites. ID match + name agreement = auto-link. Name disagreement → review (sites rename seeds!).
2Vendor SKU
80% of purchase lines carry product codes; Sysco codes are identical across sites. A join, not a guess.
3Fuzzy name
Trigram/embedding candidates with scores. Suggests, never decides.
Why the guardrails matter: unguarded fuzzy matching paired toilet tissue with OIL and an IPA with CREAM in our own staples test. Fuzzy is the fallback, never the authority — and nothing auto-links below the confidence threshold.
04The components — how to attack each one
Conversions
in progress
Approach
Three tiers: exact math → stored facts → chains of stored facts (only within one ingredient, max 3 hops). Two routes disagreeing by >1% = contradiction: flag + question, never a silent pick.
Every site invents custom units beyond the 61-unit master (IDs to 668) — the master list is a floor. Unit IDs are never portable across sites; always resolve through the site's own Units table.
ConvMissing: 62.7K logged conversion failures = a frequency-ranked list of what real kitchens got stuck on. Import it — it seeds the question queue and prioritizes reference data.
Done means
Spec tests green on every constant and tier · fact-health command running (round-trips, physics bands, contradictions) · stress agent can't find a case where the engine silently guesses.
How we test it
Full methodology — the LLM chef-simulator, the five-layer verification stack, the silent-assumption metric: Conversion Testing Playbook →
Ingredients & catalog
next
Approach
One namespace per tenant (recipes can BE ingredients). Matching = the waterfall above. Every confirmed match persists forever.
What the data said
Inv.Sku is empty everywhere — ProdCo is the real SKU store; import it fully.
Inv carries flags we want: CatchWeight (always cost per weight), IsStaple, preferred vendor, cost-method override.
Recipe lines need a third case: separators are physical Inv rows recipes point at — ingredient XOR sub-recipe XOR separator.
Watch out
Everything in the source is keyed per profit center — every join carries (ProfCentID, ItemID), or counts silently multiply.
Pricing
next
Approach
The crown jewel stays: no price field anywhere — price is derived at query time from append-only purchase facts, through the ladder in §02.
What the data said
Cost methods are a monoculture (>99.9% on the default at every site): port ONE strategy + a per-item dispatcher, stub the rest.
Cost column is a line TOTAL, proven with data — unit price is always total ÷ qty.
Zero-qty (0.4%), zero-cost (0.4%), no-unit (1.3%) rows do the real exclusion work; IgnoreCost is ~dead but port it.
Outlier guard required: our own fixture has a $0.70-vs-$50.94 same-unit entry error. Wild deviation from an item's own history → still return it, but ledger caution + question.
Watch out
Sub-recipe fake purchases (up to 21.8% of a site's Trans!) can only be excluded by the SubRecipe=1 join per profit center — NEVER by vendor name. "In-house" is ordinary data and varies per site.
Yields & waste
rides with pricing
Approach
Effective cost = unit cost ÷ usable factor (an avocado at 0.70 costs ~43% more per usable pound). Factor NULL = unknown → ledger caution on produce/protein, never a silent 1.0.
What the data said
Legacy yield data barely exists: 575 real entries in the whole fleet (99.6% of rows are the untouched 1.0 default). Import 1.0 as NULL — confirmed at maximum scale.
Seeding comes from the 3 sites that actually maintained yields + a chef review pass (session doc already prepared), not from migration.
Factors >1.0 are legal and meaningful — rice and dried goods gain weight when cooked (one site deliberately uses 2.0–3.0). Don't cap at 1.0 in validation; ledger-annotate as a yield gain.
The heart. Sum of line costs (children first for sub-recipes, circular refs rejected at write time) + adjustments + optional labor; portion cost = total ÷ portions. Every line gets a status; problems escalate the recipe to "incomplete" but the engine still returns the partial cost + ledger — never an exception for data gaps.
What the data said
Sub-recipe dual identity verified exactly (4,435 = 4,435 = 4,435) — model it as an explicit either/or line reference, killing the legacy shared-ID hack.
RecpItems looks like recipe lines and is a decoy — RecpInv is the only truth for composition.
Legacy labor is dead fleet-wide: stub the port; build fresh simple labor (per-batch + reserved per-portion column) for the beta kitchen.
Q-factors are alive at 4 sites — port the math (it's one line).
Watch out
The diff harness answer key (RecpCost) has no timestamp and carries failure codes: diff only result-code-0 rows of active recipes; bucket code-1 as expected mismatches.
Margins & batch
thin layer on costing
Approach
Food-cost % / margin / target price off one portion cost. Batch simulation is a view, never an edit: ingredients scale linearly (measured — no bulk curve), labor scales by batch count, ledger says which is which.
What the data said
Menu-price slots 2/3: empty at all 14 sites. Migrate slot 1 only; our labeled-price-list model stands, the "three mystery slots" question answered itself.
Price-change history (RecpPrice) is rich where used (to 772K rows) — harvest for price features later.
Questions & memory (the ask-once loop)
threaded throughout
Approach
Questions are generated only from ledger entries — never invented. Ranked by dollar impact (what's blocked + how much the answer swings the plate). Deduped so nothing is ever asked twice. Friction budget: median ≤3 questions per recipe.
What the data said
ConvMissing's 62.7K real-world failures are the calibration set — build the question taxonomy against what kitchens actually hit, frequency-ranked, before a single user exists.
05The fleet, in numbers
80%
of purchase lines carry a vendor product code — matching is a join first
1
cost method in real use (of 11) — port one + dispatcher
575
real yield entries fleet-wide; 71% at 3 sites — seed + chef review, not migration
62.7K
logged conversion failures (ConvMissing) — the ask-once starter inventory
0
sites using menu-price slots 2/3, or legacy labor — stub both
6,600
items with enough history for price-trend series — the alert engine's fuel