diff --git a/guides/nutrition-calculation.mdx b/guides/nutrition-calculation.mdx
index 2fcb40e..67fd385 100644
--- a/guides/nutrition-calculation.mdx
+++ b/guides/nutrition-calculation.mdx
@@ -123,7 +123,7 @@ When you include an `athlete_id`, Saturday reads that athlete's stored profile:
### Confidence score
-The `safety.confidence_score` (0.0-1.0) is derived from how wide the prescription band is: a complete fueling profile scores 1.0, and every unanswered field widens the band and lowers the score. `precision.missing_fields` tells you which answers would raise it.
+The `safety.confidence_score` (0.0-1.0) reports how much of the athlete's fueling profile was answered: a complete fueling profile scores 1.0, and every unanswered field lowers the score. `precision.missing_fields` tells you which answers would raise it.
| Range | Meaning |
|-------|---------|
diff --git a/guides/onboarding.mdx b/guides/onboarding.mdx
index b0416e7..938436b 100644
--- a/guides/onboarding.mdx
+++ b/guides/onboarding.mdx
@@ -20,7 +20,7 @@ Calculation responses carry a `precision` object on every tier:
"missing_fields": [
{ "field": "sweat_level", "required": true,
"display_label": "how much you sweat",
- "band_impact": { "carb_g_per_hr": 0.4, "sodium_mg_per_hr": 86.1, "fluid_ml_per_hr": 79.0 } }
+ "band_impact": { "carb_g_per_hr": 0, "sodium_mg_per_hr": 100, "fluid_ml_per_hr": 100 } }
],
"message": "For exact numbers, a few key details are still needed: how much you sweat. Each one narrows the range.",
"onboarding": {
@@ -32,10 +32,10 @@ Calculation responses carry a `precision` object on every tier:
```
- **`profile_complete: true`** puts exact numbers in the response (`carb_g_per_hr` and friends).
-- **`profile_complete: false`** puts bands there instead (`carb_range_g_per_hr` and friends). Band width is measured by running the engine against each missing field at its plausible extremes, so `missing_fields` sorted most-impactful-first is your collection roadmap: the top entry is the question that buys the most precision.
+- **`profile_complete: false`** puts bands there instead (`carb_range_g_per_hr` and friends). `missing_fields` arrives sorted most-impactful-first, so it is your collection roadmap: the top entry is the question that buys the most precision.
- The trial clock starts at the athlete's first calculation carrying any real data. Zero-data calls never start it. Collect the profile first and the 30-day window delivers exact numbers rather than wide bands. See [Freemium Model](/guides/freemium-model#30-day-full-precision-trial).
-Each missing field carries a `display_label`, the plain-English name of the question ("how much you sweat"), so you can build the prompt without exposing an internal key. `precision.message` is written for an athlete to read and is safe to surface directly. `band_impact` is in per-hour units, and the numbers say how much narrower the band gets when that one field is answered.
+Each missing field carries a `display_label`, the plain-English name of the question ("how much you sweat"), so you can build the prompt without exposing an internal key. `precision.message` is written for an athlete to read and is safe to surface directly. `band_impact` is in per-hour units, rounded to the same increments the bands use, and says roughly how much narrower the band gets once that field is answered. Take `missing_fields` in the order it arrives rather than re-sorting on these numbers, since rounding can leave two fields tied.
### What "complete" means, twice
@@ -46,7 +46,7 @@ Two different completeness checks share a name, and mixing them up is the usual
The athlete's own `profile_complete` field, the one `?profile_complete=false` filters on and `athlete.profile_completed` fires for, covers the stored profile only: `sex`, `year_of_birth`, `weight_kg`, `sweat_level`, `saltiness`, `satiety_level`, `fitness_level`, `carb_experience`, `usual_carb_consumption`, and an answered concerns question.
-Of the profile fields, `sex`, `year_of_birth`, `weight_kg`, `sweat_level`, `saltiness`, `carb_experience`, and `usual_carb_consumption` are the safety core, and `missing_fields` marks them `required: true`. `satiety_level`, `fitness_level`, and `concerns` come back as `required: false` because each one moves the numbers less, but exactness needs all of them.
+Of the profile fields, `sex`, `year_of_birth`, `weight_kg`, `sweat_level`, `saltiness`, `carb_experience`, and `usual_carb_consumption` are the safety core, and `missing_fields` marks them `required: true`. `satiety_level`, `fitness_level`, and `concerns` come back as `required: false`, and exactness needs all of them.
## Four ways to collect the profile
diff --git a/guides/safety.mdx b/guides/safety.mdx
index a0fe97f..d4ea40a 100644
--- a/guides/safety.mdx
+++ b/guides/safety.mdx
@@ -117,7 +117,7 @@ Sending the same activity with a fuller profile is the fastest way to tell a gua
## Confidence score
-`confidence_score` (0.0-1.0) reports how much of the athlete's fueling profile was answered, derived from the width of the prescription band. A complete profile scores 1.0. It is a completeness signal, not a clinical risk score, and `precision.missing_fields` tells you which answers would raise it. See [Athlete Onboarding](/guides/onboarding).
+`confidence_score` (0.0-1.0) reports how much of the athlete's fueling profile was answered. A complete profile scores 1.0. It is a completeness signal, not a clinical risk score, and `precision.missing_fields` tells you which answers would raise it. See [Athlete Onboarding](/guides/onboarding).
| Confidence range | Meaning | Your display |
|-----------------|---------|--------------|
diff --git a/scripts/check-engine-constants.py b/scripts/check-engine-constants.py
index 90fe4e4..0d65ed0 100755
--- a/scripts/check-engine-constants.py
+++ b/scripts/check-engine-constants.py
@@ -38,6 +38,18 @@
B rate-adjacent a number touching a nutrient rate unit: g/hr, mg/hr, mL/hr,
g/L, mg/L, units/hr, g/hour.
+ B2 impact-resolution a band_impact value that is not a multiple of the step
+ its band renders on: 10 g/hr, 100 mg/hr, 100 mL/hr. A sample prescription in
+ a response example is a number the API hands the caller, which this file
+ already calls documentation. band_impact is not that. It is the engine's own
+ output difference between one field's two probe extremes, divided by twice
+ the caller's duration and scaled by a constant, so a reader with two
+ responses at different durations inverts it back to a first difference of
+ the engine along an axis they chose. Printing it on the band's own step
+ keeps the example honest and keeps the finer number off a public page.
+ Rule B cannot see any of this: the value follows the key, and the key spells
+ its unit in underscores. Found live in the onboarding example.
+
C nutrient ceiling a line naming a nutrient AND a cap word AND carrying a
number of two or more digits. Catches a ceiling stated without a unit, which
rule B cannot see. The nutrient word is what keeps rate-limiting.mdx out of
@@ -86,6 +98,20 @@
"rate-adjacent": re.compile(r"\d[\d,.]*\s?" + RATE_UNITS),
}
+# A per-hour field name spells its unit in underscores and the value follows the
+# key, so RATE_UNITS sees neither. Only band_impact values are checked; a sample
+# prescription elsewhere in a response example is a number the API hands the
+# caller, which this file already calls documentation.
+BAND_IMPACT = re.compile(r"band_impact", re.IGNORECASE)
+IMPACT_VALUE = re.compile(
+ r"\"?(?P[a-z]+_(?:g|mg|ml)_per_hr)\"?\s*[:=]\s*(?P-?\d+(?:\.\d+)?)"
+)
+# The steps the bands themselves render on (bandRoundCarb, bandRoundSodium,
+# bandRoundFluid in fuel-backend/pkg/api/precision.go).
+IMPACT_GRID = {"g": 10.0, "mg": 100.0, "ml": 100.0}
+# A JSON band_impact object may wrap; keep looking this many lines past the key.
+IMPACT_WINDOW = 6
+
NUTRIENT = re.compile(r"\b(?:carb|carbs|carbohydrate|sodium|fluid|hydration)\b", re.IGNORECASE)
CAP_WORD = re.compile(
r"\b(?:cap|caps|capped|ceiling|ceilings|absolute|maximum|upper limit|tops out|no more than)\b",
@@ -118,16 +144,28 @@ def scan(root):
found = []
for rel, full in published_files(root):
with open(full, encoding="utf-8", errors="replace") as fh:
- for line_no, line in enumerate(fh, 1):
- for rule, pattern in RULES.items():
- for m in dict.fromkeys(x.group(0) for x in pattern.finditer(line)):
- found.append((rule, rel, line_no, m.strip()))
- if NUTRIENT.search(line) and CAP_WORD.search(line):
- for m in dict.fromkeys(TWO_DIGITS.findall(line)):
- found.append(("nutrient-ceiling", rel, line_no, m))
- if BODY_WEIGHT.search(line):
- for m in dict.fromkeys(TWO_DIGITS.findall(line)):
- found.append(("body-weight-scaling", rel, line_no, m))
+ lines = fh.readlines()
+ impact_left = 0
+ for line_no, line in enumerate(lines, 1):
+ for rule, pattern in RULES.items():
+ for m in dict.fromkeys(x.group(0) for x in pattern.finditer(line)):
+ found.append((rule, rel, line_no, m.strip()))
+ if NUTRIENT.search(line) and CAP_WORD.search(line):
+ for m in dict.fromkeys(TWO_DIGITS.findall(line)):
+ found.append(("nutrient-ceiling", rel, line_no, m))
+ if BODY_WEIGHT.search(line):
+ for m in dict.fromkeys(TWO_DIGITS.findall(line)):
+ found.append(("body-weight-scaling", rel, line_no, m))
+ if BAND_IMPACT.search(line):
+ impact_left = IMPACT_WINDOW
+ if impact_left:
+ impact_left -= 1
+ for m in IMPACT_VALUE.finditer(line):
+ unit = m.group("key").rsplit("_per_hr", 1)[0].rsplit("_", 1)[-1]
+ grid = IMPACT_GRID.get(unit)
+ val = float(m.group("val"))
+ if grid and abs(val - round(val / grid) * grid) > 1e-9:
+ found.append(("impact-resolution", rel, line_no, m.group(0).strip()))
return sorted(found)