aiml
SearchMy learning
Menu

Lesson 6 of 19Module 2

Build historical feature snapshots

Join each prediction to feature values that were available at that time.

Keep original and corrected feature values. Reconstruct historical training rows using only the values available at each prediction time.

Prerequisites

Data availability and label delay; dataset validation; Python dictionaries and basic SQL. The feature computation is reused, not reimplemented.

Preparation and term definitions

By the end, you can

  • Distinguish the time a feature describes from the time its value became available.
  • Preserve later corrections without rewriting earlier historical reads.
  • Join observations by entity, definition, cutoff and publication time while retaining missing rows.
Runtime
Python 3.12+
Dependencies
Standard library only

Lab code, tests and fixtures (ZIP, 16 KB)Quiz check your understanding

Progress is saved in this browser.

Problem

When you need this. A feature job counts one event for a user and publishes the result at 12:30. A late event arrives, so a backfill publishes a corrected count of two at 14:00. Which value belongs beside a prediction made at 13:00?

The first value. Both counts describe the same window ending at noon, but the correction was unavailable at prediction. Selecting only by the window’s end would let tomorrow’s backfill change today’s training example.

You will retain both versions in SQLite and reconstruct four requests. The example continues the synthetic user-return event log from data availability. It reuses the existing count implementation and adds storage and retrieval rules. No model is trained.

Concepts

A feature cutoff is the end of the activity window a value describes. Publication time says when that value became available. A vintage is one publication for a cutoff; later corrections create another vintage rather than replacing the earlier one.

A point-in-time join reconstructs available information for a prediction. Both cutoff and publication must be eligible. After filtering, the newest cutoff wins; publication recency breaks ties within that cutoff. Missing means that no eligible value was available, which differs from a measured count of zero.

Architecture

Prediction observationuser-01 at 13:00Retained feature vintagescutoff 12:00 · published 12:30 · 1cutoff 12:00 · published 14:00 · 2Filter both clockscutoff ≤ 13:00published_at ≤ 13:00Value: 1cutoff 12:00published 12:30The 14:00 correction is retained.It cannot change a 13:00 historical read.Order eligible rows by newest cutoff, then latest eligible publication.Prediction observationuser-01 at 13:00Retained vintagesBoth cutoffs: 12:0012:30 publication → value 114:00 publication → value 2Filter both clockscutoff ≤ 13:00published_at ≤ 13:00Joined value: 1cutoff 12:00 · published 12:3014:00 correction is too late
One cutoff can have several publication vintages. Choose only values available at the original prediction, then retain the selected timestamps with the joined row.

The reused computation materializes a value and appends its feature, entity, cutoff and publication identity in one SQLite transaction. The query filters both times against the prediction and selects the newest eligible value. Python’s join() walks every observation and calls the parameterized SQL lookup once per observation. When the lookup finds no eligible row, that observation stays in the result with feature: None. The separate current projection is not a substitute for historical retrieval.

Working code

Give each published feature value two times

A cutoff is the end of the event window a value describes. A publication time records when that value became available to the application. A vintage is one publication of a value for a particular cutoff. Different vintages may have the same value; a different value is not required to make publication history meaningful.

For user_events_1d@v1, the window is [cutoff − 24 hours, cutoff). The original as_known computation also requires an event’s arrival strictly before cutoff. A later corrected computation can include late events available by its publication time. An event exactly at cutoff belongs outside either version’s window.

Download the complete lab and enter mlops-feature-history/:

Prepare the local feature-history labTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate

SQLite comes with Python. The ZIP contains the same eight synthetic events used in the prerequisite, the reused computation, a new history store and both exercises’ tests. This lesson uses the event records only; outcome-label readiness remains a separate condition for assembling a training dataset.

Append a vintage instead of replacing a historical row

History.materialize() computes values and writes them in one SQLite transaction. The history table’s composite primary key contains four fields: feature/version, entity, cutoff and published_at. A correction at 14:00 adds another row; it does not update the 12:30 row.

A second table records each materialization’s mode and input checksum. Repeating an identical publication is a no-op. Reusing its identity with a different input or mode raises ValueError. A newly inserted publication cannot precede an already recorded publication for that feature. Backfilling an old cutoff therefore uses its actual later publication time, rather than pretending the job ran in the past.

Inspect history.py, including the transactional writes
history.pySource · pythonDownload
"""Retain feature vintages; join only values published by the prediction time.

Synthetic UTC minutes and supplied publication times model a trusted writer.
This SQLite exercise is not a production event-time or publication service.
"""
import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from vendor.availability import instant, validate, check_id
from vendor.feature_math import Feature, compute

EPOCH = datetime(2026, 1, 1, tzinfo=timezone.utc)
COUNT = Feature('user_events_1d', 1, 'user', 1, 'count')


def minute(value):
    seconds = (instant(value) - EPOCH).total_seconds()
    if seconds < 0 or seconds % 60:
        raise ValueError('fixture timestamps must be whole minutes since 2026-01-01 UTC')
    return int(seconds // 60)


def nonnegative(value):
    if type(value) is not int or not 0 <= value < 2**62:
        raise ValueError('time and age must be bounded nonnegative integer minutes')
    return value


def source_rows(events):
    validate([], events, [])
    return [{'event': index, 'user': e['entity'], 'item': 'unused',
             'event_minute': minute(e['event_at']), 'ingested_minute': minute(e['arrived_at'])}
            for index, e in enumerate(events)]


class History:
    def __init__(self, path=':memory:'):
        self.db = sqlite3.connect(path)
        self.db.row_factory = sqlite3.Row
        self.db.executescript('''
            CREATE TABLE IF NOT EXISTS definitions (
                feature TEXT PRIMARY KEY, spec TEXT NOT NULL);
            CREATE TABLE IF NOT EXISTS batches (
                feature TEXT, cutoff INTEGER, published_at INTEGER, mode TEXT NOT NULL,
                source_sha256 TEXT NOT NULL,
                PRIMARY KEY(feature, cutoff, published_at));
            CREATE TABLE IF NOT EXISTS history (
                feature TEXT, entity TEXT, cutoff INTEGER, published_at INTEGER,
                value INTEGER NOT NULL, PRIMARY KEY(feature, entity, cutoff, published_at));
            CREATE TABLE IF NOT EXISTS online (
                feature TEXT, entity TEXT, cutoff INTEGER NOT NULL, published_at INTEGER NOT NULL,
                value INTEGER NOT NULL, PRIMARY KEY(feature, entity));
        ''')

    def close(self):
        self.db.close()

    def materialize(self, events, cutoff, published_at, mode='as_known', feature=COUNT):
        cutoff = nonnegative(cutoff); published_at = nonnegative(published_at)
        if published_at < cutoff:
            raise ValueError('publication cannot precede its feature cutoff')
        if mode not in ('as_known', 'corrected'):
            raise ValueError('unknown availability mode')
        if (not isinstance(feature, Feature) or feature.entity != 'user'
                or type(feature.version) is not int or feature.version < 1
                or type(feature.window_days) is not int or feature.window_days < 1
                or feature.aggregation not in ('count', 'distinct_days')):
            raise ValueError('expected a versioned user-count or active-day feature')
        check_id(feature.name)
        # A corrected backfill still cannot use an event unavailable at publication.
        available = [e for e in source_rows(events) if e['ingested_minute'] <= published_at]
        values = compute(feature, available, cutoff, mode)
        source_hash = hashlib.sha256(json.dumps(sorted(events, key=lambda e: e['id']),
                                                sort_keys=True).encode()).hexdigest()
        key = f'{feature.name}@v{feature.version}'
        with self.db:
            old = self.db.execute('SELECT spec FROM definitions WHERE feature=?', (key,)).fetchone()
            if old and old['spec'] != feature.spec():
                raise ValueError('changed definition requires a new version')
            identity = (key, cutoff, published_at)
            batch = self.db.execute('SELECT mode,source_sha256 FROM batches WHERE feature=? '
                                    'AND cutoff=? AND published_at=?', identity).fetchone()
            if batch:
                if tuple(batch) != (mode, source_hash):
                    raise ValueError('conflicting publication identity')
                return key  # Identical retry; never rewrite a published vintage.
            latest = self.db.execute('SELECT MAX(published_at) FROM batches WHERE feature=?', (key,)).fetchone()[0]
            if latest is not None and published_at < latest:
                raise ValueError('cannot backdate publication; use the actual later availability time')
            self.db.execute('INSERT OR IGNORE INTO definitions VALUES (?,?)', (key, feature.spec()))
            self.db.execute('INSERT INTO batches VALUES (?,?,?,?,?)', (*identity, mode, source_hash))
            for entity, value in values.items():
                self.db.execute('INSERT INTO history VALUES (?,?,?,?,?)', (key, entity, cutoff, published_at, value))
                self.db.execute('''INSERT INTO online VALUES (?,?,?,?,?)
                    ON CONFLICT(feature,entity) DO UPDATE SET
                        cutoff=excluded.cutoff, published_at=excluded.published_at, value=excluded.value
                    WHERE excluded.cutoff > online.cutoff OR
                        (excluded.cutoff=online.cutoff AND excluded.published_at>online.published_at)''',
                    (key, entity, cutoff, published_at, value))
        return key

    def point_in_time(self, feature, entity, predict_at):
        nonnegative(predict_at); check_id(feature); check_id(entity)
        query = (Path(__file__).resolve().parent / 'join.sql').read_text()
        row = self.db.execute(query, (feature, entity, predict_at, predict_at)).fetchone()
        return dict(row) if row else None

    def join(self, observations, feature):
        validate(observations, [], [])
        # Preserve one row per observation, even when the feature is missing.
        return [dict(row, feature=self.point_in_time(feature, row['entity'], minute(row['predict_at'])))
                for row in observations]

    def read_online(self, feature, entity, now, max_age):
        nonnegative(now); nonnegative(max_age); check_id(feature); check_id(entity)
        row = self.db.execute('SELECT value,cutoff,published_at FROM online WHERE feature=? AND entity=?',
                              (feature, entity)).fetchone()
        if row is None:
            return {'status': 'missing', 'value': None, 'age_minutes': None}
        age = now - row['cutoff']
        if row['cutoff'] > now or row['published_at'] > now:
            status = 'unavailable'
        else:
            status = 'fresh' if age <= max_age else 'stale'
        return dict(row, status=status, value=row['value'] if status == 'fresh' else None, age_minutes=age)

The example supplies publication timestamps explicitly to make the scenario repeatable. It assumes a trusted writer records when each materialization becomes available. In a service, that timestamp must come from the publication process, rather than a client choosing an earlier time. The table cannot independently prove the truth of a supplied clock.

The aggregation bodies in vendor/feature_math.py are copied unchanged from the existing feature-store implementation. Schema and UTC validation are reused from the availability lab. This keeps the exercise focused on history: it does not introduce a second counting algorithm or overwrite the earlier experiment’s records.

Filter availability before choosing the newest row

For a prediction at P, the join requires both cutoff <= P and published_at <= P. It then orders the eligible rows by cutoff descending, followed by publication descending. An older cutoff corrected more recently must not displace a newer cutoff that was already available.

The store executes this file directly, with feature, entity and the prediction minute bound as parameters:

join.sqlSource · sqlDownload
SELECT value, cutoff, published_at
FROM history
WHERE feature = ? AND entity = ?
  AND cutoff <= ? AND published_at <= ?
ORDER BY cutoff DESC, published_at DESC
LIMIT 1;

At 13:00, the first vintage passes both conditions. The 14:00 correction fails the publication condition. At 15:00, both pass, so the later vintage wins. A 12:15 request has no eligible value: even the first publication had not happened yet. Missing is returned as None, not imputed as a count of zero.

This contract allows equality at publication time. It assumes a publication completed before a request read recorded at the same minute. If an application cannot establish that order, it needs finer timestamps or a sequence token. Event-arrival equality follows the separate strict rule defined above.

Reconstruct requests before and after a correction

The driver first publishes at 12:30, saves historical joins, and adds the corrected noon cutoff at 14:00. It then reruns the same four observations against the larger history.

Run the historical join experimentTerminal · bash
python inspect_history.py
python inspect_history.py --check
python -m unittest -v test_history.py
Recorded local SQLite joinsRecorded output · textDownload
Feature window ends at Jan 08 12:00 UTC; first publication 12:30; correction 14:00
before-job: predict_at=2026-01-08T12:15:00Z; feature=missing
first-read: predict_at=2026-01-08T13:00:00Z; feature=1
after-correction: predict_at=2026-01-08T15:00:00Z; feature=2
unknown-user: predict_at=2026-01-08T15:00:00Z; feature=missing
13:00 historical read unchanged after correction: True
Retained user-01 vintages: 2

first-read still returns one after the correction is present. after-correction returns two. The unknown user stays missing. join() preserves each observation ID and input order, so a missing feature cannot silently remove a training row or shift labels onto another example.

Inspect the four observations and materialization sequence
inspect_history.pySource · pythonDownload
"""Inspect real local SQLite joins over an authored synthetic event log."""
import argparse
import hashlib
import json
from pathlib import Path
from history import History, minute

ROOT = Path(__file__).resolve().parent
KEY = 'user_events_1d@v1'
CUTOFF = minute('2026-01-08T12:00:00Z')
FIRST = minute('2026-01-08T12:30:00Z')
CORRECTION = minute('2026-01-08T14:00:00Z')


def events():
    return json.loads((ROOT / 'scenario.json').read_text())['events']


def observations():
    return [dict(id=id, entity=entity, predict_at='2026-01-08T'+time+'Z')
            for id, entity, time in [('before-job','user-01','12:15:00'),
                                    ('first-read','user-01','13:00:00'),
                                    ('after-correction','user-01','15:00:00'),
                                    ('unknown-user','user-99','15:00:00')]]


def record():
    db = History()
    try:
        db.materialize(events(), CUTOFF, FIRST)
        before = db.join(observations(), KEY)
        db.materialize(events(), CUTOFF, CORRECTION, 'corrected')
        after = db.join(observations(), KEY)
        return {'mode': 'local SQLite; authored synthetic data; no model',
                'input_sha256': hashlib.sha256((ROOT/'scenario.json').read_bytes()).hexdigest(),
                'cutoff': CUTOFF, 'first_published_at': FIRST, 'corrected_published_at': CORRECTION,
                'before_backfill': before, 'after_backfill': after,
                'historical_read_unchanged': before[1] == after[1],
                'retained_vintages_user_01': db.db.execute('SELECT COUNT(*) FROM history '
                                                        'WHERE entity=?', ('user-01',)).fetchone()[0]}
    finally:
        db.close()


def main():
    p = argparse.ArgumentParser(); p.add_argument('--check', action='store_true'); p.add_argument('--save', type=Path)
    args=p.parse_args(); result=record()
    if args.check and result != json.loads((ROOT/'join-result.json').read_text()):
        raise SystemExit('joined rows differ from the bundled record')
    if args.save: args.save.write_text(json.dumps(result, indent=2)+'\n')
    print('Feature window ends at Jan 08 12:00 UTC; first publication 12:30; correction 14:00')
    for row in result['after_backfill']:
        feature=row['feature']; value='missing' if feature is None else feature['value']
        print(f"{row['id']}: predict_at={row['predict_at']}; feature={value}")
    print('13:00 historical read unchanged after correction:', result['historical_read_unchanged'])
    print('Retained user-01 vintages:', result['retained_vintages_user_01'])


if __name__=='__main__': main()

test_later_correction_does_not_rewrite_earlier_read checks both the historical value and the two retained vintages. Removing the publication filter breaks the earlier read; replacing rows instead of appending loses its original vintage. Separate tests check the publication boundary, entity keys and input-order preservation. The saved result includes the selected timestamps, not just the counts.

Keep feature selection separate from feature freshness

The join reconstructs which stored value was available. It does not say that value was recent enough for a particular task. This exercise applies no age limit to historical retrieval. A training pipeline must choose its missing/stale-row policy explicitly and preserve the selected cutoff so it can apply that policy.

Likewise, a matching feature value does not establish correct labels or an appropriate evaluation split. Use these joined rows with the earlier availability and split contracts. The next lesson checks serving-time age and compares online and historical reads under the same definition and snapshot.

Design exercise: a request at 13:00 sees a value describing 11:00, published at 11:30. A corrected 10:00 value arrives at 12:45. Explain why sorting all eligible rows by publication time alone would choose the wrong cutoff. Then decide what the application should do if the 11:00 value is outside its age limit.

Compare your reasoning

Choose the 11:00 cutoff. The corrected 10:00 value has a later publication but describes older activity. At 13:00, the selected value’s age is two hours; compare that age with the application’s explicit limit after the historical join. If it is too old, apply the declared refusal or fallback policy rather than silently substituting zero or relabeling an older cutoff as fresh.

Failure cases

The original count of one remains correct for the 13:00 prediction even after a later correction records two. The unpublished case must remain missing, and an older cutoff with a newer correction must not displace a newer eligible cutoff. These tests isolate availability from recency.

Check the input and failure boundaries
Trigger Required behavior
Later correction alters earlier read Return the vintage available at the original prediction time.
Unpublished value imputed as zero Retain a missing value when no publication was available.
Future cutoff joins historical row Exclude values whose cutoff is later than the prediction.
Correction uses future arrival Exclude events unavailable by the correction’s publication.
Revision recency displaces newer cutoff Choose the newest eligible cutoff before comparing publication recency.
Conflicting publication overwrites history Reject a conflicting identity without partially writing the materialization.
Backdated publication Refuse a new publication timestamp earlier than recorded publication history.
Changed definition without version Require a new definition version when feature meaning changes.
Missing join removes observation Keep every observation in the join, including those with no available value.

Deployment

Treat the historical store as append-only publication history. In a service, take publication time from the trusted publication process and record the input identity with it. The fixture supplies times explicitly for inspection; a database row alone cannot prove when a client actually knew a value.

Scaling

History grows with entities, cutoffs and corrections. Index the feature/entity and temporal selection fields according to the actual query, then compare plans and results against the small reconstruction cases. Retention or compaction must preserve the vintages needed for the supported training and audit periods.

Key takeaways

  • Historical selection needs publication time as well as feature cutoff.
  • Corrections append records instead of changing earlier reads.
  • A missing feature must not silently become zero or remove an observation.
A noon feature was published at 12:30, then corrected at 14:00. Which vintage belongs to a prediction made at 13:00?

Choose one answer, then check your reasoning.

Read the explanation

Both cutoff and publication must be eligible at prediction. A later correction cannot rewrite what was available to the earlier request.

Best practices

  • Record publication identity and input identity together.
  • Filter availability before ranking eligible vintages.
  • Test a correction arriving after a prediction.

References

  • Feast, point-in-time joins: event-time matching and optional filtering by created time. This lab implements an explicit two-clock contract in SQLite; it does not install Feast.
  • SQLite, UPSERT: conflict handling used by the separate current-value projection.

Sources checked October 24, 2026. Counts shown here come from the bundled local experiment.

Written by Arjun Prabhulal.

Module 2

Prepare data and features