Build a KPI Anomaly Detection API

Build a containerized FastAPI service that flags unusual business KPI patterns.

Introduction

30 Second Summary

Daily business metrics can look ordinary when checked one at a time. Their relationship can still reveal a suspicious pattern before a team acts on them.

In this project, you will build a typed REST API that flags unusual KPI combinations with Isolation Forest. The finished service runs from a Docker image with GitHub Actions protecting every push and pull request.

What You'll Build

Your finished API will turn a pasted batch of daily KPIs into a browser-visible result that singles out campaign-spike with an anomaly flag plus the lowest score.

By the end of this project, you'll have:

  • A browser-accessible API where you can submit batches of revenue, orders, and conversion rates through interactive documentation.
  • A clear anomaly comparison where fixed thresholds miss campaign-spike while Isolation Forest identifies it from the relationship between its metrics.
  • A reproducible Docker image backed by three passing tests plus a GitHub Actions workflow that checks every push and pull request.
  • Secret Mission: A validated sensitivity control that lets callers change the expected anomaly proportion without sending an unsupported value to the model.

Are there any prerequisites?

Basic Python scripting and familiarity with tabular KPIs are enough. The first step checks every required Windows tool before you create the project.

Before We Start

Before the hands-on work begins, define who benefits from your KPI anomaly detection API. This gives every endpoint a clear business purpose.

Prepare the Windows Project

Your KPI service needs a dependable local toolchain before it can accept business data. A supported Python runtime prevents the pinned libraries from failing during installation.

A virtual environment keeps this project's packages separate from other Python work. You will also verify Git, Visual Studio Code, and Docker Desktop before later steps depend on them.

In this step, get ready to:
  • Verify Python 3.11 or later plus the other Windows development tools.
  • Create the kpi-anomaly-api workspace with an activated .venv environment.
  • Install the dependencies pinned in requirements.txt.
Verify your Windows tools

Version checks reveal whether each command is available in the terminal. The Docker check also confirms that the command-line client can communicate with the local engine.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code into Windows search.
  • Press Enter to open Visual Studio Code.
  • Select Terminal in the menu bar.
  • Select New Terminal.
  • Select the Launch Profile dropdown in the terminal panel.
  • Select PowerShell.
  • Check all four tools by running these commands:
python --version
git --version
docker version
code --version

What Do These Commands Check?

  • The Python command prints the runtime release that creates your project environment.
  • The Git command confirms that source control is available for the repository you publish later.
  • The Docker command prints separate Client and Server information when Docker Desktop is ready.
  • The Visual Studio Code command prints the editor's version information.

Your Python result determines the next path. Choose the tab that matches the output in your terminal.

✔️ I see version 3.11 or higher

Your Python runtime meets the project's minimum version. Keep this PowerShell terminal open for the workspace setup.

ⓧ I see an older version

The pinned scikit-learn release requires Python 3.11 or later. Python 3.15 gives this project a supported runtime.

py install 3.15

What Does This Command Do?

Python Install Manager downloads the requested runtime family. The new runtime provides a supported interpreter for this project.

  • Close the current PowerShell terminal after the installation completes.
  • Select Terminal in the menu bar.
  • Select New Terminal.
  • Rerun the Python version check shown above.

ⓧ Command not found

PowerShell cannot find a registered Python runtime. Python Install Manager provides the supported Windows installation path.

  • Visit the official Python downloads page.
  • Install Python Install Manager.
  • Return to the PowerShell terminal in Visual Studio Code.
  • Install Python 3.15 by running this command:
py install 3.15

What Does This Command Do?

Python Install Manager installs the requested runtime family. It also registers the runtime for use on Windows.

  • Close the current PowerShell terminal after the installation completes.
  • Select Terminal in the menu bar.
  • Select New Terminal.
  • Rerun the Python version check shown above.

The remaining checks should print Git information plus Visual Studio Code information. The Docker output should contain both Client and Server sections.

Docker Server Section Missing?

A missing Server section means the Docker command-line client cannot reach the local engine. Docker Desktop may still be starting.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop into Windows search.
  • Press Enter to start Docker Desktop.
  • Return to the PowerShell terminal after Docker Desktop reports that the engine is running.
  • Rerun the Docker version check shown above.

Ask for targeted help with diagnosing Docker Desktop readiness.

Create the isolated environment

Visual Studio Code treats an open folder as a workspace. Keeping .venv inside that workspace ties the isolated interpreter to this project.

  • Select File in the Visual Studio Code menu bar.
  • Select Open Folder....
  • Select your Desktop location in the folder dialog.
  • Select New Folder.
  • Enter kpi-anomaly-api as the folder name.
  • Select Select Folder.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

The Explorer now shows kpi-anomaly-api as the open workspace. This visible folder name confirms that later files land in the intended project.

  • Select View in the menu bar.
  • Select Terminal.
  • Select the Launch Profile dropdown in the terminal panel.
  • Select PowerShell.
  • Create the project-local virtual environment by running this command:
python -m venv .venv

What Does This Command Do?

Python's environment module creates an isolated interpreter inside .venv. Packages installed through that interpreter stay separate from global Python packages.

The Explorer should now show a .venv folder inside kpi-anomaly-api.

The Environment Folder Is Missing?

Confirm that the terminal path ends with kpi-anomaly-api. Confirm that the Python version check reports 3.11 or later.

Ask for help with creating the Windows virtual environment.

  • Activate .venv in the PowerShell terminal by running this command:
.\.venv\Scripts\Activate.ps1

What Does This Command Do?

The activation script updates the current PowerShell session to use the interpreter inside .venv. Future Python package installations now target this project.

The PowerShell prompt should gain the virtual environment name at its start. Choose the tab that matches what happened.

✔️ The environment is active

Your terminal now uses the interpreter inside .venv. Keep this terminal open for dependency installation.

ⓧ PowerShell blocks the script

PowerShell can require a current-user execution policy before it runs activation scripts. This setting allows locally created scripts while keeping protections for downloaded scripts.

  • Set the current-user execution policy by running this command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

What Does This Command Do?

The command changes the execution policy for your Windows user account. PowerShell may ask you to confirm the change before it continues.

  • Confirm the policy change if PowerShell asks for approval.
  • Activate .venv again by running this command:
.\.venv\Scripts\Activate.ps1

What Happens Now?

PowerShell reruns the project-local activation script under the updated policy. The terminal prompt should gain the virtual environment name at its start.

Install the pinned dependencies

A requirements file gives every environment the same dependency versions. Exact pins keep local development aligned with the automated tests you add later.

  • Select the Explorer view in the Visual Studio Code Activity Bar.
  • Select New File....
  • Enter requirements.txt as the file name.
  • Press Enter.

A blank requirements.txt editor tab should open. The file should also appear in the Explorer.

  • Add the pinned project dependencies to requirements.txt by copying this content:
fastapi[standard]==0.162.0
pydantic==2.14.1
scikit-learn==1.9.1
uvicorn==0.54.0
pytest==9.1.1
httpx==0.28.1

What Do These Dependencies Provide?

  • FastAPI provides the web API framework plus its standard development tooling.
  • Pydantic validates the KPI request structure.
  • scikit-learn provides the Isolation Forest model used for anomaly detection.
  • Uvicorn runs the API as a local web service.
  • pytest runs the behavior-focused test suite.
  • HTTPX supports the test client used to call the API in tests.
  • Save requirements.txt by pressing Ctrl+S.
  • Confirm that the editor tab no longer shows an unsaved-change indicator.

The Requirements File Looks Different?

Check that each dependency occupies one line. Confirm that every version uses two equals signs.

Ask for help with checking the dependency pins.

✔️ Awesome, I've got everything!

Your dependency file is saved. Continue with the installation in the active PowerShell terminal.

ⓧ I'd like to double check the full code

fastapi[standard]==0.162.0
pydantic==2.14.1
scikit-learn==1.9.1
uvicorn==0.54.0
pytest==9.1.1
httpx==0.28.1

How Should This File Look?

Your complete requirements.txt should match these six lines exactly. This is the full dependency artifact for this step.

The active environment now has a reproducible dependency list. Installing from that file keeps every package inside .venv.

  • Install the pinned dependencies by running this command:
python -m pip install -r requirements.txt

What Does This Command Do?

Python runs the package installer from the active virtual environment. The installer reads each exact version from requirements.txt.

The first installation can take a few minutes while Python downloads compiled machine-learning packages. The terminal should finish without a package installation failure.

Dependency Installation Failed?

Confirm that the terminal prompt still shows the active virtual environment. Confirm that Python reports 3.11 or later.

Check your internet connection before retrying the installation. A network interruption can stop package downloads.

Ask for targeted help with diagnosing the pinned installation.

Before you run the final check, do you expect all three imports to succeed inside the activated environment?

  • Verify the installed API and machine-learning packages by running this command:
python -c "import fastapi, pydantic, sklearn; print('Environment ready')"

What Does This Command Prove?

Python imports the API framework plus the validation library plus the machine-learning package. The confirmation text prints only after all three imports succeed.

You should see Environment ready in the PowerShell terminal. That's the local foundation complete: the isolated environment can load every core library.

The Ready Message Did Not Print?

Confirm that requirements.txt contains all six pinned lines. Confirm that the terminal still uses .venv.

Ask for help with finding the failed import.

Your Windows workspace now has verified tools plus an isolated dependency set. Next, you will turn it into a running API with a browser-visible health endpoint.

Launch the First API Endpoint

Your Python virtual environment is ready. The next goal is a browser-visible service.

A visible HTTP response turns this project from a script into a service before machine learning adds complexity.

FastAPI defines the route. Uvicorn serves it over HTTP.

In this step, get ready to:
  • Define a FastAPI application with a root health route.
  • Serve the application locally with Uvicorn.
  • Verify the route through FastAPI's interactive documentation.
Create the baby service

A route pairs an HTTP method with a URL path. The root route gives your service its smallest useful response.

  • In the Visual Studio Code Explorer sidebar, select the new-file icon beside the open kpi-anomaly-api folder.
  • Type main.py into the filename field.
  • Press Enter to create the file.
  • Add the FastAPI application to main.py by pasting this code:
from fastapi import FastAPI

app = FastAPI(title="KPI Anomaly Detection API")


@app.get("/")
def read_root():
    return {"service": "KPI Anomaly Detection API", "status": "running"}

What Does This Code Do?

  • FastAPI creates the API application.
  • app stores the application that Uvicorn serves.
  • title="KPI Anomaly Detection API" sets the name shown in the generated documentation.
  • @app.get("/") maps GET requests for the root path to the function below it.
  • read_root() returns the service name plus its running status as JSON.
  • Press Ctrl+S to save main.py.

Your first route is defined. Use these tabs to confirm that the saved file matches the intended service.

✔️ Awesome, I've got everything!

Great. Your main.py file now contains the application plus its root route.

ⓧ I'd like to double check the full code

from fastapi import FastAPI

app = FastAPI(title="KPI Anomaly Detection API")


@app.get("/")
def read_root():
    return {"service": "KPI Anomaly Detection API", "status": "running"}

Compare this reference with your saved main.py file. The import, application title, route decorator, function name, and response must match.

Run and inspect the service

Uvicorn loads the app object from main.py. It keeps serving requests until you stop the process.

The Terminal Stays Busy

The server process occupies this terminal while it runs. Ctrl+C stops the process when you finish.

Before you start the server, do you expect the endpoint to answer a browser request with only one route defined?

  • Switch back to the activated PowerShell terminal from the previous step.
  • Start the local service by running this command:
uvicorn main:app --host 0.0.0.0 --port 8000

What Does This Command Do?

  • main:app tells Uvicorn to load app from main.py.
  • --host 0.0.0.0 makes the service listen on every available network interface.
  • --port 8000 makes the service accept requests on port 8000.

PowerShell should indicate that startup is complete. The service now listens on port 8000.

The Server Did Not Start?

Confirm that the terminal prompt shows the active .venv environment. Check that the terminal is inside the kpi-anomaly-api folder.

Confirm that main.py is saved in that folder. A missing file or typing error prevents Uvicorn from loading app.

Ask for targeted help with diagnosing the Uvicorn startup problem.

The root URL tests the response returned by read_root(). This is the shortest proof that your Python function is reachable over HTTP.

Before you load the root route, what response shape do you expect the browser to show?

  • Open your preferred web browser through Windows search.
  • Enter http://127.0.0.1:8000/ in the browser address bar.

You'll see {"service": "KPI Anomaly Detection API", "status": "running"} in the browser.

Your first live API response is working. The browser has confirmed that Uvicorn can reach your FastAPI route.

The Root Page Did Not Load?

Check that the Uvicorn terminal is still running. Confirm that the browser address uses port 8000.

Ask for targeted help with diagnosing the local API connection.

What Is OpenAPI?

OpenAPI describes an API's routes plus their request and response shapes. FastAPI uses that description to generate interactive documentation.

The documentation gives each route a browser interface. Later steps use it to send KPI batches without building a separate frontend.

Before you open the documentation, how many project routes do you expect to see?

  • Replace the current browser address with http://127.0.0.1:8000/docs.

You'll see the KPI Anomaly Detection API title. You'll also see the visible GET / route.

Your first API route is live with browser-accessible documentation. Next, you'll add a typed KPI contract and watch a rule-based detector miss a suspicious combination.

Add the Rule-Based Baseline

Your FastAPI service already responds through the browser. It now needs a rule-based baseline that checks business metrics against fixed limits.

The suspicious record combines revenue with an unusual order count. This step tests whether broad independent thresholds can recognize that relationship.

In this step, get ready to:
  • Define a typed request contract for KPI batches.
  • Add a fixed-threshold endpoint to the API.
  • Send a sample batch through the interactive documentation to expose the baseline's false negative.
Define the KPI contract

A request contract describes the shape and data types that the API accepts. Pydantic uses these models to validate each incoming JSON body before the endpoint runs.

  • In main.py, select everything from the first import through the blank line before @app.get("/").
  • Replace the selected section by copying the code below:
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="KPI Anomaly Detection API")


class KPIRecord(BaseModel):
    label: str
    revenue: float
    orders: int
    conversion_rate: float


class KPIBatch(BaseModel):
    records: list[KPIRecord]

What Does This Code Do?

  • The BaseModel import provides the foundation for both request models.
  • KPIRecord requires a label plus three typed business metrics.
  • KPIBatch accepts a list of those KPI records in one request.
  • Save main.py.
  • Return to the VS Code PowerShell terminal from earlier.
  • Stop the running server with Ctrl+C.
  • Restart the API with the updated request models by running this command:
uvicorn main:app --host 0.0.0.0 --port 8000

What Does This Command Do?

Uvicorn imports the updated app from main.py. A successful startup proves that both request models contain valid Python syntax.

  • Return to the interactive documentation at http://127.0.0.1:8000/docs.
  • Refresh the browser page.

You'll still see GET / in the OpenAPI documentation. The page loading successfully proves that the new request models did not break the service.

Server Does Not Restart?

Check that both model classes sit below the app declaration. Confirm that the existing @app.get("/") decorator remains below them.

Ask for targeted help with diagnosing the request models.

Add the fixed-threshold endpoint

A fixed-threshold detector evaluates every metric against its own acceptable range. The demo limits are deliberately broad teaching values that give you a simple baseline.

  • In main.py, place the cursor below the read_root() function.
  • Add the baseline endpoint by copying the code below:
@app.post("/rule-check")
def rule_check(batch: KPIBatch):
    results = []

    for record in batch.records:
        is_anomaly = (
            record.revenue < 500
            or record.revenue > 5000
            or record.orders < 10
            or record.orders > 200
            or record.conversion_rate < 0.01
            or record.conversion_rate > 0.10
        )
        results.append({"label": record.label, "is_anomaly": is_anomaly})

    return {"method": "fixed_thresholds", "results": results}

How Does the Baseline Work?

  • The @app.post("/rule-check") decorator exposes a POST endpoint for typed KPI batches.
  • The loop checks revenue against its lower and upper limits.
  • The same expression checks orders and conversion rate independently.
  • The response preserves each record's label with its resulting anomaly flag.
  • Save main.py.
  • Return to the running Uvicorn terminal.
  • Stop the server with Ctrl+C.
  • Restart the API with the new endpoint by running this command:
uvicorn main:app --host 0.0.0.0 --port 8000

What Does This Restart Prove?

The restart imports the new rule_check() function. Uvicorn keeps the service available on port 8000 while the endpoint handles requests.

  • Return to http://127.0.0.1:8000/docs.
  • Refresh the interactive documentation.

Good progress. You'll now see POST /rule-check beside the existing health endpoint.

The New Endpoint Is Missing?

Confirm that you saved main.py before restarting Uvicorn. Check that @app.post("/rule-check") starts at the left edge of the file.

Ask for help with finding the missing endpoint.

Your cumulative main.py should now match the appropriate view below.

✔️ Awesome, I've got everything!

Your saved file now contains the health endpoint plus the typed fixed-threshold endpoint.

ⓧ I'd like to double check the full code

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI(title="KPI Anomaly Detection API")


class KPIRecord(BaseModel):
    label: str
    revenue: float
    orders: int
    conversion_rate: float


class KPIBatch(BaseModel):
    records: list[KPIRecord]


@app.get("/")
def read_root():
    return {"service": "KPI Anomaly Detection API", "status": "running"}


@app.post("/rule-check")
def rule_check(batch: KPIBatch):
    results = []

    for record in batch.records:
        is_anomaly = (
            record.revenue < 500
            or record.revenue > 5000
            or record.orders < 10
            or record.orders > 200
            or record.conversion_rate < 0.01
            or record.conversion_rate > 0.10
        )
        results.append({"label": record.label, "is_anomaly": is_anomaly})

    return {"method": "fixed_thresholds", "results": results}

What Should This File Contain?

The file defines two request models plus two API routes. The POST route evaluates each KPI field against an independent range.

Send the sample batch

The sample batch contains nine records clustered around a similar pattern. Its final record combines much higher revenue with far fewer orders.

  • In the VS Code file sidebar, create sample-request.json beside main.py.

You'll see sample-request.json listed with the existing project files.

  • Fill sample-request.json with the sample KPI batch by copying the code below:
{
  "records": [
    {"label": "day-1", "revenue": 1000, "orders": 100, "conversion_rate": 0.0300},
    {"label": "day-2", "revenue": 1020, "orders": 98, "conversion_rate": 0.0310},
    {"label": "day-3", "revenue": 980, "orders": 103, "conversion_rate": 0.0290},
    {"label": "day-4", "revenue": 1010, "orders": 101, "conversion_rate": 0.0300},
    {"label": "day-5", "revenue": 995, "orders": 99, "conversion_rate": 0.0320},
    {"label": "day-6", "revenue": 1030, "orders": 104, "conversion_rate": 0.0280},
    {"label": "day-7", "revenue": 970, "orders": 97, "conversion_rate": 0.0310},
    {"label": "day-8", "revenue": 1040, "orders": 102, "conversion_rate": 0.0290},
    {"label": "day-9", "revenue": 990, "orders": 100, "conversion_rate": 0.0305},
    {"label": "campaign-spike", "revenue": 4000, "orders": 20, "conversion_rate": 0.0300}
  ]
}

What Does This Sample Represent?

The first nine records form a consistent relationship between revenue and orders. campaign-spike stays inside every individual demo range despite its unusual combination.

  • Save sample-request.json.

Your sample file should now match the appropriate view below.

✔️ Awesome, I've got everything!

Your saved sample contains ten records with campaign-spike as the final entry.

ⓧ I'd like to double check the full code

{
  "records": [
    {"label": "day-1", "revenue": 1000, "orders": 100, "conversion_rate": 0.0300},
    {"label": "day-2", "revenue": 1020, "orders": 98, "conversion_rate": 0.0310},
    {"label": "day-3", "revenue": 980, "orders": 103, "conversion_rate": 0.0290},
    {"label": "day-4", "revenue": 1010, "orders": 101, "conversion_rate": 0.0300},
    {"label": "day-5", "revenue": 995, "orders": 99, "conversion_rate": 0.0320},
    {"label": "day-6", "revenue": 1030, "orders": 104, "conversion_rate": 0.0280},
    {"label": "day-7", "revenue": 970, "orders": 97, "conversion_rate": 0.0310},
    {"label": "day-8", "revenue": 1040, "orders": 102, "conversion_rate": 0.0290},
    {"label": "day-9", "revenue": 990, "orders": 100, "conversion_rate": 0.0305},
    {"label": "campaign-spike", "revenue": 4000, "orders": 20, "conversion_rate": 0.0300}
  ]
}

How Is the Batch Structured?

The top-level records property matches KPIBatch. Every object inside it matches the four fields required by KPIRecord.

  • Return to http://127.0.0.1:8000/docs.
  • Expand POST /rule-check.
  • Select Try it out.
  • Replace the example request body with the contents of sample-request.json.

Before you execute the request, do you think independent limits can recognize the unusual relationship in campaign-spike?

  • Select Execute.

You'll see method set to fixed_thresholds. Every is_anomaly value is false, including the result for campaign-spike.

Why Did the Baseline Miss It?

Each field stays inside its own allowed range. The detector never compares the relationship between high revenue and a low order count.

This false negative is the intended shortfall. You now have a concrete baseline for judging a detector that evaluates KPI combinations.

The Request Does Not Return Results?

Confirm that the request body starts with the records property. Check every record for matching quotation marks and commas.

Ask for help with debugging the sample request.

You have a working baseline with a visible weakness. Next, you'll evaluate relationships across the KPI features so the suspicious combination stands out.

Detect Anomalies with Machine Learning

Your typed FastAPI service now checks every KPI against broad limits. The suspicious campaign-spike record still slips through because each metric remains inside its individual range.

This step adds Isolation Forest to evaluate relationships across all three features. You will also protect the service with automated tests.

In this step, get ready to:
  • Add a model-backed endpoint that returns anomaly flags.
  • Create three tests for the API's critical behavior.
  • Compare the threshold detector with the machine-learning detector.
Add the model-backed endpoint

Isolation Forest identifies records that are separated from the rest of a dataset. Lower decision scores represent more unusual feature combinations.

Why Isolation Forest?

Independent thresholds cannot judge how revenue relates to order volume. Isolation Forest evaluates the three KPI features together.

The model also runs locally on tabular data. This keeps the anomaly check inside your existing API process.

  • Switch back to main.py in Visual Studio Code.
  • Find this import line:
from pydantic import BaseModel

What Are You Finding?

This existing import sits directly below the FastAPI import. The new model import belongs immediately after it.

  • Replace that line with this import group:
from pydantic import BaseModel
from sklearn.ensemble import IsolationForest

Why Add This Import?

The new import makes the IsolationForest estimator available to your endpoint. The endpoint can now create a model for each submitted batch.

  • Save main.py.
  • Switch back to the terminal running Uvicorn.

Uvicorn should reload the application without a traceback. This confirms that scikit-learn imports successfully from your active environment.

Seeing an Import Error?

  • Confirm that the terminal prompt shows the active .venv environment.
  • Check that scikit-learn==1.9.1 remains listed in requirements.txt.

Ask for help with diagnosing the Isolation Forest import.

The new route turns each KPI record into a feature row. It fits the model against the submitted batch before returning one flag plus one score per record.

  • Add the model-backed route beneath rule_check() by pasting this code:
@app.post("/detect")
def detect_anomalies(batch: KPIBatch):
    features = [
        [record.revenue, record.orders, record.conversion_rate]
        for record in batch.records
    ]

    model = IsolationForest(contamination=0.1, random_state=42)
    predictions = model.fit_predict(features)
    scores = model.decision_function(features)

    results = [
        {
            "label": record.label,
            "is_anomaly": bool(prediction == -1),
            "score": round(float(score), 4),
        }
        for record, prediction, score in zip(
            batch.records, predictions, scores, strict=True
        )
    ]

    return {"method": "isolation_forest", "results": results}

What Does This Endpoint Do?

  • The features list converts every record into revenue, orders, and conversion-rate values.
  • The model expects an anomaly proportion of 0.1.
  • The random_state value of 42 keeps repeated fits reproducible.
  • The result converts an outlier prediction of -1 into a Boolean anomaly flag.
  • The response rounds each decision score to four decimal places.
  • Save main.py.
  • Switch back to http://127.0.0.1:8000/docs in your browser.
  • Refresh the interactive documentation.

You should now see POST /detect beside the existing endpoints. The generated request schema comes from KPIBatch.

  • Expand POST /detect.
  • Select Try it out.
  • Switch back to sample-request.json.
  • Copy the complete JSON body.
  • Return to the request body in the interactive documentation.
  • Paste the copied JSON.

Before you execute the request, which record do you expect the relationship-aware model to isolate?

  • Select Execute.

The response marks campaign-spike as the single anomaly. Its score is lower than every score returned for the normal daily records.

The Detect Request Failed?

  • Confirm that the refreshed documentation includes POST /detect.
  • Check that the request body begins with the records key from sample-request.json.
  • Inspect the Uvicorn terminal for a Python traceback from detect_anomalies().

Ask for help with debugging the detect endpoint.

✔️ Awesome, I've got everything!

Your model-backed route is working. Double-check that you saved main.py before creating the tests.

ⓧ I'd like to double check the full code

from fastapi import FastAPI
from pydantic import BaseModel
from sklearn.ensemble import IsolationForest

app = FastAPI(title="KPI Anomaly Detection API")


class KPIRecord(BaseModel):
    label: str
    revenue: float
    orders: int
    conversion_rate: float


class KPIBatch(BaseModel):
    records: list[KPIRecord]


@app.get("/")
def read_root():
    return {"service": "KPI Anomaly Detection API", "status": "running"}


@app.post("/rule-check")
def rule_check(batch: KPIBatch):
    results = []

    for record in batch.records:
        is_anomaly = (
            record.revenue < 500
            or record.revenue > 5000
            or record.orders < 10
            or record.orders > 200
            or record.conversion_rate < 0.01
            or record.conversion_rate > 0.10
        )
        results.append({"label": record.label, "is_anomaly": is_anomaly})

    return {"method": "fixed_thresholds", "results": results}


@app.post("/detect")
def detect_anomalies(batch: KPIBatch):
    features = [
        [record.revenue, record.orders, record.conversion_rate]
        for record in batch.records
    ]

    model = IsolationForest(contamination=0.1, random_state=42)
    predictions = model.fit_predict(features)
    scores = model.decision_function(features)

    results = [
        {
            "label": record.label,
            "is_anomaly": bool(prediction == -1),
            "score": round(float(score), 4),
        }
        for record, prediction, score in zip(
            batch.records, predictions, scores, strict=True
        )
    ]

    return {"method": "isolation_forest", "results": results}

How to Use This Reference

Compare this file with your saved main.py. Pay close attention to the import location plus the indentation inside detect_anomalies().

Add behavior-focused tests

Automated tests preserve the behavior you have observed in the browser. FastAPI's TestClient sends requests directly to the application during pytest runs.

  • Select the New File control in the Visual Studio Code Explorer sidebar.
  • Name the file test_main.py.

You should see test_main.py beside main.py in the Explorer sidebar.

  • Add the shared test client plus the health test by pasting this code into test_main.py:
import json
from pathlib import Path

from fastapi.testclient import TestClient

from main import app

client = TestClient(app)
SAMPLE = json.loads(
    (Path(__file__).parent / "sample-request.json").read_text(encoding="utf-8")
)


def test_health_endpoint():
    response = client.get("/")

    assert response.status_code == 200
    assert response.json()["status"] == "running"

What Does This Test Set Up?

  • The client variable sends requests to the imported application.
  • The SAMPLE fixture loads the existing KPI batch from sample-request.json.
  • The first test confirms that the health endpoint returns a successful status.
  • The same test confirms that the response reports a running service.
  • Save test_main.py.
  • Run the health test from the activated PowerShell terminal with this command:
python -m pytest -q

What Does This Check Prove?

Pytest discovers the test function in test_main.py. The test calls the API without opening a browser.

Pytest should report one passing test. Your health endpoint is now protected by an automated check.

The Health Test Did Not Pass?

  • Confirm that PowerShell is inside the kpi-anomaly-api folder.
  • Check that test_main.py imports app from main.
  • Compare the expected running value with the response in read_root().

Ask for help with debugging the health test.

The second test preserves the designed baseline failure. It proves that the broad independent thresholds still miss every record in the shared sample.

  • Add the baseline test beneath test_health_endpoint() by pasting this code:
def test_rule_check_misses_combination_anomaly():
    response = client.post("/rule-check", json=SAMPLE)

    assert response.status_code == 200
    assert all(item["is_anomaly"] is False for item in response.json()["results"])

What Does the Baseline Test Protect?

The test submits the shared fixture to /rule-check. It confirms that the teaching baseline returns no anomaly flags.

  • Save test_main.py.
  • Run both tests from the activated PowerShell terminal with this command:
python -m pytest -q

What Does This Check Prove?

Pytest now checks the health response plus the baseline behavior. A passing run confirms that both existing outcomes remain stable.

Pytest should report two passing tests. The false negative is now documented as intentional baseline behavior.

The Baseline Test Failed?

  • Confirm that sample-request.json remains beside test_main.py.
  • Check that the test posts SAMPLE to /rule-check.
  • Inspect the response for an unexpected is_anomaly value.

Ask for help with debugging the baseline test.

The final test captures the machine-learning improvement. It checks both the identity of the outlier and the total anomaly count.

  • Add the model test beneath test_rule_check_misses_combination_anomaly() by pasting this code:
def test_isolation_forest_detects_campaign_spike():
    response = client.post("/detect", json=SAMPLE)

    assert response.status_code == 200
    results = response.json()["results"]
    campaign_spike = next(
        item for item in results if item["label"] == "campaign-spike"
    )
    assert campaign_spike["is_anomaly"] is True
    assert sum(item["is_anomaly"] for item in results) == 1

What Does the Model Test Protect?

  • The test sends the shared KPI batch to /detect.
  • The campaign_spike lookup isolates the suspicious record from the response.
  • The final assertion prevents the model from flagging additional records unexpectedly.
  • Save test_main.py.

✔️ Awesome, I've got everything!

Your three behavior tests are assembled. Continue to the final comparison to run the complete suite.

ⓧ I'd like to double check the full code

import json
from pathlib import Path

from fastapi.testclient import TestClient

from main import app

client = TestClient(app)
SAMPLE = json.loads(
    (Path(__file__).parent / "sample-request.json").read_text(encoding="utf-8")
)


def test_health_endpoint():
    response = client.get("/")

    assert response.status_code == 200
    assert response.json()["status"] == "running"


def test_rule_check_misses_combination_anomaly():
    response = client.post("/rule-check", json=SAMPLE)

    assert response.status_code == 200
    assert all(item["is_anomaly"] is False for item in response.json()["results"])


def test_isolation_forest_detects_campaign_spike():
    response = client.post("/detect", json=SAMPLE)

    assert response.status_code == 200
    results = response.json()["results"]
    campaign_spike = next(
        item for item in results if item["label"] == "campaign-spike"
    )
    assert campaign_spike["is_anomaly"] is True
    assert sum(item["is_anomaly"] for item in results) == 1

How to Use This Reference

Compare this complete file with your saved test_main.py. Check the fixture path plus the indentation inside each test.

Compare both detection models

The two endpoints now receive the same typed batch. Their different results reveal the distinction between isolated rules and multivariate detection.

Before you run the complete suite, how many tests do you expect pytest to discover?

  • Verify all three behaviors from the activated PowerShell terminal with this command:
python -m pytest -q

What Does the Final Test Run Prove?

The suite checks the health endpoint, the deliberate baseline miss, and the machine-learning detection. One command now protects the service's core behavior.

Pytest should report 3 passed. That is the hard part complete: your API behavior is now covered by repeatable tests.

One of the Three Tests Failed?

  • Read the failed test name in the pytest output.
  • Compare that test with the matching full-file reference above.
  • Inspect the returned response body when an assertion reports an unexpected value.

Ask for help with diagnosing the failed test.

  • Switch back to http://127.0.0.1:8000/docs.
  • Expand POST /rule-check.
  • Select Try it out.
  • Paste the contents of sample-request.json into the request body.

Before you execute the baseline again, do you expect any anomaly flags to change?

  • Select Execute.

Every is_anomaly value remains false. The independent thresholds still miss the suspicious relationship.

  • Expand POST /detect.
  • Select Try it out.
  • Paste the contents of sample-request.json into the request body.

Before you execute the model-backed request, which record should receive the lowest decision score?

  • Select Execute.

The response marks only campaign-spike as an anomaly. That record also has the lowest decision score in the batch.

The Two Results Look the Same?

  • Confirm that the model request uses POST /detect.
  • Check that the request includes all ten records from sample-request.json.
  • Verify that IsolationForest receives contamination=0.1 plus random_state=42.

Ask for help with comparing the endpoint results.

Your API now detects the relationship that fixed thresholds missed. Next, you will package the service in a container and run these tests automatically on GitHub.

Containerize and Automate Testing

Your machine-learning endpoint now catches the suspicious KPI combination. A laptop-only process cannot prove that another machine can reproduce the same result.

Docker packages the FastAPI service into a repeatable container. GitHub Actions provides continuous integration by running your tests outside your laptop on every push or pull request.

In this step, get ready to:
  • Build the myimage Docker image from a reproducible definition.
  • Run the API inside mycontainer through port 80.
  • Confirm remote tests on every push through a public repository.
Build and run the container

A Docker image records the runtime plus the files needed by your service. A running container turns that image into a browser-accessible process.

  • Create Dockerfile inside kpi-anomaly-api through the VS Code Explorer sidebar.
  • Copy this container definition into Dockerfile:
FROM python:3.14

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./main.py /code/main.py

CMD ["fastapi", "run", "main.py", "--port", "80"]

What Does This Container Definition Do?

  • The Python 3.14 base image supplies the runtime used inside the container.
  • The /code directory gives the application a predictable location.
  • Copying requirements.txt before main.py lets Docker reuse the dependency layer when only application code changes.
  • The final command starts the FastAPI service on port 80.
  • Save Dockerfile.

The Explorer sidebar should now list Dockerfile beside main.py.

  • Create .dockerignore inside kpi-anomaly-api through the Explorer sidebar.
  • Copy these image exclusions into .dockerignore:
.venv
__pycache__
.pytest_cache
.git
.github
test_main.py
sample-request.json

What Does This Ignore File Do?

The ignore file keeps the virtual environment plus local caches out of the image. It also excludes repository metadata plus files that the running service does not need.

A smaller build context gives Docker fewer local files to process.

  • Save .dockerignore.

The Explorer sidebar should now list .dockerignore beside Dockerfile.

  • Press the Windows key to open Windows search.
  • Type Docker Desktop into Windows search.
  • Press Enter to open Docker Desktop.
  • Wait for Docker Desktop to report that its engine is running.
  • Open another PowerShell terminal in VS Code.
  • Build myimage from the files in kpi-anomaly-api by running this command:
docker build -t myimage .

What Does This Command Do?

Docker reads Dockerfile from the current folder. It builds the layers before assigning the local image name myimage.

The first build downloads the base image plus the pinned Python packages. Later builds can reuse unchanged layers.

The terminal should finish with a successful build result for myimage.

The Image Build Failed?

Confirm that the PowerShell prompt points to the kpi-anomaly-api folder. Docker must find Dockerfile plus requirements.txt in that location.

Check that Docker Desktop reports a running engine before rebuilding.

Ask for targeted help with diagnosing the Docker image build.

  • Start mycontainer from the image by running this command:
docker run -d --name mycontainer -p 80:80 myimage

What Does This Command Do?

Docker starts mycontainer in the background. The port mapping sends requests from port 80 on your computer to port 80 inside the container.

The terminal prints the new container identifier when startup succeeds.

  • Switch back to sample-request.json from earlier.
  • Copy the entire JSON request.
  • Visit http://127.0.0.1/docs in a new browser tab.
  • Expand POST /detect in the interactive documentation.
  • Select Try it out.
  • Paste the copied request into the request body.
  • Select Execute.

The container response should mark campaign-spike as the single anomaly. Its score should be the lowest result in the batch.

Cannot Reach the Container API?

Confirm that you used http://127.0.0.1/docs without port 8000. The container uses host port 80.

Check Docker Desktop to confirm that mycontainer is running.

Ask for help with diagnosing the container connection.

Define the CI pipeline

A CI workflow gives the repository its own test process. The remote runner rebuilds the Python environment before checking the same three behaviors you tested locally.

  • Create .gitignore inside kpi-anomaly-api through the VS Code Explorer sidebar.
  • Copy these repository exclusions into .gitignore:
.venv/
__pycache__/
.pytest_cache/
*.py[cod]

What Does This Ignore File Do?

Git skips the local virtual environment plus generated Python caches. The repository keeps source files plus reproducible dependency declarations.

  • Save .gitignore.

The Explorer sidebar should now list .gitignore beside .dockerignore.

  • Create the .github/workflows folder path inside kpi-anomaly-api through the Explorer sidebar.

The Explorer sidebar should now show workflows nested inside .github.

  • Create ci.yml inside .github/workflows.
  • Copy this workflow into ci.yml:
name: CI

on: [push, pull_request]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: '3.13'
          cache: 'pip'
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - name: Run tests
        run: pytest -q

How Does the Pipeline Work?

  • The workflow runs for repository pushes plus pull requests.
  • The test job uses an Ubuntu runner with Python 3.13.
  • The checkout step copies your repository onto the runner.
  • The installation step recreates the packages from requirements.txt before pytest checks the API.
  • Save ci.yml.

Before you run the local check, do you expect the new repository files to change the three test results?

  • Recheck the project behavior by running this command in the activated PowerShell terminal:
python -m pytest -q

What Does This Command Confirm?

Pytest discovers the three API tests in test_main.py. This local run confirms that the container plus repository files did not change application behavior.

You should still see 3 passed. Your service now has a remote-ready test definition without losing its local guarantees.

The Local Tests No Longer Pass?

Confirm that main.py, test_main.py plus sample-request.json remain in the kpi-anomaly-api folder.

Check that the activated terminal prompt still includes .venv.

Ask for help with diagnosing the failed API tests.

✔️ Awesome, I've got everything!

Great. Double-check that every new file is saved before publishing the repository.

ⓧ I'd like to double check the full code

.venv
__pycache__
.pytest_cache
.git
.github
test_main.py
sample-request.json
name: CI

on: [push, pull_request]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-python@v7
        with:
          python-version: '3.13'
          cache: 'pip'
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
      - name: Run tests
        run: pytest -q
.venv/
__pycache__/
.pytest_cache/
*.py[cod]
FROM python:3.14

WORKDIR /code

COPY ./requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt

COPY ./main.py /code/main.py

CMD ["fastapi", "run", "main.py", "--port", "80"]
from fastapi import FastAPI
from pydantic import BaseModel
from sklearn.ensemble import IsolationForest

app = FastAPI(title="KPI Anomaly Detection API")


class KPIRecord(BaseModel):
    label: str
    revenue: float
    orders: int
    conversion_rate: float


class KPIBatch(BaseModel):
    records: list[KPIRecord]


@app.get("/")
def read_root():
    return {"service": "KPI Anomaly Detection API", "status": "running"}


@app.post("/rule-check")
def rule_check(batch: KPIBatch):
    results = []

    for record in batch.records:
        is_anomaly = (
            record.revenue < 500
            or record.revenue > 5000
            or record.orders < 10
            or record.orders > 200
            or record.conversion_rate < 0.01
            or record.conversion_rate > 0.10
        )
        results.append({"label": record.label, "is_anomaly": is_anomaly})

    return {"method": "fixed_thresholds", "results": results}


@app.post("/detect")
def detect_anomalies(batch: KPIBatch):
    features = [
        [record.revenue, record.orders, record.conversion_rate]
        for record in batch.records
    ]

    model = IsolationForest(contamination=0.1, random_state=42)
    predictions = model.fit_predict(features)
    scores = model.decision_function(features)

    results = [
        {
            "label": record.label,
            "is_anomaly": bool(prediction == -1),
            "score": round(float(score), 4),
        }
        for record, prediction, score in zip(
            batch.records, predictions, scores, strict=True
        )
    ]

    return {"method": "isolation_forest", "results": results}
fastapi[standard]==0.162.0
pydantic==2.14.1
scikit-learn==1.9.1
uvicorn==0.54.0
pytest==9.1.1
httpx==0.28.1
{
  "records": [
    {"label": "day-1", "revenue": 1000, "orders": 100, "conversion_rate": 0.0300},
    {"label": "day-2", "revenue": 1020, "orders": 98, "conversion_rate": 0.0310},
    {"label": "day-3", "revenue": 980, "orders": 103, "conversion_rate": 0.0290},
    {"label": "day-4", "revenue": 1010, "orders": 101, "conversion_rate": 0.0300},
    {"label": "day-5", "revenue": 995, "orders": 99, "conversion_rate": 0.0320},
    {"label": "day-6", "revenue": 1030, "orders": 104, "conversion_rate": 0.0280},
    {"label": "day-7", "revenue": 970, "orders": 97, "conversion_rate": 0.0310},
    {"label": "day-8", "revenue": 1040, "orders": 102, "conversion_rate": 0.0290},
    {"label": "day-9", "revenue": 990, "orders": 100, "conversion_rate": 0.0305},
    {"label": "campaign-spike", "revenue": 4000, "orders": 20, "conversion_rate": 0.0300}
  ]
}
import json
from pathlib import Path

from fastapi.testclient import TestClient

from main import app

client = TestClient(app)
SAMPLE = json.loads(
    (Path(__file__).parent / "sample-request.json").read_text(encoding="utf-8")
)


def test_health_endpoint():
    response = client.get("/")

    assert response.status_code == 200
    assert response.json()["status"] == "running"


def test_rule_check_misses_combination_anomaly():
    response = client.post("/rule-check", json=SAMPLE)

    assert response.status_code == 200
    assert all(item["is_anomaly"] is False for item in response.json()["results"])


def test_isolation_forest_detects_campaign_spike():
    response = client.post("/detect", json=SAMPLE)

    assert response.status_code == 200
    results = response.json()["results"]
    campaign_spike = next(
        item for item in results if item["label"] == "campaign-spike"
    )
    assert campaign_spike["is_anomaly"] is True
    assert sum(item["is_anomaly"] for item in results) == 1
Publish the repository and inspect CI

The workflow becomes active after GitHub receives the repository. Inspecting its job reveals how the green check is produced.

  • Open GitHub in your browser.
  • Create a public repository named kpi-anomaly-api.
  • Keep the repository empty by leaving optional starter files disabled.
  • Submit the repository form.

GitHub should display the empty kpi-anomaly-api repository page.

  • Initialize the local repository on the main branch by running this command in the VS Code PowerShell terminal:
git init -b main

What Does This Command Do?

Git creates repository metadata inside kpi-anomaly-api. The branch option names the initial branch main.

The terminal should report that an empty Git repository was initialized.

  • Stage the project files for the first commit by running this command:
git add .

What Does This Command Do?

Git stages the project files from the current folder. The patterns in .gitignore keep the local environment plus generated caches out of that snapshot.

  • Create the first project snapshot by running this command:
git commit -m "Build KPI anomaly API"

What Does This Command Do?

Git records the staged files as one commit. The message identifies the working API plus its container and test automation.

The terminal should report a new commit on main with the project files included.

  • Connect the local repository by replacing OWNER with your GitHub username in this command:
git remote add origin https://github.com/OWNER/kpi-anomaly-api.git

What Does This Command Do?

Git stores the public repository URL under the remote name origin. Future push commands can use that short name.

Git may open a browser authentication flow if this computer has not connected to GitHub before. Complete that flow with the account that owns the new repository.

Before you push, do you expect the remote test to reproduce the same three passing results as your activated virtual environment?

  • Publish the main branch by running this command:
git push -u origin main

What Does This Command Do?

Git sends the local commit to origin. The upstream option links the local main branch to its remote counterpart.

The push event also starts the CI workflow stored in ci.yml.

  • Return to the kpi-anomaly-api repository in GitHub.
  • Open the repository's Actions page.
  • Select the latest CI workflow run.
  • Open the test job.
  • Inspect the Run tests step.

The workflow should finish with a green check. Its test output should report 3 passed.

The Push or CI Run Failed?

Confirm that the remote URL uses your GitHub username in place of OWNER. Check that the public repository name is exactly kpi-anomaly-api.

If the workflow fails during installation or testing, inspect the first failed step in the test job. Compare the repository files with the full-code reference above.

Ask for targeted help with diagnosing the push or CI failure.

That closes the reproducibility gap. Your anomaly detector now behaves the same way locally, inside Docker plus on a GitHub-hosted test runner.

Secret mission

Make Sensitivity Configurable

Let API callers choose the expected anomaly proportion for each KPI batch. Validate the value before it reaches the model, then prove that changing it changes the result.

Clean Up Your Resources

Clean Up Your Resources

Your project uses local resources in Docker Desktop plus a public GitHub repository. For personal learning on the required path, these resources cost $0 to keep.

Resources you used:

  • The running Docker container named mycontainer.
  • The local Docker image named myimage.
  • The local kpi-anomaly-api folder. It contains the .venv environment plus your project files.
  • The public kpi-anomaly-api repository. It contains the green GitHub Actions CI workflow.

Keep everything running

No action needed. Choose this if you are still testing the API or extending its anomaly-detection behavior.

  • Keep mycontainer running so the interactive documentation remains available.
  • Keep myimage for reproducible local runs.
  • Keep the kpi-anomaly-api folder for future code changes.
  • Keep the public repository so its CI history remains available.

The API remains available at http://127.0.0.1/docs while the container runs. The CI workflow remains ready for future pushes and pull requests.

Pause - I'll come back to this later

Shut down the running container to free up local memory. Keep the image plus project files so you can resume later.

  • Pause the local runtime by running these commands in the activated PowerShell terminal:
docker stop mycontainer
deactivate

What do these commands pause?

  • The first command stops mycontainer without deleting it.
  • The deactivate command leaves the project virtual environment.
  • Check Docker Desktop to confirm mycontainer is stopped.
  • Check the PowerShell prompt to confirm the virtual environment marker is gone.

Container Still Running?

Confirm Docker Desktop is running before you retry the stop command.

Help me pause the project safely.

  • Resume the project later by running these commands from the PowerShell terminal in kpi-anomaly-api:
docker start mycontainer
.\.venv\Scripts\Activate.ps1

What do these commands restore?

  • The first command starts the existing mycontainer container.
  • The second command reactivates the project environment from .venv.

Having Trouble Resuming?

Confirm Docker Desktop is running if the container does not start. Confirm the terminal is inside kpi-anomaly-api if activation cannot find the script.

Help me resume the project.

Delete - I don't want to use this again

Deleting everything is a big step. This option removes the local artifacts plus the remote repository.

  • Remove the local Docker resources by running these commands in the PowerShell terminal:
docker stop mycontainer
docker rm mycontainer
docker image rm myimage

What do these commands remove?

  • The first command stops the running container.
  • The second command removes mycontainer from your machine.
  • The third command removes the local myimage image.
  • Check Docker Desktop to confirm mycontainer no longer appears.
  • Check Docker Desktop to confirm myimage no longer appears.

Docker Resource Still Present?

If Docker cannot remove the image, confirm mycontainer was removed first. Retry the image removal after the container disappears.

Help me remove the Docker resources.

  • Close the VS Code window that has kpi-anomaly-api open.
  • Open File Explorer from the Windows taskbar.
  • Return to the parent directory containing kpi-anomaly-api.
  • Select the kpi-anomaly-api folder.
  • Delete the selected folder.

The kpi-anomaly-api folder no longer appears in File Explorer. Its .venv environment is removed with it.

  • Open the public kpi-anomaly-api repository on GitHub.
  • Select Settings.
  • Select General in the repository settings.
  • Scroll to Danger Zone.
  • Select Delete this repository.
  • Complete GitHub's confirmation prompts.

The repository URL no longer opens the project. Its CI workflow is removed with the repository.

Nice Work!

Nice Work!

You did it! You've built a service that catches suspicious KPI relationships and proves its behavior on every update.

You've learned how to:

  • Build a typed FastAPI REST API that validates KPI batches through a clear request contract.
  • Reveal the fixed-threshold blind spot when independent rules miss campaign-spike. Apply Isolation Forest to return anomaly flags plus decision scores for the unusual KPI relationship.
  • Ship the service in a Docker container with a GitHub Actions CI workflow that runs the test suite on every push or pull request.
  • Secret Mission: Expose a validated contamination input that changes model sensitivity. Use Pydantic to reject out-of-range values before model execution. Protect the new behavior with a fourth API test.

Ready to quiz yourself? Let's see what stuck.