FULL SOURCE INCLUDED

Reproduction Guide

Run the exact benchmark yourself on your own AWS account. Every script, every rule, every Terraform resource documented and copy-pasteable.

25 min
Hands-on time
$0.45
Total AWS cost
79.5M
Rows scored
7
Files to copy
Before you start

Prerequisites

AWS CLI v2
Authenticated with Glue + S3 + IAM permissions
aws configure
Terraform ≥ 1.6
Provisions all 15 AWS resources
Download →
Python 3.11+
Only for local dataset staging
python --version

Minimum IAM permissions

  • glue:* on jobs named sparkrules-bench-*
  • s3:* on buckets named sparkrules-bench-*
  • iam:CreateRole, iam:AttachRolePolicy, iam:PutRolePolicy, iam:PassRole
  • logs:CreateLogGroup, logs:PutLogEvents
  • · ~2 GB local disk for taxi parquet cache
What you'll score

Dataset: NYC TLC Yellow Taxi

Real production-grade open data used by AWS, Google Cloud, Databricks, and academic benchmarks.

SourceNYC Taxi & Limousine Commission
Download URLd37ci6vzurychx.cloudfront.net/trip-data/
Data pagenyc.gov/site/tlc/about/tlc-trip-record-data.page
FormatApache Parquet (Snappy compressed)
Rows used79,479,946 (24 months: Jan 2023 - Dec 2024)
Size on disk~1.33 GB (compressed parquet)
LicenseNYC Open Data, public domain
Schema11 columns: VendorID, tpep_pickup_datetime, passenger_count, trip_distance, fare_amount, tip_amount, total_amount, payment_type, PULocationID, DOLocationID, store_and_fwd_flag
Why not s3://nyc-tlc/? The legacy bucket is locked down, returns AccessDenied even with --request-payer requester. CloudFront is the new canonical source. Our staging script uses it and re-stages to your own S3 bucket for Glue.
Structure

File layout

7 files total. All copy-pasteable from this guide.

deploy/
└── aws-glue/
    ├── terraform/
    │   ├── main.tf              # 15 AWS resources
    │   ├── variables.tf         # 9 variables
    │   ├── outputs.tf           # 7 outputs
    │   └── terraform.tfvars     # your config
    └── glue_job/
        ├── benchmark_taxi_rules.py     # main scoring script
        ├── consolidate_taxi_input.py   # one-time schema prep
        └── taxi_rules_30.drl           # 30-rule DRL pack
stage_taxi_to_s3.py                        # local dataset staging
1
Step one

Stage the dataset to S3

Save as stage_taxi_to_s3.py. Run after Step 2 provisions the bucket.

"""Download 24 months of NYC TLC yellow taxi parquet from CloudFront and stage to S3."""
from __future__ import annotations
import concurrent.futures as cf
import subprocess
import sys
import time
from pathlib import Path
from urllib.request import urlretrieve

# EDIT THESE TWO LINES FOR YOUR ACCOUNT:
BUCKET = "sparkrules-bench-dev-artifacts-YOURACCOUNTID"
REGION = "us-east-1"

S3_PREFIX = "input/yellow/"
CLOUDFRONT = "https://d37ci6vzurychx.cloudfront.net/trip-data/"
LOCAL_DIR = Path("./yellow_cache")
LOCAL_DIR.mkdir(parents=True, exist_ok=True)

MONTHS = [f"{y}-{m:02d}" for y in (2023, 2024) for m in range(1, 13)]

def fetch_and_stage(month):
    fname = f"yellow_tripdata_{month}.parquet"
    local = LOCAL_DIR / fname
    if not local.exists():
        urlretrieve(f"{CLOUDFRONT}{fname}", local)
    size = local.stat().st_size
    subprocess.run(
        ["aws", "s3", "cp", str(local), f"s3://{BUCKET}/{S3_PREFIX}{fname}",
         "--region", REGION, "--only-show-errors"],
        check=True,
    )
    return (month, size)

print(f"staging {len(MONTHS)} months to s3://{BUCKET}/{S3_PREFIX}")
with cf.ThreadPoolExecutor(max_workers=6) as ex:
    for month, size in ex.map(fetch_and_stage, MONTHS):
        print(f"  [{month}] {size/1_000_000:.1f} MB")
print("done")
2
Step two

Terraform infrastructure

All 15 AWS resources in one module. Four files.

deploy/aws-glue/terraform/main.tf

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
}

provider "aws" {
  region = var.region
}

locals {
  name_prefix = "${var.project}-${var.environment}"
  tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# --- S3 buckets ---

resource "aws_s3_bucket" "artifacts" {
  bucket        = "${local.name_prefix}-artifacts-${data.aws_caller_identity.current.account_id}"
  force_destroy = true
  tags          = local.tags
}

resource "aws_s3_bucket_versioning" "artifacts" {
  bucket = aws_s3_bucket.artifacts.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_public_access_block" "artifacts" {
  bucket                  = aws_s3_bucket.artifacts.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "artifacts" {
  bucket = aws_s3_bucket.artifacts.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }
}

resource "aws_s3_bucket" "results" {
  bucket        = "${local.name_prefix}-results-${data.aws_caller_identity.current.account_id}"
  force_destroy = true
  tags          = local.tags
}

resource "aws_s3_bucket_public_access_block" "results" {
  bucket                  = aws_s3_bucket.results.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_server_side_encryption_configuration" "results" {
  bucket = aws_s3_bucket.results.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }
}

# --- Upload the Glue scripts + DRL pack ---

resource "aws_s3_object" "glue_job_script" {
  bucket = aws_s3_bucket.artifacts.id
  key    = "scripts/benchmark_taxi_rules.py"
  source = "${path.module}/../glue_job/benchmark_taxi_rules.py"
  etag   = filemd5("${path.module}/../glue_job/benchmark_taxi_rules.py")
}

resource "aws_s3_object" "drl_pack" {
  bucket = aws_s3_bucket.artifacts.id
  key    = "drl/taxi_rules_30.drl"
  source = "${path.module}/../glue_job/taxi_rules_30.drl"
  etag   = filemd5("${path.module}/../glue_job/taxi_rules_30.drl")
}

resource "aws_s3_object" "consolidate_job_script" {
  bucket = aws_s3_bucket.artifacts.id
  key    = "scripts/consolidate_taxi_input.py"
  source = "${path.module}/../glue_job/consolidate_taxi_input.py"
  etag   = filemd5("${path.module}/../glue_job/consolidate_taxi_input.py")
}

# --- IAM role for Glue ---

data "aws_caller_identity" "current" {}

data "aws_iam_policy_document" "glue_assume" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["glue.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "glue_job" {
  name               = "${local.name_prefix}-glue-job-role"
  assume_role_policy = data.aws_iam_policy_document.glue_assume.json
  tags               = local.tags
}

resource "aws_iam_role_policy_attachment" "glue_service" {
  role       = aws_iam_role.glue_job.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSGlueServiceRole"
}

data "aws_iam_policy_document" "glue_s3" {
  statement {
    sid     = "WriteResults"
    actions = ["s3:PutObject", "s3:GetObject", "s3:DeleteObject", "s3:ListBucket"]
    resources = [
      aws_s3_bucket.results.arn,
      "${aws_s3_bucket.results.arn}/*",
      aws_s3_bucket.artifacts.arn,
      "${aws_s3_bucket.artifacts.arn}/*",
    ]
  }
  statement {
    sid = "Logs"
    actions = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
    resources = ["*"]
  }
}

resource "aws_iam_role_policy" "glue_s3" {
  name   = "${local.name_prefix}-glue-s3"
  role   = aws_iam_role.glue_job.id
  policy = data.aws_iam_policy_document.glue_s3.json
}

# --- Benchmark Glue job ---

resource "aws_glue_job" "benchmark" {
  name              = "${local.name_prefix}-benchmark"
  role_arn          = aws_iam_role.glue_job.arn
  glue_version      = "5.0"
  worker_type       = var.worker_type
  number_of_workers = var.number_of_workers
  timeout           = 30
  max_retries       = 0

  command {
    name            = "glueetl"
    script_location = "s3://${aws_s3_bucket.artifacts.id}/${aws_s3_object.glue_job_script.key}"
    python_version  = "3"
  }

  default_arguments = {
    "--job-language"                     = "python"
    "--enable-metrics"                   = "true"
    "--enable-continuous-cloudwatch-log" = "true"
    "--enable-glue-datacatalog"          = "true"
    "--enable-spark-ui"                  = "true"
    "--spark-event-logs-path"            = "s3://${aws_s3_bucket.artifacts.id}/spark-logs/"
    "--drl_s3_path"                      = "s3://${aws_s3_bucket.artifacts.id}/${aws_s3_object.drl_pack.key}"
    "--input_s3_path"                    = var.input_s3_path
    "--output_s3_path"                   = "s3://${aws_s3_bucket.results.id}/taxi-bench/"
    "--rows_limit"                       = tostring(var.rows_limit)
    "--num_partitions"                   = tostring(var.num_partitions)
    "--TempDir"                          = "s3://${aws_s3_bucket.artifacts.id}/tmp/"
    "--additional-python-modules"        = "sparkrules==${var.sparkrules_version}"
  }

  tags = local.tags
}

# --- Consolidate (one-time schema normalization) Glue job ---

resource "aws_glue_job" "consolidate" {
  name              = "${local.name_prefix}-consolidate"
  role_arn          = aws_iam_role.glue_job.arn
  glue_version      = "5.0"
  worker_type       = var.worker_type
  number_of_workers = var.number_of_workers
  timeout           = 30
  max_retries       = 0

  command {
    name            = "glueetl"
    script_location = "s3://${aws_s3_bucket.artifacts.id}/${aws_s3_object.consolidate_job_script.key}"
    python_version  = "3"
  }

  default_arguments = {
    "--job-language"                     = "python"
    "--enable-metrics"                   = "true"
    "--enable-continuous-cloudwatch-log" = "true"
    "--enable-glue-datacatalog"          = "true"
    "--input_s3_path"                    = "s3://${aws_s3_bucket.artifacts.id}/input/yellow/"
    "--output_s3_path"                   = "s3://${aws_s3_bucket.artifacts.id}/input/yellow_consolidated/"
    "--TempDir"                          = "s3://${aws_s3_bucket.artifacts.id}/tmp/"
  }

  tags = local.tags
}

deploy/aws-glue/terraform/variables.tf

variable "region"             { type = string  default = "us-east-1" }
variable "project"            { type = string  default = "sparkrules-bench" }
variable "environment"        { type = string  default = "dev" }
variable "worker_type" {
  type = string
  default = "G.1X"
  validation {
    condition     = contains(["G.1X", "G.2X", "G.4X", "G.8X"], var.worker_type)
    error_message = "worker_type must be G.1X, G.2X, G.4X, or G.8X."
  }
}
variable "number_of_workers" {
  type = number
  default = 10
  validation {
    condition     = var.number_of_workers >= 2 && var.number_of_workers <= 200
    error_message = "number_of_workers must be between 2 and 200."
  }
}
variable "input_s3_path"      { type = string  default = "" }
variable "rows_limit"         { type = number  default = 0 }
variable "sparkrules_version" { type = string  default = "1.2.0" }
variable "num_partitions"     { type = number  default = 80 }

deploy/aws-glue/terraform/outputs.tf

output "glue_job_name"        { value = aws_glue_job.benchmark.name }
output "consolidate_job_name" { value = aws_glue_job.consolidate.name }
output "artifacts_bucket"     { value = aws_s3_bucket.artifacts.id }
output "results_bucket"       { value = aws_s3_bucket.results.id }
output "results_path"         { value = "s3://${aws_s3_bucket.results.id}/taxi-bench/" }
output "spark_logs_path"      { value = "s3://${aws_s3_bucket.artifacts.id}/spark-logs/" }
output "start_command" {
  value = "aws glue start-job-run --job-name ${aws_glue_job.benchmark.name} --region ${var.region}"
}

deploy/aws-glue/terraform/terraform.tfvars

region             = "us-east-1"
project            = "sparkrules-bench"
environment        = "dev"
worker_type        = "G.1X"
number_of_workers  = 10
num_partitions     = 80
sparkrules_version = "1.2.0"
rows_limit         = 0

# Update after first `terraform apply` - replace YOURACCOUNTID with your AWS account id
input_s3_path = "s3://sparkrules-bench-dev-artifacts-YOURACCOUNTID/input/yellow_consolidated/"
3
Step three

Glue scripts

deploy/aws-glue/glue_job/benchmark_taxi_rules.py

Main scoring script. Reads consolidated parquet, applies 30 rules via apply_with_counts, writes typed output and summary JSON back to S3.

"""sparkrules 1.2.0 benchmark on NYC TLC Yellow Taxi data."""
from __future__ import annotations
import json
import sys
import time
from datetime import datetime, timezone

from awsglue.context import GlueContext
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from pyspark.sql import functions as F


def main() -> int:
    args = getResolvedOptions(
        sys.argv,
        ["JOB_NAME", "drl_s3_path", "input_s3_path", "output_s3_path", "rows_limit"],
    )
    try:
        num_partitions = int(getResolvedOptions(sys.argv, ["num_partitions"])["num_partitions"])
    except Exception:
        num_partitions = 80

    sc = SparkContext()
    glue = GlueContext(sc)
    spark = glue.spark_session
    spark.conf.set("spark.sql.adaptive.enabled", "true")
    spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")

    drl_text = _read_s3_text(spark, args["drl_s3_path"])

    from sparkrules.compiler.rulepack import RulePack
    from sparkrules.spark.executor import SparkRuleExecutor

    pack = RulePack.from_drl(drl_text)
    summary = pack.summary()

    # Probe the optional Rust backend on the driver
    native_info = {"available": False, "version": None, "error": None}
    try:
        from sparkrules.native.bridge import load_native
        mod = load_native()
        if mod is not None:
            native_info = {"available": True, "version": mod.native_version(), "error": None}
    except Exception as e:
        native_info["error"] = f"{type(e).__name__}: {e}"

    # --- Read NYC TLC input ---
    t_read_start = time.perf_counter()
    df = spark.read.parquet(args["input_s3_path"])
    rows_limit = int(args.get("rows_limit", "0") or "0")
    if rows_limit > 0:
        df = df.limit(rows_limit)

    df = df.select(
        F.struct(
            F.col("VendorID").cast("bigint").alias("vendor_id"),
            F.col("passenger_count").cast("bigint").alias("passenger_count"),
            F.col("trip_distance").cast("double").alias("trip_distance"),
            F.col("fare_amount").cast("double").alias("fare_amount"),
            F.col("tip_amount").cast("double").alias("tip_amount"),
            F.col("total_amount").cast("double").alias("total_amount"),
            F.col("payment_type").cast("bigint").alias("payment_type"),
            F.col("PULocationID").cast("bigint").alias("pu_location"),
            F.col("DOLocationID").cast("bigint").alias("do_location"),
            F.hour(F.col("tpep_pickup_datetime")).alias("pickup_hour"),
            F.dayofweek(F.col("tpep_pickup_datetime")).alias("pickup_dow"),
        ).alias("t")
    ).repartition(num_partitions)
    t_read_elapsed = time.perf_counter() - t_read_start

    # --- Apply rules (Strategy A: Catalyst pushdown) ---
    executor = SparkRuleExecutor.from_drl(drl_text)

    t_scoring_start = time.perf_counter()
    if hasattr(executor, "apply_with_counts"):
        result, counts = executor.apply_with_counts(df)
        t_scoring_elapsed = time.perf_counter() - t_scoring_start
        total = int(counts.pop("_total_rows"))
        fired_counts = {k: int(v) for k, v in counts.items()}
        api_used = "apply_with_counts"
    else:
        result = executor.apply(df)
        rule_cols = [c for c in result.columns if c.startswith("r_")]
        agg_exprs = [F.sum(F.when(F.col(c), 1).otherwise(0)).alias(c) for c in rule_cols]
        agg_exprs.append(F.count(F.lit(1)).alias("total_rows"))
        fired_row = result.agg(*agg_exprs).collect()[0]
        t_scoring_elapsed = time.perf_counter() - t_scoring_start
        total = int(fired_row["total_rows"])
        fired_counts = {c: int(fired_row[c]) for c in rule_cols}
        api_used = "apply+manual_agg"

    rows_per_sec = total / t_scoring_elapsed if t_scoring_elapsed > 0 else 0

    try:
        catalyst_plan = result.limit(1)._jdf.queryExecution().optimizedPlan().toString()[:4000]
    except Exception as e:
        catalyst_plan = f"(plan capture failed: {e})"

    # --- Write typed results ---
    t_write_start = time.perf_counter()
    write_partitions = max(8, num_partitions // 2)
    result.coalesce(write_partitions).write.mode("overwrite").parquet(
        args["output_s3_path"].rstrip("/") + "/results/"
    )
    t_write_elapsed = time.perf_counter() - t_write_start

    # --- Write benchmark summary JSON ---
    bench = {
        "job_name": args["JOB_NAME"],
        "timestamp_utc": datetime.now(timezone.utc).isoformat(),
        "input_s3_path": args["input_s3_path"],
        "rows_scored": total,
        "rows_limit_applied": rows_limit,
        "num_partitions": num_partitions,
        "rulepack_summary": summary,
        "fired_counts": fired_counts,
        "timing": {
            "read_elapsed_s": round(t_read_elapsed, 3),
            "scoring_elapsed_s": round(t_scoring_elapsed, 3),
            "write_elapsed_s": round(t_write_elapsed, 3),
            "rows_per_sec": int(rows_per_sec),
            "api_used": api_used,
        },
        "native_backend": native_info,
        "sparkrules_version": _sparkrules_version(),
        "catalyst_plan_head": catalyst_plan,
    }
    bench_json = json.dumps(bench, indent=2, default=str)
    print(bench_json)

    # Write to S3
    summary_dir = args["output_s3_path"].rstrip("/") + "/benchmark_summary_dir"
    URI = spark._jvm.java.net.URI
    Path = spark._jvm.org.apache.hadoop.fs.Path
    FileSystem = spark._jvm.org.apache.hadoop.fs.FileSystem
    fs = FileSystem.get(URI(summary_dir), spark._jsc.hadoopConfiguration())
    if fs.exists(Path(summary_dir)):
        fs.delete(Path(summary_dir), True)

    rdd = spark.sparkContext.parallelize([bench_json], 1)
    rdd.saveAsTextFile(summary_dir)
    return 0


def _read_s3_text(spark, s3_path: str) -> str:
    return "\n".join(spark.read.text(s3_path).rdd.map(lambda r: r[0]).collect())


def _sparkrules_version() -> str:
    try:
        import sparkrules
        return sparkrules.__version__
    except Exception:
        return "unknown"


if __name__ == "__main__":
    main()

deploy/aws-glue/glue_job/consolidate_taxi_input.py

One-time prep job. Normalizes schema drift across 24 months (VendorID is INT32 in some months, INT64 in others).

"""Normalize 24 schema-drifted TLC yellow parquets into one consolidated folder."""
from __future__ import annotations
import sys
import time

from awsglue.context import GlueContext
from awsglue.utils import getResolvedOptions
from pyspark.context import SparkContext
from pyspark.sql import functions as F


def main():
    args = getResolvedOptions(sys.argv, ["JOB_NAME", "input_s3_path", "output_s3_path"])

    sc = SparkContext()
    glue = GlueContext(sc)
    spark = glue.spark_session
    spark.conf.set("spark.sql.adaptive.enabled", "true")
    spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")

    input_path = args["input_s3_path"]
    output_path = args["output_s3_path"]

    URI = spark._jvm.java.net.URI
    Path = spark._jvm.org.apache.hadoop.fs.Path
    FileSystem = spark._jvm.org.apache.hadoop.fs.FileSystem
    fs = FileSystem.get(URI(input_path), spark._jsc.hadoopConfiguration())
    statuses = fs.listStatus(Path(input_path))
    files = sorted(
        s.getPath().toString()
        for s in statuses
        if s.getPath().toString().endswith(".parquet")
    )

    normalize = [
        F.col("VendorID").cast("bigint").alias("VendorID"),
        F.col("tpep_pickup_datetime").cast("timestamp").alias("tpep_pickup_datetime"),
        F.col("tpep_dropoff_datetime").cast("timestamp").alias("tpep_dropoff_datetime"),
        F.col("passenger_count").cast("double").alias("passenger_count"),
        F.col("trip_distance").cast("double").alias("trip_distance"),
        F.col("fare_amount").cast("double").alias("fare_amount"),
        F.col("tip_amount").cast("double").alias("tip_amount"),
        F.col("total_amount").cast("double").alias("total_amount"),
        F.col("payment_type").cast("bigint").alias("payment_type"),
        F.col("PULocationID").cast("bigint").alias("PULocationID"),
        F.col("DOLocationID").cast("bigint").alias("DOLocationID"),
    ]

    t0 = time.perf_counter()
    parts = [spark.read.parquet(f).select(*normalize) for f in files]
    df = parts[0]
    for p in parts[1:]:
        df = df.unionByName(p)

    fs_out = FileSystem.get(URI(output_path), spark._jsc.hadoopConfiguration())
    if fs_out.exists(Path(output_path)):
        fs_out.delete(Path(output_path), True)

    df.repartition(64).write.mode("overwrite").parquet(output_path)
    print(f"[consolidate] wrote in {time.perf_counter() - t0:.2f}s to {output_path}")


if __name__ == "__main__":
    main()
4
Step four

The 30 rules

All 30 rules classified SQL_PUSHDOWN. Save as deploy/aws-glue/glue_job/taxi_rules_30.drl.

DATA QUALITY
4 rules
r01, r06, r11, r22
REVIEW FLAGS
5 rules
r02, r04, r05, r10, r23
ANOMALY
5 rules
r03, r08, r09, r12, r25
AIRPORT DETECTION
3 rules
r13 (JFK), r14 (LGA), r15 (EWR)
TIME PATTERNS
4 rules
r16, r17, r18, r19
SEGMENTATION
5 rules
r26, r27, r28, r29, r30
// NYC TLC Yellow Taxi: 30-rule decision pack for the sparkrules cluster benchmark.
// Fact binding: $t : T holds struct fields vendor_id, passenger_count, trip_distance,
// fare_amount, tip_amount, total_amount, payment_type, pu_location, do_location,
// pickup_hour, pickup_dow.
//
// All rules are SQL_PUSHDOWN-classifiable (V2 Strategy A) so the benchmark
// measures pure Catalyst predicate throughput.

rule "r01_fare_zero_or_negative"
  salience 100
  when $t : T( $t.fare_amount <= 0 )
  then result.flag = "DATA_QUALITY"; result.reason = "FARE_NONPOSITIVE"; end

rule "r02_fare_extreme_high"
  salience 90
  when $t : T( $t.fare_amount > 500 )
  then result.flag = "REVIEW"; result.reason = "FARE_EXTREME"; end

rule "r03_distance_zero_nonzero_fare"
  salience 95
  when $t : T( $t.trip_distance == 0 and $t.fare_amount > 10 )
  then result.flag = "ANOMALY"; result.reason = "ZERO_DIST_POSITIVE_FARE"; end

rule "r04_distance_extreme"
  salience 85
  when $t : T( $t.trip_distance > 100 )
  then result.flag = "REVIEW"; result.reason = "DISTANCE_OVER_100MI"; end

rule "r05_tip_exceeds_fare"
  salience 90
  when $t : T( $t.tip_amount > $t.fare_amount and $t.fare_amount > 0 )
  then result.flag = "REVIEW"; result.reason = "TIP_EXCEEDS_FARE"; end

rule "r06_tip_negative"
  salience 100
  when $t : T( $t.tip_amount < 0 )
  then result.flag = "DATA_QUALITY"; result.reason = "TIP_NEGATIVE"; end

rule "r07_high_tip_percentage"
  salience 50
  when $t : T( $t.tip_amount > 20 and $t.fare_amount < 30 )
  then result.flag = "GENEROUS_TIP"; result.reason = "TIP_RATIO_HIGH"; end

rule "r08_cash_tip_unusual"
  salience 80
  when $t : T( $t.payment_type == 2 and $t.tip_amount > 0 )
  then result.flag = "ANOMALY"; result.reason = "CASH_TIP_RECORDED"; end

rule "r09_short_trip_high_fare"
  salience 85
  when $t : T( $t.trip_distance < 0.5 and $t.fare_amount > 40 )
  then result.flag = "ANOMALY"; result.reason = "SHORT_TRIP_HIGH_FARE"; end

rule "r10_long_trip_low_fare"
  salience 80
  when $t : T( $t.trip_distance > 20 and $t.fare_amount < 30 )
  then result.flag = "REVIEW"; result.reason = "LONG_TRIP_LOW_FARE"; end

rule "r11_passenger_zero"
  salience 95
  when $t : T( $t.passenger_count == 0 )
  then result.flag = "DATA_QUALITY"; result.reason = "ZERO_PASSENGERS"; end

rule "r12_passenger_over_six"
  salience 80
  when $t : T( $t.passenger_count > 6 )
  then result.flag = "ANOMALY"; result.reason = "PASSENGER_COUNT_HIGH"; end

rule "r13_airport_jfk_pickup"
  salience 30
  when $t : T( $t.pu_location == 132 )
  then result.flag = "AIRPORT_TRIP"; result.reason = "PICKUP_JFK"; end

rule "r14_airport_lga_pickup"
  salience 30
  when $t : T( $t.pu_location == 138 )
  then result.flag = "AIRPORT_TRIP"; result.reason = "PICKUP_LGA"; end

rule "r15_airport_ewr_pickup"
  salience 30
  when $t : T( $t.pu_location == 1 )
  then result.flag = "AIRPORT_TRIP"; result.reason = "PICKUP_EWR"; end

rule "r16_rush_hour_am"
  salience 20
  when $t : T( $t.pickup_hour >= 7 and $t.pickup_hour <= 9 )
  then result.flag = "RUSH_HOUR"; result.reason = "AM_RUSH"; end

rule "r17_rush_hour_pm"
  salience 20
  when $t : T( $t.pickup_hour >= 17 and $t.pickup_hour <= 19 )
  then result.flag = "RUSH_HOUR"; result.reason = "PM_RUSH"; end

rule "r18_late_night"
  salience 15
  when $t : T( $t.pickup_hour >= 0 and $t.pickup_hour <= 4 )
  then result.flag = "LATE_NIGHT"; result.reason = "LATE_NIGHT_TRIP"; end

rule "r19_weekend_trip"
  salience 10
  when $t : T( $t.pickup_dow == 1 or $t.pickup_dow == 7 )
  then result.flag = "WEEKEND"; result.reason = "WEEKEND_TRIP"; end

rule "r20_vendor_cmt"
  salience 5
  when $t : T( $t.vendor_id == 1 )
  then result.flag = "VENDOR"; result.reason = "CMT"; end

rule "r21_vendor_vts"
  salience 5
  when $t : T( $t.vendor_id == 2 )
  then result.flag = "VENDOR"; result.reason = "VTS"; end

rule "r22_total_under_fare"
  salience 95
  when $t : T( $t.total_amount < $t.fare_amount )
  then result.flag = "DATA_QUALITY"; result.reason = "TOTAL_LT_FARE"; end

rule "r23_total_extreme"
  salience 85
  when $t : T( $t.total_amount > 1000 )
  then result.flag = "REVIEW"; result.reason = "TOTAL_EXTREME"; end

rule "r24_no_tip_credit"
  salience 40
  when $t : T( $t.payment_type == 1 and $t.tip_amount == 0 and $t.fare_amount > 20 )
  then result.flag = "NOTABLE"; result.reason = "CREDIT_NO_TIP"; end

rule "r25_same_pu_do"
  salience 60
  when $t : T( $t.pu_location == $t.do_location and $t.trip_distance < 0.1 )
  then result.flag = "ANOMALY"; result.reason = "SAME_LOCATION_ZERO_DIST"; end

rule "r26_small_fare_trip"
  salience 5
  when $t : T( $t.fare_amount < 5 )
  then result.flag = "SMALL_FARE"; result.reason = "MINIMUM_FARE_TRIP"; end

rule "r27_medium_trip"
  salience 5
  when $t : T( $t.trip_distance >= 2 and $t.trip_distance <= 5 )
  then result.flag = "MEDIUM_TRIP"; result.reason = "MEDIUM_DISTANCE"; end

rule "r28_long_trip"
  salience 5
  when $t : T( $t.trip_distance > 10 and $t.trip_distance <= 30 )
  then result.flag = "LONG_TRIP"; result.reason = "LONG_DISTANCE"; end

rule "r29_solo_rider"
  salience 5
  when $t : T( $t.passenger_count == 1 )
  then result.flag = "SOLO"; result.reason = "SINGLE_RIDER"; end

rule "r30_group_ride"
  salience 5
  when $t : T( $t.passenger_count >= 4 and $t.passenger_count <= 6 )
  then result.flag = "GROUP"; result.reason = "GROUP_RIDE"; end
5
Step five

Full run sequence

# ------ 1. Provision 15 AWS resources ------
cd deploy/aws-glue/terraform/
terraform init
terraform apply -auto-approve

# ------ 2. Capture output names ------
ARTIFACTS_BUCKET=$(terraform output -raw artifacts_bucket)
RESULTS_BUCKET=$(terraform output -raw results_bucket)
BENCH_JOB=$(terraform output -raw glue_job_name)
CONSOLIDATE_JOB=$(terraform output -raw consolidate_job_name)

# ------ 3. Stage 24 months of taxi parquet to S3 (first run only, ~6 min) ------
# Edit stage_taxi_to_s3.py, set BUCKET = $ARTIFACTS_BUCKET, then:
python stage_taxi_to_s3.py

# ------ 4. One-time consolidate (~120s, ~$0.12) ------
aws glue start-job-run --job-name "$CONSOLIDATE_JOB" --region us-east-1

# ------ 5. Run the benchmark (~140s, ~$0.17) ------
aws glue start-job-run --job-name "$BENCH_JOB" --region us-east-1

# Poll for status:
aws glue get-job-run --job-name "$BENCH_JOB" --run-id <id> --region us-east-1 \
  --query "JobRun.{State:JobRunState,ExecTime:ExecutionTime,DPU:DPUSeconds}" \
  --output json

# ------ 6. Pull the summary JSON ------
aws s3 cp "s3://$RESULTS_BUCKET/taxi-bench/benchmark_summary_dir/part-00000" ./bench.json
cat bench.json | jq '.timing, .native_backend'

# ------ 7. Tear down (IMPORTANT - avoid ongoing cost) ------
terraform destroy -auto-approve

# Confirm nothing left:
aws glue list-jobs --region us-east-1 --query "JobNames[?starts_with(@, 'sparkrules')]"
# Expected: []
Always run terraform destroy when done. Glue charges per DPU-second. Leaving resources provisioned won't charge you for idle time, but leftover S3 storage and job configurations still show up in your account. Clean teardown keeps your environment tidy and auditable.
What to expect

Expected output

From a clean run on 10 × G.1X workers. This is the actual bench.json from run jr_428c3052...:

{
  "job_name": "sparkrules-bench-dev-benchmark",
  "timestamp_utc": "2026-05-06T03:22:37.423119+00:00",
  "rows_scored": 79479946,
  "rows_limit_applied": 0,
  "num_partitions": 80,
  "rulepack_summary": {
    "total_rules": 30,
    "sql_pushdown": 30,
    "alpha_shared": 0,
    "python_fallback": 0
  },
  "timing": {
    "read_elapsed_s": 1.648,
    "scoring_elapsed_s": 24.252,
    "write_elapsed_s": 36.906,
    "rows_per_sec": 3277231,
    "api_used": "apply_with_counts"
  },
  "native_backend": {
    "available": true,
    "version": "0.1.0",
    "error": null
  },
  "sparkrules_version": "1.2.0"
}

Top fire counts you should see

RuleExpected fires% of 79.5M
r_r21_vendor_vts59,715,34775.1%
r_r29_solo_rider56,456,16271.0%
r_r19_weekend_trip21,932,36727.6%
r_r27_medium_trip21,842,87627.5%
r_r20_vendor_cmt19,753,63224.9%
r_r17_rush_hour_pm16,070,15320.2%
r_r16_rush_hour_am8,528,63010.7%
What it costs

Cost breakdown

On-demand US East (N. Virginia) pricing at G.1X = $0.44/DPU-hour.

StepDPU-secCost
Staging script (local + S3 PUTs)n/a< $0.001
terraform apply (15 resources)n/a$0
Consolidate job (10 workers × 119s)~1,200~$0.15
Benchmark run (10 workers × 139s)1,394$0.17
S3 storage (2 GB × 1 day)n/a~$0.002
S3 requests (~200 PUTs)n/a~$0.001
Data transfern/a$0 (same region)
terraform destroyn/a$0
Total-~$0.33

Scaling guidance

  • • 25 workers: $0.43 per full benchmark (~3× faster)
  • • 50 workers: $0.86 per full benchmark (~5× faster)
  • • G.2X workers double the cost and typically double the speed
Issues & fixes

Troubleshooting

"LAUNCH ERROR | No script provided"
Empty-string --additional-python-modules breaks Glue's arg parser. Make sure terraform.tfvars uses a non-empty value. If you must suppress python modules, omit the flag entirely rather than setting "".
Adding the experimental sparkrules-native scorer on Glue
sparkrules-native is not on PyPI, so --additional-python-modules cannot install it. Build the Linux wheel locally with maturin build --release, upload it to S3, and reference via:
--extra-py-files s3://.../sparkrules_native-0.1.0-cp311-abi3-manylinux_2_34_x86_64.whl
Tier-1 JSON FFI measures ~1.1-1.3x over the Python local scorer today. Cluster rule evaluation still rides Catalyst - not a Spark executor path.
Schema mismatch between months
Caught by the consolidate job. Symptoms: CANNOT_MERGE_SCHEMAS or column [VendorID], physicalType: INT32, logicalType: bigint. The consolidate job normalizes all 24 months to a single schema. Run it once and point the benchmark at input/yellow_consolidated/.
NYC TLC bucket 403 AccessDenied
The legacy s3://nyc-tlc/ bucket has been locked down. Use the CloudFront URL https://d37ci6vzurychx.cloudfront.net/trip-data/ instead. Our staging script uses this.
Terraform destroy leaves orphaned resources
force_destroy = true in the bucket block handles this. If you hit stragglers:
aws s3 rm s3://sparkrules-bench-dev-artifacts-YOURACCOUNTID --recursive
aws s3 rm s3://sparkrules-bench-dev-results-YOURACCOUNTID --recursive
aws s3 rb s3://sparkrules-bench-dev-artifacts-YOURACCOUNTID
aws s3 rb s3://sparkrules-bench-dev-results-YOURACCOUNTID
Verify

Files checklist

If you're reproducing from scratch, you need these seven files in this structure:

deploy/aws-glue/
├── terraform/
│   ├── main.tf                          # Step 2
│   ├── variables.tf                     # Step 2
│   ├── outputs.tf                       # Step 2
│   └── terraform.tfvars                 # Step 2
└── glue_job/
    ├── benchmark_taxi_rules.py          # Step 3
    ├── consolidate_taxi_input.py        # Step 3
    └── taxi_rules_30.drl                # Step 4
stage_taxi_to_s3.py                      # Step 1
All seven files are copy-pasteable from this guide. No hidden configuration, no private S3 buckets, no internal packages. If you can click the copy buttons, you have everything.
Legal

Licensing

Code in this guide
Apache 2.0 (same as sparkrules)
NYC TLC data
Public domain
sparkrules
Apache 2.0
github.com/vaquarkhan/sparkrules
sparkrules-native experimental
Apache 2.0 · not on PyPI
Build locally · sparkrules_native/

PyPI packages

pip install sparkrules==1.2.0 View on PyPI →
# Not published on PyPI yet - build locally or use CI artifacts
cd sparkrules_native && maturin develop --release

For Glue / Databricks clusters: upload a locally built Linux wheel to S3 and reference it via --extra-py-files. See docs/NATIVE_TIER1.md.

Ready to score your first billion rows?

7 files, 25 minutes, $0.45 of AWS spend. Reproducible on any Spark 3.5+ cluster.

For AI Coding Agents · Cursor · Kiro · Claude Code

Help land the Rust native kernel

Honest state: sparkrules_native is a starter PyO3 crate with a JSON-FFI Tier-1 interpreter. Measured roughly 1.1-1.3x over the Python local scorer today - not the 10-50x story that would justify the marketing weight. The scaffolding exists; a real columnar batch scorer is the open contribution.

Where we are · where to go
NOW
Tier-1 scalar · JSON FFI
~1.1-1.3x measured · not on PyPI
NEXT
Columnar batch scorer
target 5-15M rps · community contribution
GOAL
SIMD + autovec
aspirational · blocked on Tier-2