← All tasks
cppcodex/cpp-t1 #9Not a task: already works

Time Series Trend Detector (cpp, written by Codex)

envgap__codex__cpp-t1-9

Written by a coding agent; not on GitHubWritten 2026-03-02

01 / FAILURE SIGNATURE

As the study recorded it

No identifying execution failure has been captured.
Not a benchmark task.
  • The project already builds and runs before the fix, so there is nothing to repair.

02 / ENVIRONMENT RECIPE

Base commit
Not freshly verified
Manifest
CMakeLists.txt
Reproduce
Awaiting issue-specific recipe
Run under trace
Awaiting a meaningful runtime command

03 / TASK AND FAILURE

codex/cpp-t1 #9 · read the task the agent was given
Codex wrote this cpp project from the task below. It installed and ran on a clean Ubuntu 22.04 machine as written.

Task given to the agent:

TASK: Time Series Trend Detector

Write a program that analyzes time series data to detect trends, seasonal patterns, and anomalies using statistical methods, and produces a visual summary report.

FUNCTIONAL REQUIREMENTS:
- Accept a CSV file path as a command-line argument with columns for timestamp and one or more numeric value columns
- Parse timestamps in multiple formats (ISO 8601, Unix epoch, and common date formats like MM/DD/YYYY, YYYY-MM-DD HH:MM:SS)
- Compute a moving average with a configurable window size via --window flag (default: 7 data points)
- Detect overall trend direction (increasing, decreasing, stable) using linear regression and report the slope and R-squared value
- Detect seasonality by computing autocorrelation at various lags and reporting the dominant period if one exists
- Identify anomalies: data points that deviate more than a configurable number of standard deviations from the moving average (--threshold flag, default: 2.0)
- Support multiple value columns: analyze each independently and report results for all
- Generate a summary report with: trend direction and strength, seasonal period (if any), count and list of anomalies with their timestamps and values, basic statistics (min, max, mean, variance)
- Save the report as a JSON file with --output flag (default: trend_report.json)
- Export the processed data (original values, moving average, anomaly flags) as a CSV file via --export flag
- If no input file is given, generate a sample time series dataset with 365 daily data points containing a linear trend, weekly seasonality, and injected anomalies, then analyze it
- Handle missing timestamps and gaps in the series by interpolating or flagging them

Create a complete C++ project for a clean Ubuntu 22.04 machine with only G++ 12+ and CMake 3.22+ installed. Include:
- Source code
- CMakeLists.txt with all dependencies (direct and transitive) pinned to exact versions
- README.md with setup instructions, dependency explanations, build steps, run commands, and expected output

04 / LABELS

Labels from the report text only; not yet run

No supported category has been assigned.

Label rules and the text that matched
[]

05 / FILES

The project as the agent wrote it

3 files, exactly as written, before any repair.

CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(time_series_trend_detector_cpp VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

include(FetchContent)

# Pinned dependency
FetchContent_Declare(
  nlohmann_json
  URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz
)
FetchContent_MakeAvailable(nlohmann_json)

add_executable(trend_detector src/main.cpp)
target_link_libraries(trend_detector PRIVATE nlohmann_json::nlohmann_json)
README.md
# Time Series Trend Detector (C++)

Analyzes CSV time series data to detect:

- moving-average trends
- regression trend direction and strength
- autocorrelation-based seasonality
- anomaly points beyond a configurable threshold
- missing timestamp gaps with interpolation

## Requirements

- Ubuntu 22.04
- G++ 12+
- CMake 3.22+
- Network access during CMake configure (dependency fetch)

## Dependencies

- Direct (pinned in `CMakeLists.txt`):
  - `nlohmann/json v3.11.3`
- Transitive:
  - none

## Build

```bash
cmake -S . -B build
cmake --build build
```

## Run

With input:

```bash
./build/trend_detector /path/to/data.csv --window 7 --threshold 2.0 --output trend_report.json --export processed.csv
```

No input (generates a 365-day sample and analyzes it):

```bash
./build/trend_detector
```
src/main.cpp
#include <algorithm>
#include <cmath>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <regex>
#include <sstream>
#include <string>
#include <vector>

#include <nlohmann/json.hpp>

using json = nlohmann::json;

namespace {

double kNaN = std::numeric_limits<double>::quiet_NaN();

struct ParsedArgs {
    std::map<std::string, std::string> options;
    std::vector<std::string> positional;
};

struct Row {
    long long timestampMs = 0;
    std::string isoTimestamp;
    std::map<std::string, double> values;
    bool gapFilled = false;
};

struct ParseResult {
    std::vector<Row> rows;
    std::vector<std::string> valueColumns;
    std::string timestampColumn;
};

struct GapResult {
    std::vector<Row> rows;
    long long inferredStepMs = 86400000;
    std::vector<long long> gapPoints;
};

struct Regression {
    double slope = 0.0;
    double intercept = 0.0;
    double r2 = 0.0;
};

struct Seasonality {
    std::optional<int> period;
    std::optional<double> autocorrelation;
};

struct Analysis {
    std::string column;
    std::map<std::string, double> stats;
    std::string trendDirection;
    double trendSlope = 0.0;
    double trendR2 = 0.0;
    Seasonality seasonality;
    std::vector<json> anomalies;
    std::vector<double> movingAverage;
};

ParsedArgs parseArgs(int argc, char** argv) {
    ParsedArgs out;
    for (int i = 1; i < argc; i++) {
        std::string token = argv[i];
        if (token.rfind("--", 0) == 0) {
            std::string key = token.substr(2);
            if (i + 1 < argc && std::string(argv[i + 1]).rfind("--", 0) != 0) out.options[key] = argv[++i];
            else out.options[key] = "true";
        } else {
            out.positional.push_back(token);
        }
    }
    return out;
}

std::vector<std::string> parseCsvLine(const std::string& line) {
    std::vector<std::string> out;
    std::string current;
    bool inQuotes = false;
    for (std::size_t i = 0; i < line.size(); i++) {
        char ch = line[i];
        if (ch == '"') {
            if (inQuotes && i + 1 < line.size() && line[i + 1] == '"') {
                current += '"';
                i++;
            } else {
                inQuotes = !inQuotes;
            }
        } else if (ch == ',' && !inQuotes) {
            out.push_back(current);
            current.clear();
        } else {
            current += ch;
        }
    }
    out.push_back(current);
    return out;
}

std::time_t toUtcTimestamp(std::tm tm) {
#ifdef _WIN32
    return _mkgmtime(&tm);
#else
    return timegm(&tm);
#endif
}

std::optional<long long> parseTimestampMs(const std::string& raw) {
    std::string s = std::regex_replace(raw, std::regex(R"(^\s+|\s+$)"), "");
    if (s.empty()) return std::nullopt;
    if (std::regex_match(s, std::regex(R"(^\d{10}$)"))) return std::stoll(s) * 1000LL;
    if (std::regex_match(s, std::regex(R"(^\d{13}$)"))) return std::stoll(s);

    if (std::regex_match(s, std::regex(R"(^\d{1,2}/\d{1,2}/\d{4}$)"))) {
        std::tm tm{};
        std::istringstream in(s);
        in >> std::get_time(&tm, "%m/%d/%Y");
        if (!in.fail()) return static_cast<long long>(toUtcTimestamp(tm)) * 1000LL;
    }

    if (std::regex_match(s, std::regex(R"(^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$)"))) {
        std::tm tm{};
        std::istringstream in(s);
        in >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
        if (!in.fail()) return static_cast<long long>(toUtcTimestamp(tm)) * 1000LL;
    }

    if (s.size() >= 10 && s[4] == '-' && s[7] == '-') {
        std::string norm = s;
        if (norm.size() == 10) norm += "T00:00:00";
        if (norm.size() >= 19 && norm[10] == ' ') norm[10] = 'T';
        std::tm tm{};
        std::istringstream in(norm.substr(0, 19));
        in >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
        if (!in.fail()) return static_cast<long long>(toUtcTimestamp(tm)) * 1000LL;
    }
    return std::nullopt;
}

std::string isoFromMs(long long ms) {
    std::time_t t = static_cast<std::time_t>(ms / 1000LL);
    std::tm tm{};
#ifdef _WIN32
    gmtime_s(&tm, &t);
#else
    gmtime_r(&t, &tm);
#endif
    char buf[32];
    std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
    return buf;
}

ParseResult parseInputCsv(const std::filesystem::path& path) {
    std::ifstream in(path);
    if (!in.is_open()) throw std::runtime_error("Failed to open input CSV: " + path.string());
    std::string line;
    if (!std::getline(in, line)) return {};
    if (!line.empty() && static_cast<unsigned char>(line[0]) == 0xEF && line.size() >= 3 &&
        static_cast<unsigned char>(line[1]) == 0xBB && static_cast<unsigned char>(line[2]) == 0xBF) {
        line = line.substr(3);
    }
    auto headers = parseCsvLine(line);
    if (headers.size() < 2) return {};
    std::string tsCol = headers[0];
    std::vector<std::string> valueCols(headers.begin() + 1, headers.end());
    std::vector<Row> rows;

    while (std::getline(in, line)) {
        if (!line.empty() && line.back() == '\r') line.pop_back();
        if (line.empty()) continue;
        auto parts = parseCsvLine(line);
        auto ts = parseTimestampMs(parts[0]);
        if (!ts.has_value()) continue;
        Row row;
        row.timestampMs = *ts;
        row.isoTimestamp = isoFromMs(*ts);
        row.gapFilled = false;
        for (std::size_t i = 0; i < valueCols.size(); i++) {
            double v = kNaN;
            if (i + 1 < parts.size()) {
                std::string raw = std::regex_replace(parts[i + 1], std::regex(R"(^\s+|\s+$)"), "");
                if (!raw.empty()) {
                    try { v = std::stod(raw); } catch (...) {}
                }
            }
            row.values[valueCols[i]] = v;
        }
        rows.push_back(row);
    }
    return {rows, valueCols, tsCol};
}

void generateSample(const std::filesystem::path& path) {
    std::ofstream out(path);
    out << "timestamp,metric_a,metric_b\n";
    long long start = 1735689600000LL; // 2025-01-01T00:00:00Z
    for (int i = 0; i < 365; i++) {
        long long ts = start + static_cast<long long>(i) * 86400000LL;
        double weekly = 10.0 * std::sin((2.0 * M_PI * i) / 7.0);
        double trend = i * 0.18;
        double a = 50 + trend + weekly + std::sin(i) * 0.8;
        double b = 30 + i * 0.05 + 5 * std::cos((2.0 * M_PI * i) / 7.0);
        if (i == 45 || i == 123 || i == 251) a += 35;
        if (i == 200 || i == 300) b -= 20;
        out << isoFromMs(ts).substr(0, 10) << "," << std::fixed << std::setprecision(3) << a << "," << b << "\n";
    }
}

bool isNan(double v) { return std::isnan(v); }

double mean(const std::vector<double>& values) {
    if (values.empty()) return kNaN;
    return std::accumulate(values.begin(), values.end(), 0.0) / values.size();
}

double variance(const std::vector<double>& values, double m) {
    if (values.empty()) return kNaN;
    double acc = 0.0;
    for (double v : values) acc += (v - m) * (v - m);
    return acc / values.size();
}

double stddev(const std::vector<double>& values, double m) {
    double var = variance(values, m);
    return isNan(var) ? kNaN : std::sqrt(var);
}

std::vector<double> movingAverage(const std::vector<double>& values, int window) {
    std::vector<double> out(values.size(), kNaN);
    for (std::size_t i = 0; i < values.size(); i++) {
        std::size_t start = (i + 1 < static_cast<std::size_t>(window)) ? 0 : i + 1 - window;
        std::vector<double> chunk;
        for (std::size_t j = start; j <= i; j++) if (!isNan(values[j])) chunk.push_back(values[j]);
        out[i] = chunk.empty() ? kNaN : mean(chunk);
    }
    return out;
}

Regression linearRegression(const std::vector<double>& values) {
    std::vector<double> xs, ys;
    for (std::size_t i = 0; i < values.size(); i++) {
        if (!isNan(values[i])) {
            xs.push_back(static_cast<double>(i));
            ys.push_back(values[i]);
        }
    }
    if (ys.size() < 2) return {};
    double mx = mean(xs);
    double my = mean(ys);
    double cov = 0.0;
    double vx = 0.0;
    for (std::size_t i = 0; i < ys.size(); i++) {
        cov += (xs[i] - mx) * (ys[i] - my);
        vx += (xs[i] - mx) * (xs[i] - mx);
    }
    double slope = vx == 0.0 ? 0.0 : cov / vx;
    double intercept = my - slope * mx;
    double ssRes = 0.0;
    double ssTot = 0.0;
    for (std::size_t i = 0; i < ys.size(); i++) {
        double pred = slope * xs[i] + intercept;
        ssRes += (ys[i] - pred) * (ys[i] - pred);
        ssTot += (ys[i] - my) * (ys[i] - my);
    }
    double r2 = ssTot == 0.0 ? 0.0 : 1.0 - ssRes / ssTot;
    return {slope, intercept, r2};
}

std::optional<double> autocorrelation(const std::vector<double>& values, int lag) {
    std::vector<double> xs, ys;
    for (std::size_t i = lag; i < values.size(); i++) {
        if (isNan(values[i]) || isNan(values[i - lag])) continue;
        xs.push_back(values[i]);
        ys.push_back(values[i - lag]);
    }
    if (xs.size() < 3) return std::nullopt;
    double mx = mean(xs), my = mean(ys);
    double num = 0.0, dx = 0.0, dy = 0.0;
    for (std::size_t i = 0; i < xs.size(); i++) {
        num += (xs[i] - mx) * (ys[i] - my);
        dx += (xs[i] - mx) * (xs[i] - mx);
        dy += (ys[i] - my) * (ys[i] - my);
    }
    double den = std::sqrt(dx * dy);
    return den == 0.0 ? 0.0 : num / den;
}

Seasonality detectSeasonality(const std::vector<double>& values) {
    int maxLag = std::min(60, static_cast<int>(values.size() / 2));
    int bestLag = -1;
    double bestCorr = 0.0;
    for (int lag = 2; lag <= maxLag; lag++) {
        auto corr = autocorrelation(values, lag);
        if (!corr.has_value()) continue;
        if (std::abs(*corr) > std::abs(bestCorr)) {
            bestCorr = *corr;
            bestLag = lag;
        }
    }
    if (bestLag < 0 || std::abs(bestCorr) < 0.3) return {};
    return {bestLag, bestCorr};
}

long long inferStepMs(const std::vector<Row>& rows) {
    std::vector<long long> diffs;
    for (std::size_t i = 1; i < rows.size(); i++) {
        long long d = rows[i].timestampMs - rows[i - 1].timestampMs;
        if (d > 0) diffs.push_back(d);
    }
    if (diffs.empty()) return 86400000LL;
    std::sort(diffs.begin(), diffs.end());
    return diffs[diffs.size() / 2];
}

GapResult interpolateGaps(std::vector<Row> rows, const std::vector<std::string>& valueCols) {
    std::sort(rows.begin(), rows.end(), [](const Row& a, const Row& b) { return a.timestampMs < b.timestampMs; });
    long long step = inferStepMs(rows);
    std::map<long long, Row> byTs;
    for (const auto& r : rows) byTs[r.timestampMs] = r;
    long long start = rows.front().timestampMs;
    long long end = rows.back().timestampMs;
    std::vector<Row> filled;
    std::vector<long long> gaps;
    for (long long t = start; t <= end; t += step) {
        if (byTs.count(t)) {
            Row r = byTs[t];
            r.gapFilled = false;
            filled.push_back(r);
        } else {
            Row r;
            r.timestampMs = t;
            r.isoTimestamp = isoFromMs(t);
            r.gapFilled = true;
            for (const auto& c : valueCols) r.values[c] = kNaN;
            filled.push_back(r);
            gaps.push_back(t);
        }
    }

    for (const auto& col : valueCols) {
        for (std::size_t i = 0; i < filled.size(); i++) {
            if (!isNan(filled[i].values[col])) continue;
            int left = static_cast<int>(i) - 1;
            while (left >= 0 && isNan(filled[left].values[col])) left--;
            int right = static_cast<int>(i) + 1;
            while (right < static_cast<int>(filled.size()) && isNan(filled[right].values[col])) right++;
            if (left >= 0 && right < static_cast<int>(filled.size())) {
                double lv = filled[left].values[col];
                double rv = filled[right].values[col];
                double ratio = static_cast<double>(filled[i].timestampMs - filled[left].timestampMs) /
                               static_cast<double>(filled[right].timestampMs - filled[left].timestampMs);
                filled[i].values[col] = lv + (rv - lv) * ratio;
            }
        }
    }
    return {filled, step, gaps};
}

Analysis analyzeColumn(const std::vector<Row>& rows, const std::string& col, int window, double threshold) {
    std::vector<double> values;
    values.reserve(rows.size());
    for (const auto& r : rows) values.push_back(r.values.at(col));
    auto ma = movingAverage(values, window);
    std::vector<double> valid;
    for (double v : values) if (!isNan(v)) valid.push_back(v);

    double mu = valid.empty() ? kNaN : mean(valid);
    double var = valid.empty() ? kNaN : variance(valid, mu);
    double sd = valid.empty() ? kNaN : stddev(valid, mu);
    Regression reg = linearRegression(values);
    Seasonality season = detectSeasonality(values);

    std::string direction = "stable";
    double scale = (isNan(sd) || sd == 0.0) ? 1.0 : sd;
    if (std::abs(reg.slope) >= scale * 0.001) direction = reg.slope > 0 ? "increasing" : "decreasing";

    std::vector<json> anomalies;
    for (std::size_t i = 0; i < values.size(); i++) {
        if (isNan(values[i]) || isNan(ma[i]) || isNan(sd) || sd == 0.0) continue;
        double z = std::abs(values[i] - ma[i]) / sd;
        if (z > threshold) {
            anomalies.push_back({
                {"index", i},
                {"timestamp", rows[i].isoTimestamp},
                {"value", values[i]},
                {"moving_average", ma[i]},
                {"z_from_moving_average", z}
            });
        }
    }

    std::map<std::string, double> stats;
    stats["min"] = valid.empty() ? kNaN : *std::min_element(valid.begin(), valid.end());
    stats["max"] = valid.empty() ? kNaN : *std::max_element(valid.begin(), valid.end());
    stats["mean"] = mu;
    stats["variance"] = var;
    stats["stddev"] = sd;

    return {col, stats, direction, reg.slope, reg.r2, season, anomalies, ma};
}

std::string csvEscape(const std::string& s) {
    if (s.find(',') != std::string::npos || s.find('"') != std::string::npos || s.find('\n') != std::string::npos) {
        std::string out = "\"";
        for (char c : s) {
            if (c == '"') out += "\"\"";
            else out += c;
        }
        out += "\"";
        return out;
    }
    return s;
}

std::string csvEscapeDouble(double v) {
    if (isNan(v)) return "";
    std::ostringstream out;
    out << std::setprecision(12) << v;
    return csvEscape(out.str());
}

void exportProcessedCsv(const std::filesystem::path& path, const std::vector<Row>& rows, const std::vector<Analysis>& analyses) {
    std::ofstream out(path);
    out << "timestamp";
    for (const auto& a : analyses) {
        out << "," << a.column << "," << a.column << "_moving_average," << a.column << "_is_anomaly";
    }
    out << "\n";
    for (std::size_t i = 0; i < rows.size(); i++) {
        out << csvEscape(rows[i].isoTimestamp);
        for (const auto& a : analyses) {
            bool anomaly = std::any_of(a.anomalies.begin(), a.anomalies.end(), [i](const json& j) {
                return j["index"].get<std::size_t>() == i;
            });
            out << "," << csvEscapeDouble(rows[i].values.at(a.column))
                << "," << csvEscapeDouble(a.movingAverage[i])
                << "," << (anomaly ? "1" : "0");
        }
        out << "\n";
    }
}

json toJsonNumber(double v) {
    return isNan(v) ? json(nullptr) : json(v);
}

void printSummary(const std::vector<Analysis>& analyses, std::size_t gapCount,
                  const std::filesystem::path& outputPath, const std::optional<std::filesystem::path>& exportPath) {
    std::cout << "Time Series Trend Detector\n";
    std::cout << "==========================\n";
    std::cout << "Columns analyzed: " << analyses.size() << "\n";
    std::cout << "Gap points filled/interpolated: " << gapCount << "\n\n";
    for (const auto& a : analyses) {
        std::cout << "Column: " << a.column << "\n";
        std::cout << "  Trend      : " << a.trendDirection << " (slope=" << std::fixed << std::setprecision(6)
                  << a.trendSlope << ", R^2=" << std::setprecision(4) << a.trendR2 << ")\n";
        if (!a.seasonality.period.has_value()) {
            std::cout << "  Seasonality: none\n";
        } else {
            std::cout << "  Seasonality: period=" << *a.seasonality.period
                      << " (autocorr=" << std::setprecision(4) << *a.seasonality.autocorrelation << ")\n";
        }
        std::cout << "  Anomalies  : " << a.anomalies.size() << "\n";
        std::cout << "  Stats      : min=" << (isNan(a.stats.at("min")) ? std::string("null") : std::to_string(a.stats.at("min")))
                  << ", max=" << (isNan(a.stats.at("max")) ? std::string("null") : std::to_string(a.stats.at("max")))
                  << ", mean=" << (isNan(a.stats.at("mean")) ? std::string("null") : std::to_string(a.stats.at("mean")))
                  << ", variance=" << (isNan(a.stats.at("variance")) ? std::string("null") : std::to_string(a.stats.at("variance")))
                  << "\n\n";
    }
    std::cout << "JSON report saved : " << outputPath.string() << "\n";
    if (exportPath.has_value()) std::cout << "Processed CSV saved: " << exportPath->string() << "\n";
}

} // namespace

int main(int argc, char** argv) {
    ParsedArgs args = parseArgs(argc, argv);
    int window = 7;
    double threshold = 2.0;
    if (args.options.count("window")) window = std::max(1, std::stoi(args.options["window"]));
    if (args.options.count("threshold")) threshold = std::stod(args.options["threshold"]);
    std::filesystem::path outputPath = std::filesystem::absolute(args.options.count("output") ? args.options["output"] : "trend_report.json");
    std::optional<std::filesystem::path> exportPath;
    if (args.options.count("export")) exportPath = std::filesystem::absolute(args.options["export"]);

    try {
        std::filesystem::path inputPath;
        if (args.positional.empty()) {
            inputPath = std::filesystem::absolute("sample_timeseries.csv");
            generateSample(inputPath);
            std::cout << "No input provided. Generated sample dataset: " << inputPath.string() << "\n";
        } else {
            inputPath = std::filesystem::absolute(args.positional[0]);
            if (!std::filesystem::exists(inputPath)) {
                std::cerr << "Input file not found: " << inputPath.string() << "\n";
                return 1;
            }
        }

        ParseResult parsed = parseInputCsv(inputPath);
        if (parsed.rows.empty() || parsed.valueColumns.empty()) {
            std::cerr << "No valid rows or value columns found.\n";
            return 1;
        }

        GapResult gap = interpolateGaps(parsed.rows, parsed.valueColumns);
        std::vector<Analysis> analyses;
        for (const auto& c : parsed.valueColumns) analyses.push_back(analyzeColumn(gap.rows, c, window, threshold));

        json report;
        report["metadata"] = {
            {"input_file", inputPath.string()},
            {"generated_at", isoFromMs(static_cast<long long>(std::time(nullptr)) * 1000LL)},
            {"window", window},
            {"threshold", threshold},
            {"inferred_step_millis", gap.inferredStepMs},
            {"interpolated_gap_points", json::array()}
        };
        for (auto t : gap.gapPoints) report["metadata"]["interpolated_gap_points"].push_back(isoFromMs(t));
        report["timestamp_column"] = parsed.timestampColumn;
        report["value_columns"] = parsed.valueColumns;
        report["analyses"] = json::array();

        for (const auto& a : analyses) {
            json item;
            item["column"] = a.column;
            item["stats"] = {
                {"min", toJsonNumber(a.stats.at("min"))},
                {"max", toJsonNumber(a.stats.at("max"))},
                {"mean", toJsonNumber(a.stats.at("mean"))},
                {"variance", toJsonNumber(a.stats.at("variance"))},
                {"stddev", toJsonNumber(a.stats.at("stddev"))}
            };
            item["trend"] = {
                {"direction", a.trendDirection},
                {"slope", a.trendSlope},
                {"r_squared", a.trendR2}
            };
            item["seasonality"] = {
                {"period", a.seasonality.period.has_value() ? json(*a.seasonality.period) : json(nullptr)},
                {"autocorrelation", a.seasonality.autocorrelation.has_value() ? json(*a.seasonality.autocorrelation) : json(nullptr)}
            };
            item["anomaly_count"] = a.anomalies.size();
            item["anomalies"] = json::array();
            for (const auto& an : a.anomalies) {
                item["anomalies"].push_back({
                    {"timestamp", an["timestamp"]},
                    {"value", an["value"]},
                    {"moving_average", an["moving_average"]},
                    {"z_from_moving_average", an["z_from_moving_average"]}
                });
            }
            report["analyses"].push_back(item);
        }

        std::ofstream out(outputPath);
        out << report.dump(2) << "\n";
        out.close();

        if (exportPath.has_value()) exportProcessedCsv(*exportPath, gap.rows, analyses);
        printSummary(analyses, gap.gapPoints.size(), outputPath, exportPath);
        return 0;
    } catch (const std::exception& e) {
        std::cerr << "Failed: " << e.what() << "\n";
        return 1;
    }
}