# SalumiLab — Engine Specification

Five calculators, one shared formula module (`assets/curing.js`), one test suite
(`tests/curing.test.js`, run with `node tests/curing.test.js`). Static HTML/CSS/JS,
no framework, no build step. Each calculator is one page.

House rules this spec enforces:

- **Zero guesswork** — every engine takes what the user knows and returns an exact number.
- **Conservative by default** — the lowest applicable USDA FSIS figure is the default dose.
- **ppm on every cure calculation** — delivered nitrite (and nitrate when present) is always
  shown next to the USDA limit, never grams alone.
- **Refuse, don't warn, above the limit** — the dry cure and brine engines will not return
  amounts for a target above the legal maximum.
- **Contested guidance is footnoted** on the page, never silently decided. Items marked
  ⚠︎ below are the flagged uncertainties.
- **Disclaimer on every page**: *This is a calculator, not a tested recipe. When in doubt,
  follow an established recipe from a trusted source.*

---

## 0. Shared constants

### 0.1 Atomic weights and molar masses

IUPAC/CIAAW standard atomic weights, abridged table (CIAAW 2021), five significant figures.

| Element | g/mol |
|---|---|
| N | 14.007 |
| O | 15.999 |
| Na | 22.990 |
| Cl | 35.45 |
| K | 39.098 |

| Compound | Sum | g/mol |
|---|---|---|
| NaNO₂ | 22.990 + 14.007 + 2(15.999) | 68.995 |
| NaNO₃ | 22.990 + 14.007 + 3(15.999) | 84.994 |
| KNO₂ | 39.098 + 14.007 + 2(15.999) | 85.103 |
| KNO₃ | 39.098 + 14.007 + 3(15.999) | 101.102 |
| NaCl | 22.990 + 35.45 | 58.44 |
| NO₃⁻ | 14.007 + 3(15.999) | 62.004 |

Used for: the saltpeter (KNO₃) molar sodium-nitrate equivalent,
`NaNO₃-eq = KNO₃ g × 84.994 / 101.102 = 0.8407 × KNO₃ g`, and the nitrate-ion fractions
quoted on the ppm checker (NaNO₃ 73.0%, KNO₃ 61.3%).

⚠︎ The FinsFlora SPEC.md was not available in this repository; values above follow the
CIAAW abridged table directly. Reconcile against FinsFlora's table if it uses a different
precision.

### 0.2 Curing salts (mass fractions)

| Cure | NaNO₂ | NaNO₃ | NaCl |
|---|---|---|---|
| Cure #1 (Prague Powder / Instacure #1) | 0.0625 | 0 | 0.9375 |
| Cure #2 (Prague Powder / Instacure #2) | 0.0625 | 0.0400 | 0.8975 |
| Custom | from label | from label | 1 − nitrite − nitrate |
| Morton Tender Quick (ppm checker preset) | 0.005 | 0.005 | — |

⚠︎ Brand formulations vary; every engine offers a custom cure. Tender Quick values are from
Morton's published label — the page tells the user to confirm against theirs.

### 0.3 USDA FSIS ingoing limits

Sources: **9 CFR 424.21(c)** (curing agents), **9 CFR 424.22(b)** (bacon), **USDA FSIS
Processing Inspectors' Calculations Handbook (FSIS Directive 7620.3, 1995)**.

Regulation text is per 100 lb of meat; 100 lb = 1,600 oz, so ppm = oz / 1600 × 10⁶.

| Key | Product | NaNO₂ ppm | NaNO₃ ppm | Derivation |
|---|---|---|---|---|
| `comminuted` | Sausage / salami | **156.25** | **1,718.75** | ¼ oz and 2¾ oz per 100 lb |
| `dry` | Whole muscle, dry rub | **625** | **2,187.5** | 1 oz and 3½ oz per 100 lb |
| `immersion` | Immersion or pumped | **200** | **700** | 2 lb / 7 lb per 100 gal pickle at 10% pump (FSIS handbook figures) |
| `baconBrine` | Bacon, pumped/immersion | **120** | not permitted | 9 CFR 424.22(b) |
| `baconDry` | Bacon, dry cured | **200** | not permitted | 9 CFR 424.22(b) |

- **Minimum:** 120 ppm ingoing nitrite for cured "Keep Refrigerated" products unless safety
  is assured otherwise (FSIS handbook, 1995). Used as the low-side warning.
- **House default dose:** 156.25 ppm on every product (the comminuted limit = 0.25% Cure #1).
  Bacon brine defaults to 120.
- ⚠︎ **Rounding:** FSIS publishes 156 / 1,718 / 625 / 2,187. We use the exact regulatory
  fraction (156.25 etc.) so the standard 0.25% Cure #1 dose reads "at the limit" rather than
  0.25 ppm over. Footnoted on every cure page.
- ⚠︎ **Whole-muscle default:** the legal ceiling for dry-rubbed whole muscle is 625 ppm and
  some traditional recipes dose above 156. We default to 156.25 and let the user raise it to
  625. Footnoted as contested.
- ⚠︎ **Potassium salts:** 9 CFR 424.21(c) lists "sodium or potassium" nitrite/nitrate against
  one mass figure. The checker adds KNO₃ to nitrate **by mass** (conservative: KNO₃ carries
  less nitrate per gram) and also displays the molar NaNO₃ equivalent. Verify against the
  current eCFR text before relying on this for commercial work.
- **Residual cap** (not calculable): 424.21 also limits finished-product nitrite to 200 ppm.
  Footnoted.
- Bacon also requires 550 ppm ascorbate/erythorbate commercially (424.22(b)); footnoted, not
  calculated.

### 0.4 Core ppm formula (FSIS)

```
ppm = agent grams / basis grams × 10⁶
agent grams = cure grams × fraction of agent in the cure
cure grams for a target = target ppm × basis grams / (10⁶ × nitrite fraction)
```

Basis is the **green weight** of the meat, except for equilibrium brine (meat + water) and
pumped brine (below).

Rounding: cure grams are **floored** to 0.01 g (`floor2`), and delivered ppm is recomputed
from the floored grams, so the number the user weighs never exceeds the dose shown. Other
ingredients are shown to 0.01 g under 100 g, 0.1 g above.

Status (`assess`): `over` if nitrite > limit or nitrate > limit (ε = 10⁻⁶);
else `low` if nitrite < 120; else `ok`.

### 0.5 Units

1 lb = 453.59237 g exactly; 1 oz = 1/16 lb. °F = °C × 9/5 + 32.

---

## 1. Dry cure calculator — `dry-cure.html`

**Inputs:** meat weight M (g/kg/lb/oz, trimmed boneless), product (`dry`, `comminuted`,
`baconDry`), cure (#1, #2, custom %), target nitrite ppm (default 156.25), total salt % of
meat (defaults: whole muscle 3.0, ground 2.75, bacon 2.5), sugar % (default 0.5), spices
(name, g/kg).

**Formulas:**
```
cure_g     = floor2(target × M / (10⁶ × f_nitrite))
nitrite    = cure_g × f_nitrite / M × 10⁶
nitrate    = cure_g × f_nitrate / M × 10⁶
salt_total = salt% / 100 × M
salt_cure  = cure_g × f_salt
salt_add   = max(0, salt_total − salt_cure)
sugar_g    = sugar% / 100 × M
spice_g    = g_per_kg × M / 1000
```

**Outputs:** grams and g/kg of salt to add, cure, sugar, each spice; total salt % and the
share from the cure; delivered nitrite (and nitrate for Cure #2 / custom) ppm with meter and
verdict against the product's limit.

**Guards:** refuse if target > limit, if nitrate > limit, or if nitrate is used in bacon.
Warn if nitrite < 120 ppm, salt < 2.5%, cure < 10 g (use a 0.01 g scale), or cure salt alone
exceeds the salt target.

**Footnoted:** limit derivation and rounding; whole-muscle default (contested); Cure #1 vs #2
(contested); salt range 2.5–3.5%; weigh trimmed (bone-in over-reads meat, under-reads ppm);
residual 200 ppm cap; cure time not calculated (contested).

---

## 2. Equilibrium brine — `brine.html`

**Inputs:** meat M, water W (units each), product (`immersion` or `baconBrine`), cure,
target nitrite ppm (default 156.25; bacon 120), equilibrium salt % of M + W (default 2.0),
sugar % of M + W (default 1.0).

**Formulas:**
```
T          = M + W
cure_g     = floor2(target × T / (10⁶ × f_nitrite))
nitrite    = cure_g × f_nitrite / T × 10⁶
salt_add   = max(0, salt% / 100 × T − cure_g × f_salt)
salt_tot%  = (salt_add + cure_g × f_salt) / T × 100
brine%     = (salt_add + cure salt) / (W + salt_add + cure salt) × 100
sugar_g    = sugar% / 100 × T
```

**Derivation of ppm on T:** at equilibrium every gram of meat + water carries the same
concentration, so the meat holds M/T of agent N. Relative to meat green weight:
(N × M/T) / M = N / T. Ignores water uptake by the meat and nitrite consumed by curing
reactions — both push the real value down, so N/T is the conservative (high) estimate.

**Outputs:** grams of salt, cure, sugar to dissolve; **total salt % of combined weight**;
day-one brine strength; delivered nitrite ppm at equilibrium vs. limit.

**Guards:** refuse above the limit; refuse nitrate in bacon.

**Footnoted:** immersion and bacon limits; ascorbate requirement; equilibrium assumption;
equalization time (contested, not calculated); bone takes no cure; refrigerate below 4 °C.

---

## 3. ppm safety checker — `ppm-checker.html`

The reverse tool: "did I mess this up?"

**Inputs:** method (ground / rubbed / equilibrium brine / pumped brine), bacon flag, meat M,
water W (equilibrium), total brine B and pump % P (pumped), and grams used of: Cure #1,
Cure #2, other cure (+ label %s or Tender Quick preset), pure NaNO₂, pure NaNO₃, KNO₃
(saltpeter), salt (optional).

Limit key: ground → `comminuted`; rubbed → `dry` / `baconDry`;
brine → `immersion` / `baconBrine`.

**Formulas:**
```
nitrite_g = Σ cure_g × f_nitrite + NaNO₂_g
nitrate_g = Σ cure_g × f_nitrate + NaNO₃_g + KNO₃_g          (by mass, conservative)
nitrate_molar_eq_g = Σ cure_g × f_nitrate + NaNO₃_g + KNO₃_g × 84.994/101.102

factor = 10⁶ / M                 mixed in / rubbed
       = 10⁶ / (M + W)           equilibrium brine
       = (P/100) / B × 10⁶       pumped — FSIS: % pump × % agent in pickle × 10,000
ppm = grams × factor
salt% = salt_g / M (or / (M + W) for equilibrium) × 100
```

**Dilution rescue** (ground, mixed in, over the limit only):
```
M' = max(nitrite_g × 10⁶ / limit_nitrite, nitrate_g × 10⁶ / limit_nitrate)
extra meat = M' − M
extra salt = extra meat × salt_g / M
```

**Outputs:** delivered nitrite and nitrate ppm vs. limit with verdict; bacon-nitrate flag;
molar equivalent when saltpeter is used; dilution amounts; totals and salt %.

**Footnoted:** limits and 120 ppm minimum; equilibrium/pump basis; saltpeter handling;
spoons are not grams (no volume conversion — density varies); no fix for over-cured whole
muscle — discard.

---

## 4. Weight-loss tracker — `weight-loss.html`

**Inputs:** item name, product (whole muscle / salami / custom), target % loss (default 35),
hang date, start weight + unit, weigh-ins (date, weight). Multiple items; stored in
`localStorage` only (wrapped in try/catch; the page works without it).

**Formulas** (t = days since hang, UTC-date arithmetic):
```
L_i = 1 − W_i / W₀
target weight = W₀ × (1 − target%/100)

Projected (diffusion, L = a·√t, least squares through origin):
  a = Σ(√t_i · L_i) / Σ t_i
  t* = (L_target / a)²

Current pace (last two weigh-ins):
  r = (L_n − L_{n−1}) / (t_n − t_{n−1})
  t_pace = t_n + (L_target − L_n) / r        (earliest plausible — drying decelerates)
```

**Derivation:** in the falling-rate period, moisture loss is diffusion-limited. The
short-time solution of Fick's second law for a slab gives cumulative loss ∝ √t (Crank,
*The Mathematics of Diffusion*). Fitting L = a√t through the origin with x = √t gives
a = Σ x L / Σ x² = Σ √t L / Σ t.

⚠︎ Late-stage drying slows further than √t as the product approaches equilibrium with the
room, so the projected date can still be early. Stated on the page; readiness is target
loss **and** even firmness.

**Outputs:** table of weigh-ins with day and % loss; target weight; loss so far; projected
and current-pace dates; at-target / stalled / low-confidence (< 3 points) states; SVG loss
curve with target line and projected curve, per-point hover.

**Footnoted:** ⚠︎ target loss (30–40% typical, contested); weight loss ≠ water activity
(FSIS shelf-stable: aw ≤ 0.85, or pH ≤ 5.0 with aw ≤ 0.91); ⚠︎ start-weight convention
(hang weight vs. green weight, contested).

---

## 5. Fermentation planner — `fermentation.html`

**Inputs:** batch weight, °C/°F, culture (fast / traditional / packet), culture dose g/kg
(default 0.25), dextrose % (fast 0.5, traditional 0.3), fermentation temperature (fast 24 °C,
traditional 20 °C), RH (default 90%). pH gate: stages (hours, temperature), latest pH.

**Formulas:**
```
culture_g  = dose × M / 1000
dextrose_g = dex% / 100 × M

Degree-hours (AMI 1997, referenced by FSIS):
  DH = Σ hours_i × max(0, T_i(°F) − 60)
  limit = 1200 if T_max < 90 °F; 1000 if 90 ≤ T_max ≤ 100; 900 if T_max > 100
  max hours at constant T = limit / (T − 60)

pH gate (PH_GATE = 5.3):
  fail  if DH > limit                 (discard — S. aureus toxin is heat-stable)
  pass  if DH ≤ limit and pH ≤ 5.3    (move to drying)
  wait  otherwise; hours left = (limit − DH) / (T_last − 60)
```
Example: 24 °C = 75.2 °F → 1200 / 15.2 = 78.9 h to reach pH 5.3.

**Drying schedule** (⚠︎ published practice, not a USDA requirement — footnoted):

| Stage | Temp | RH | Until |
|---|---|---|---|
| Settle | 13–15 °C | 85–90% | first week or ~10–15% loss |
| Dry | 12–15 °C | 80–85% | ~25% loss |
| Finish | 12–15 °C | 75–80% | target, typically 30–40% |

**Outputs:** culture and dextrose grams; fermentation temperature, RH, pH target, maximum
hours and degree-hour limit; drying schedule; gate verdict with degree-hours used.

**Footnoted:** degree-hour source; ⚠︎ culture dose varies by product (0.25 g/kg is common,
use the packet); ⚠︎ dextrose → pH has no reliable formula (measure); ⚠︎ drying schedule
(contested); out of scope: E. coli O157:H7 5-log requirement, Trichinella, pH meter
calibration.

⚠︎ The AMI GMP document is cited as referenced by FSIS; the exact FSIS guidance document
that incorporates it should be confirmed and linked before launch.

---

## Verification

`node tests/curing.test.js` checks: molar masses; cure fractions sum to 1; limit
derivations; 0.25% Cure #1 = 156.25 ppm; dry-cure grams, salt accounting, floor rounding,
refusal above limits; Cure #2 nitrate; equilibrium brine amounts, salt % and brine %; bacon
guards; checker for mixed, equilibrium, FSIS pump formula, saltpeter molar equivalent,
dilution; √t projection recovers a known curve; stall/reached states; degree-hours, max
hours and the three gate outcomes; unit conversions.
