Diary Validation Hardening — Design
Date: 2026-07-05
Status: Approved (brainstorm), pending implementation plan
Builds on: docs/superpowers/specs/2026-07-05-diary-context-mapping-design.md
Purpose
The diary context-mapping feature assumes a clean diary supplied by the caller.
Today's _validate_diary catches only missing columns, non-tz-aware timestamps,
and end <= start. Several malformed inputs slip through and cause silent wrong
results or opaque crashes instead of a clear error at the boundary:
- Null
context→groupby('context')silently drops the row; the column vanishes with no error. NaTinstart/end→ passes the dtype andend > startchecks, then silently matches nothing during masking (comparisons withNaTare alwaysFalse).pd.NA/ non-listactivities→ crashes deep inside_context_maskwith aTypeErroronlen(...).- Non-string / empty
context→ produces a nonsense column likecontext__orcontext__123. - Typo'd activity label (e.g.
'sleeping') → silently matches nothing.
Cleaning the diary remains the caller's responsibility (out of scope). This work
makes _validate_diary/context the boundary guard that converts every such
problem into a clear ValueError.
Principle
Strict: raise on everything. Every detected problem is a ValueError with a
specific, actionable message. Consistent with the existing strict timezone rule.
No warnings. The one normalization applied is stripping surrounding whitespace
from context names (see context() below) — a padded name is a formatting
nuisance, not an error, and stripping also lets ' work ' and 'work' collapse
into a single column instead of splitting.
Architecture
Keep the existing diary/df split:
Exposures._validate_diary(diary)— diary-only structural checks (needs nodf). Gains the new NaT / context / activities checks, including validating activity labels againstsettings.ACTIVITIES(already imported inexposures.py).Exposures.context(df, diary)— the checks that relate the diary to thedf(activity column present, timezone match on bothstartandend, no column collision), added before the annotation loop.Exposures._context_mask— its "no gate" detection is generalized fromfloat-NaN-only to any scalar NA (pd.api.types.is_scalar(x) and pd.isna(x)), matching what_validate_diarynow accepts. Without this, a validation-approvedpd.NAactivities cell would reachlen(pd.NA)and raiseTypeError, breaking the ValueError-only contract. Its defensiveNone/NaN/pd.NA/empty-list handling keeps it robust and independently testable.
Rejected alternatives: passing df into _validate_diary (muddies the "validate
the diary alone" boundary, makes it un-callable without a df); a separate
_validate_context_call function (unneeded surface for a small feature).
Checks
_validate_diary(diary) — diary-only
In addition to the existing checks (required columns start/end/context;
start/end are pd.DatetimeTZDtype; every end > start), raise ValueError
on:
NaTinstartorend— any missing timestamp. Message names the column.contextinvalid — a value that is null (NaN/None), not astr, or empty/whitespace-only afterstr.strip().activitiesmalformed — for each row, the cell must be one of:- "no gate":
None, floatNaN, orpd.NA, or an empty list[]. - a gate: a non-empty
listwhose every element is astrpresent in theACTIVITIESvalues (the 13 canonical labels:non-wear, lie, sit, stand, shuffle, walk, run, stairs, bicycle, row, kneel, squat, fast-walk).
Anything else raises: pd.NA is fine (no gate), but a bare string, a number, a
tuple/set, a list containing a non-string, or a list containing an unknown
label all raise. The message names the offending value/label.
Note on missing detection: a cell is "missing" (no gate) when it is not a list and
pd.isna(cell) is True (covers None, NaN, pd.NA). Lists are checked as
gates. This ordering avoids calling pd.isna on a list (which returns an array).
context(df, diary) — diary↔df relationship
Before df.copy() and the loop, in addition to the existing index-tz-aware and
start-zone-match checks, raise ValueError on:
dfhas noactivitycolumn — required for both masking and the gate.endtimezone mismatch — extend the existingstart-vs-index zone check so bothstartandendzones must equal the index zone. (String comparison of tz as today.)- Column collision —
dfalready contains a column namedcontext__<name>for some (normalized) context in the diary. Prevents silently overwriting caller data.
Normalization (not a check): after validation, strip surrounding whitespace
from the context values before grouping — e.g. diary = diary.copy();
diary['context'] = diary['context'].str.strip(). This is done on the local copy
only (the caller's frame is never mutated). Because grouping and the
context__<name> column names then use the stripped values, ' work ' yields
context__work, and ' work ' + 'work' collapse into one column. The collision
check (6) runs against these normalized names.
Behavior notes & edge cases
- Empty list
activities([]) stays valid = no gate, matching the existing_context_maskbehavior and its tests. - Empty diary (zero rows, correct dtypes) still passes — every per-row check
is vacuous, and
context()returns the copy unchanged. Preserve this. _context_maskNA handling is generalized fromisinstance(activities, float) and pd.isna(...)topd.api.types.is_scalar(activities) and pd.isna(activities), sopd.NA(which validation accepts as "no gate") is handled by the helper too rather than crashing onlen(pd.NA).- Messages should be specific enough to fix the input (name the column, the row's value, or the unknown label).
Testing
New tests extend tests/test_exposures_context.py.
TestValidateDiary:
- NaT in start raises; NaT in end raises.
- null context raises; non-string context (e.g. 123) raises; empty/whitespace
context raises.
- activities as pd.NA → passes (no gate); as a bare string → raises; as a
number → raises; as a list with a non-string element → raises; as a list with an
unknown label ('sleeping') → raises; as a list of valid labels → passes.
- Existing valid-diary and []-as-no-gate cases still pass.
TestContext:
- df without an activity column raises.
- diary whose end is in a different tz than the index raises.
- df already containing a context__work column raises when the diary has a
work context.
- whitespace normalization: a diary context ' work ' produces a
context__work column (not context__ work); a diary mixing ' work ' and
'work' yields a single context__work column (their intervals unioned).
- Existing behavior tests (columns added, overlap, union, copy, empty-diary,
tz-mismatch, naive-index) still pass.
Open items for the implementation plan
None — the check-set and placement are fully specified.