hal/zine
← back to builds
18 SEP 2026 · build · in progress

A really good fitness tracker

One store for food, training and body data — and a calorie target inferred from what the body actually did, instead of predicted from a formula about it.

stack Python · SQLite · MCP · launchd materials github.com/vladnicolaegligor/adaptive-fitness-tracker started September 2026 status in progress

1. What it does

One system for intake and training, accurate enough to set a daily calorie target from.

intake
Free text in, macros out — labelled food and unlabelled food alike, both resolving to grams.
weight
Weigh-ins kept as a series and fitted as a trend, with the daily target derived from that fit rather than set by hand.
training
Every set: exercise, load, reps, and how close to failure it went.
device
Sleep, HRV, steps, heart rate and burn, pulled from the watch on a schedule.
store
One database, queried directly.
reports
A day at a time and a window at a time, over intake, weight, training and recovery.

2. Why not an app

calorie apps
Every item has to be searched for, every time. Entries are brand-specific, so the same food appears a dozen ways. Barcodes often don't resolve. Food cooked at home has no representation at all.
exercise apps
Warm-up sets aren't counted. Exercises are missing depending on the app, and variations of the ones present are missing more often.
smartwatch
Calorie estimates are poor and weight training is hard for it to read. Good at walking and running, which is not the only use case.
no adaptation
Nothing takes the scale, the intake, the burn and the sessions together and adjusts the target from them. The number is set once and left.
opaque models
No way to see which prediction model an app uses, or to check it against your own numbers.

Based on previous tries with the apps, my conclusion is that most of the time those are closed systems built around a formula. The fix is not a better formula but an adaptive mechanism.

3. How it works

Maintenance is not predicted from body dimensions. It is solved backwards from what the body did with the food that went in. Over a rolling window there are two quantities worth trusting — what was logged as eaten, and what the scale did:

TDEE = mean_intake − (weight_slope_kg_per_day × 7700)

7700 kcal is the standard fat-equivalent constant for a kilogram of tissue. The target is that inferred maintenance minus a deficit sized as a percentage of bodyweight per week.

Macro estimates are wrong by some unknown amount and the watch’s burn figure is wrong by another. Neither has to be correct, because both are already inside the weight response being fitted: a systematic bias in the intake estimate shifts the inferred maintenance by the same amount and cancels at the target. The estimate has to be consistently inaccurate, not accurate. That is also why the watch does not feed the fit — sleep, HRV, steps and burn are stored and shown, and none of them are inputs.

the fit

The slope is an ordinary least-squares fit over the weigh-ins, run against elapsed days rather than sample index — weigh-ins are not daily, and treating a four-day gap as one step inflates the slope:

slope     = Σ(x−x̄)(y−ȳ) / Σ(x−x̄)²        x = days since first weigh-in
trend_kg  = intercept + slope · span       # the fitted endpoint, not the last reading
TDEE      = mean_intake − slope · 7700

The target stands on the fitted endpoint rather than this morning’s number, because a single weigh-in carries about a kilo of water either way.

The slope also comes with a standard error, and that is the part that decides how much the fit is allowed to say:

residual = √( Σ(y − ŷ)² / (n−2) / Σ(x−x̄)² )
floor    = 0.3 kg / √Σ(x−x̄)²        # the scale cannot resolve finer than this
stderr   = max(residual, floor)

The floor matters more than the residual early on. A short series of weigh-ins that happens to line up tidily produces a near-zero residual and therefore a claim of near-perfect certainty, which is false: hydration, glycogen and gut content move the scale by more in a day than fat does in a week. The floor relaxes on its own as weigh-ins accumulate — roughly 0.095 kg/day over five daily points, 0.006 over thirty — so nothing has to be switched off later.

Multiplying that standard error by 7700 turns it into the error bar on maintenance itself, and early on it is brutal. At five weigh-ins my slope was −0.020 ± 0.096 kg/day — the 95% interval spanned zero, so the trend was not distinguishable from no weight change at all. Dropping any single weigh-in from that series moved the inferred TDEE by up to 885 kcal. The estimator is not wrong there; it is honestly saying it does not know yet.

Which is why the formula everyone else uses is still in the system, as a prior rather than an answer. Mifflin-St Jeor with an activity factor gives a maintenance estimate carrying about 15% relative error, and the two estimates are combined by inverse-variance weighting — each counts in proportion to its own precision:

w_adaptive = 1 / stderr_adaptive²
w_prior    = 1 / stderr_prior²
combined   = (tdee·w_adaptive + prior·w_prior) / (w_adaptive + w_prior)

Nothing switches over on a date or a threshold. The adaptive standard error shrinks as weigh-ins accumulate and the prior’s does not, so control transfers by itself — about 19% adaptive weight at five weigh-ins, past 90% at a month. At eight weigh-ins over nine days it sits at 67%, and the worst single-point swing is down to 122 kcal.

logging

The write path is a set of tools rather than a screen. The system exposes its writes as MCP tools and I log through Claude Code: I type what I ate or what I lifted as a sentence, and the parsing, the gram resolution and the row writes happen behind it.

It is not tied to the desk. The same session is driven from my phone with /rc in chat, so a meal is logged standing in the kitchen with the packet in hand rather than remembered until I am back at the machine — which is the difference between a label figure and a guess.

The cost of that is real and worth stating. There is a nondeterministic model in the write path every time I log a banana. It takes a few seconds rather than being instant, it costs tokens, and it gets things wrong — a plant milk entered at 5.0g protein per 100ml when the label said 3.1, onion rings carried at a generic 276 kcal/100g when the packet said 220. Both were caught by photographing the label and re-logging. A form would not have made those mistakes. A form also would not have let me say “120g soya texturate boiled with a stock cube, 4g olive oil, 50g broccoli” and have it come back as five weighed components with the stock cube flagged as the one figure that is a guess.

There are twenty tools, in two groups. Twelve cover logging and reading back: a weigh-in, a meal, a session with its sets, micronutrients against a meal already logged, a day, a range of days, a correction, a merge. Eight cover food composition: search the reference table, store a food from its label, import one from CIQUAL, fill in the micros a label left unsaid, add a weighed component to a meal, rebuild that meal from its components. Each is an ordinary function with a decorator — the signature is the schema the model is held to, and the docstring is the instruction it reads before calling:

@mcp.tool()
def health_log_workout(date: str, type: str, duration_min: int | None = None,
                       rpe: int | None = None, notes: str | None = None,
                       sets: list[dict] | None = None) -> dict:
    """Log a training session. type e.g. strength|run|bike|swim|walk. rpe 1-10.

    sets is a list of {exercise, reps, weight_kg, rpe} — one entry PER SET, so
    3x5 bench is three entries. Call health_known_exercises first and reuse an
    existing exercise name where it matches: a new spelling forks the trend."""

Several tools exist only to stop drift. health_known_exercises returns the names already in use and the model is told to check it before writing; health_known_nutrients does the same for the nutrient vocabulary. Neither repairs a fork that already happened, so there is also a merge for two spellings of one lift, and a rule that refuses to store one reference food twice under two names.

The meal tool keeps raw — verbatim what I said — beside the macros, which are stored flagged as an estimate with a confidence level. That is what makes a meal redoable later against better data.

training

Training has no catalogue. An exercise exists the moment I name one, and the only discipline is reusing the spelling. Nothing can be missing from a list that was never fixed in advance, and a variation is just a name I have not used yet.

A session is a row and every set is its own row under it — exercise, load, reps, proximity to failure. Three sets of squats is three rows, not 3×5 @ 70, because collapsed into one the third set cannot differ from the first.

Warm-ups are not stored as warm-ups. The load pattern within an exercise already says which sets were ramps, so it is derived at read time. It is a heuristic: loads that climb monotonically read cleanly, but a back-off set taken after a top set will be read as a warm-up, because from the numbers alone that is what it looks like.

food

For anything that comes in a packet, I photograph the label. The panel is read off the photo and stored per 100g against that product, so the item is entered once and reused.

For everything without a label — vegetables, oil, anything cooked at home — the composition comes from CIQUAL, the French food composition table published by ANSES: a few thousand foods with full nutrient profiles, measured in laboratories and maintained by a public agency rather than submitted by users. The entries are in French, and Laitue, crue is one row rather than forty user-submitted variants with forty different calorie counts.

Neither source alone is enough. An EU label is authoritative for macros — it is the actual formulation, not an average of similar products — but EU labels declare almost no micronutrients. A day containing mozzarella, yogurt, whey and breaded cheese reported 348 mg of calcium, which was not a measurement: four dairy items, none of which prints calcium on the packet.

So the two are blended inside one food. The label keeps the macros and whatever micros it states, CIQUAL’s generic equivalent fills only what the label left unsaid, and a micro the label declares always wins. The row records which CIQUAL food the borrowed micros came from, so the blend is not silent. Provenance then drives confidence: each source carries one, a borrowed micro caps its food at medium however good the label is, and a meal inherits the weakest component it contains.

SOURCE_CONFIDENCE = {"ciqual": "high", "label": "high", "usda": "high",
                     "off": "med", "estimate": "low"}

Macros are computed from the stored composition rows and the weight of each component rather than estimated at the level of the meal. A meal recorded as 34g lettuce, 22g spinach can be re-derived when the food data underneath it improves — a better label, a CIQUAL entry replacing an estimate. A meal recorded as one flat number cannot.

the watch

The watch is a Garmin Venu 3S, and it is the one source that writes itself. A scheduled job runs every morning and pulls the last three days rather than yesterday alone, because Garmin revises sleep, HRV and Body Battery after the fact. Every write is an upsert, so running it twice changes nothing. The date stored is Garmin’s own calendar date, never derived from a timestamp, because sleep spans midnight.

It lands in its own tables, parallel to the hand-logged ones, and a session links to its activity by id rather than by date — one day last week held six walks in both tables, which a date join cannot tell apart. Linking copies across the three numbers only the watch has: average heart rate, peak heart rate, and its estimate of the burn.

CREATE TABLE garmin_activity (
  garmin_id    INTEGER PRIMARY KEY,   -- Garmin's own id: a re-pull is a no-op
  date         TEXT NOT NULL,         -- from startTimeLocal, so a 23:00 session
  start_local  TEXT,                  -- lands on the day it felt like
  type         TEXT,                  -- strength_training | walking | running
  duration_min REAL,
  avg_hr       INTEGER,
  max_hr       INTEGER,
  calories     INTEGER,
  raw_json     TEXT,
  synced_at    TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

A third table, from a separate endpoint, holds the inside of a strength session: each set’s duration and the rest that followed it. One session last week held 31 minutes of rest, between 40 seconds and four minutes, which nothing else in the system recorded.

The division is by what each source can know. The watch holds what I cannot report about myself — heart rate, how long each set took, how long I rested. The log holds what only I can say: which exercise, how many reps, how much weight, how close to failure. The watch will also take reps and loads and it stores them, but as provenance rather than as the record. Where the two differ — a warm-up recorded five kilos light, a working set ten kilos light — the log wins and the difference is left visible.

the store

Everything lands in one SQLite file, the same one that holds my notes and my todos. Thirteen tables: what I report (body_log, meal_log, workout_log, workout_set), what a meal is made of (meal_component, meal_nutrient), the reference data those point at (food, food_nutrient, and two CIQUAL staging tables), and the watch’s three.

Chat is the interface, and a model that has been running for an hour is exactly the thing that logs lunch twice. The constraints that stop it live in the schema:

UNIQUE(date, slot, raw)                     -- a meal, by what and when
date TEXT NOT NULL UNIQUE                   -- one weigh-in per day
UNIQUE(workout_id, exercise_slug, set_no)   -- one set, one position

slot   TEXT NOT NULL CHECK(slot IN ('breakfast','lunch','dinner','snack'))
source TEXT NOT NULL CHECK(source IN ('ciqual','usda','off','label','estimate'))
rpe    INTEGER CHECK(rpe IS NULL OR (rpe BETWEEN 1 AND 10))

Micronutrients are key-value rows against a fixed vocabulary with canonical units, so a write in a unit the vocabulary does not name is rejected rather than stored and silently unsummable. Every table is created by a numbered migration and nothing in the application issues a CREATE TABLE of its own. There is no ORM: the queries are SQL against tables whose definitions are the documentation.

4. What breaks, and what stops it

Most of the engine is not the fit. It is the refusals around it.

settling
Under two weeks of span the trend is mostly water. Below that the fit is marked unsettled and the deficit is held where it is rather than moved on a number that has not earned it.
rate ceiling
Loss is targeted as a percentage of bodyweight per week and capped at 1%. Above that the weight still comes off and stops being mostly fat.
two floors
An absolute kcal floor and a relative one at 65% of inferred maintenance, whichever binds first. When a floor binds, the deficit gives way rather than the floor.
not the watch
Deliberately not floored on Garmin's resting-burn figure. It reports 2114 kcal for me; solving Mifflin backwards for that gives a height of 233 cm, so it is not a basal rate and binding the target to it would reintroduce the error the design exists to absorb.
protein
Set per kilogram of bodyweight and never reduced to make the calories work. The deficit comes out of carbohydrate and fat.
stall guard
If the trend flattens for ten days the target stops being lowered automatically, because the next move is a decision rather than an arithmetic step.

The invariant: a session’s burn is never added on top of the target. It is already inside the inferred maintenance because it is already inside the weight the fit reads, so crediting it again spends the deficit twice. The report the engine returns has no field for an exercise bonus and no way to attach one — the burn enters the calculation once, through the weight response, or not at all.

The arithmetic sits in modules with no database handle, no clock and no network — they take numbers and return numbers. Every figure in a report can be recomputed from the weigh-ins and the intake days with a calculator. That is the half the model never touches.

Things that have actually gone wrong:

the fork
Two rows in food — olive-oil and olive-oil-evoo — both holding CIQUAL code 17270, identical in every column. The upsert conflicted on slug alone, so a second name for an already-imported code forked the food. Fixed by treating the CIQUAL code as the identity; the duplicate is gone.
phantom micros
The 348 mg calcium day. Not a bug in the code — the code faithfully summed what the labels declared, which was nothing. The fix was provenance and the CIQUAL blend.
bad estimates
Meals logged on the model's guess and corrected once the label was photographed: plant milk at 5.0g protein per 100ml against an actual 3.1, onion rings at 276 kcal/100g against a packet figure of 220. Both meals were deleted and re-logged rather than amended, which is why the corrections are visible in the row history.
a key in prose
Sessions were linked to watch activities by writing "Garmin activity 24382544432" into the notes field. Correct and unqueryable. It is a column now; thirteen of sixteen sessions were recovered by parsing that text.

5. Where it is now

Running: the MCP tools, the food composition layer, the Garmin sync on a morning schedule, and the adaptive target. Nine days of intake, eight weigh-ins. The engine’s read on a sample series — the arithmetic is real output from the code in the repo, the body is not mine:

inferred maintenance   2612 kcal      (adaptive 2678, prior 2477)
adaptive weight        67%            precision-weighted, rising
target                 1923 kcal      maintenance − 689
protein                150 g
trend                  83.51 kg       slope −0.0904 kg/day  (±0.0336)
floor                  1698 kcal      not binding
settled                false          9-day span, needs 14
hold_deficit           true           target held until the fit settles

So it has not yet earned the right to move the deficit on its own, and says so. The stated rate is 0.8%/week and the observed slope is running at 0.76%/week, which is inside the ceiling — but it is nine days of data, and the engine is right not to act on it yet.

Nine days as the range tool returns them:

date         kcal   protein   weight   workouts
2026-03-02   2010     151.0    84.30      1
2026-03-03   1885     146.0    83.95      1
2026-03-04   2060     138.0    84.15      0
2026-03-05   1940     152.0    84.55      1
2026-03-06   1990     149.0    83.90      0
2026-03-07   2150     121.0        —      0
2026-03-08   1820     144.0        —      2
2026-03-09   1975     155.0    83.60      1
2026-03-10   2005     150.0    83.70      3

The gaps are real gaps — days without a weigh-in come back empty rather than carried forward, and 7 March is a day protein came in 29 grams short. Both are things the report is supposed to show rather than smooth.

Not done: micronutrient coverage is thin and mostly low-confidence, because most labels declare nothing and the CIQUAL gap-fill is applied per food by hand rather than automatically. Weighed components are used on a minority of meals; the rest are still whole-meal estimates.

What I want next, roughly in that order. Fitness and nutrition advice stored with its sources, so a recommendation carries the study or the label it came from and can be re-read later instead of being a sentence I have to take on trust. Medical visits and lab results folded into the same store — the consultations already exist as notes and are joined to none of this, which is the obvious gap when the whole point was one place to ask questions from. And better reminders: the system knows the protein gap at six in the evening and does nothing with it.

The repo linked at the top has the schema as a structure-only SQL file — thirteen tables, no rows — along with the energy engine and the food layer, the migrations in the order they ran, the MCP tool definitions, and the Garmin sync. The CIQUAL tables ship empty: that composition data is ANSES’s to distribute, not mine. Neither is my own data in there.

· § ·