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
2 changes: 1 addition & 1 deletion guides/nutrition-calculation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|---------|
Expand Down
8 changes: 4 additions & 4 deletions guides/onboarding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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

Expand All @@ -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.

<Note>
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.
</Note>

## Four ways to collect the profile
Expand Down
2 changes: 1 addition & 1 deletion guides/safety.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-----------------|---------|--------------|
Expand Down
58 changes: 48 additions & 10 deletions scripts/check-engine-constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<key>[a-z]+_(?:g|mg|ml)_per_hr)\"?\s*[:=]\s*(?P<val>-?\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",
Expand Down Expand Up @@ -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)


Expand Down