diff --git a/.github/workflows/action-smoke.yml b/.github/workflows/action-smoke.yml
new file mode 100644
index 0000000..0a44000
--- /dev/null
+++ b/.github/workflows/action-smoke.yml
@@ -0,0 +1,76 @@
+name: Gate Action Smoke Test
+
+on:
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - action.yml
+ - pyproject.toml
+ - src/framevitals/cli.py
+ - src/framevitals/operations.py
+ - .github/workflows/action-smoke.yml
+ pull_request:
+ paths:
+ - action.yml
+ - pyproject.toml
+ - src/framevitals/cli.py
+ - src/framevitals/operations.py
+ - .github/workflows/action-smoke.yml
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Create stable fixture datasets
+ shell: bash
+ run: |
+ cat > reference.csv <<'CSV'
+ age,income,segment
+ 21,30000,basic
+ 34,62000,pro
+ 48,81000,pro
+ 52,90000,enterprise
+ CSV
+ cp reference.csv current.csv
+
+ - name: Run local FrameVitals gate action
+ id: framevitals
+ uses: ./
+ with:
+ current: current.csv
+ reference: reference.csv
+ output: gate-result.json
+
+ - name: Verify action outputs
+ shell: bash
+ env:
+ STATUS: ${{ steps.framevitals.outputs.status }}
+ PASSED: ${{ steps.framevitals.outputs.passed }}
+ RESULT_PATH: ${{ steps.framevitals.outputs.result-path }}
+ run: |
+ test "$STATUS" = "pass"
+ test "$PASSED" = "true"
+ test "$RESULT_PATH" = "gate-result.json"
+ test -s gate-result.json
+ python - <<'PY'
+ import json
+ from pathlib import Path
+
+ payload = json.loads(Path("gate-result.json").read_text(encoding="utf-8"))
+ assert payload["status"] == "pass"
+ assert payload["passed"] is True
+ assert payload["checks_run"] == ["drift"]
+ PY
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
new file mode 100644
index 0000000..481b32c
--- /dev/null
+++ b/.github/workflows/benchmark.yml
@@ -0,0 +1,152 @@
+name: Performance Benchmark
+
+on:
+ push:
+ branches: [main, "develop/august"]
+ paths:
+ - "src/framevitals/**"
+ - "benchmarks/**"
+ - ".github/workflows/benchmark.yml"
+ workflow_dispatch:
+ inputs:
+ rows:
+ description: Rows in the synthetic profile benchmark workload
+ required: false
+ default: "50000"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ profile-scale:
+ name: profile scale benchmark
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install benchmark runtime
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow]"
+
+ - name: Run reproducible profile benchmark
+ env:
+ BENCHMARK_ROWS: ${{ inputs.rows || '50000' }}
+ run: |
+ python benchmarks/benchmark_profile_scale.py \
+ --rows "$BENCHMARK_ROWS" \
+ --numeric-columns 40 \
+ --categorical-columns 5 \
+ --scenarios numpy auto parquet \
+ --output benchmark-results.json
+
+ - name: Publish benchmark summary
+ run: |
+ python - <<'PY'
+ import json
+ import os
+ from pathlib import Path
+
+ payload = json.loads(Path("benchmark-results.json").read_text(encoding="utf-8"))
+ lines = [
+ "## FrameVitals profile benchmark",
+ "",
+ "| Scenario | Seconds | Peak RSS (MB) | Backend |",
+ "| --- | ---: | ---: | --- |",
+ ]
+ for measurement in payload["measurements"]:
+ backend = measurement.get("backend_status", {}).get("selected", "unknown")
+ lines.append(
+ f"| {measurement['scenario']} | {measurement['elapsed_seconds']:.3f} "
+ f"| {measurement['peak_rss_mb']:.1f} | {backend} |"
+ )
+ lines.extend([
+ "",
+ "Results are measurements, not absolute pass/fail thresholds. Compare like-for-like runs.",
+ ])
+ Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(
+ "\n".join(lines) + "\n",
+ encoding="utf-8",
+ )
+ PY
+
+ - name: Upload benchmark result
+ uses: actions/upload-artifact@v5
+ with:
+ name: framevitals-profile-benchmark
+ path: benchmark-results.json
+ retention-days: 30
+
+ deep-8k-180:
+ name: 8k x 180 Deep benchmark
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install benchmark runtime
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e "."
+
+ - name: Run reproducible 8k x 180 Deep benchmark
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: |
+ python benchmarks/benchmark_deep_pipeline.py \
+ --rows 8000 \
+ --columns 180 \
+ --output deep-benchmark-results.json
+
+ - name: Publish Deep benchmark summary
+ run: |
+ python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import json
+ from pathlib import Path
+
+ payload = json.loads(Path("deep-benchmark-results.json").read_text())
+ print("## FrameVitals 8k x 180 Deep benchmark")
+ print()
+ print(f"- Wall time: **{payload['elapsed_seconds']:.3f}s**")
+ print(f"- Historical original: {payload['historical_original_seconds']:.3f}s")
+ print(f"- Speedup vs historical original: **{payload['speedup_vs_historical_original']:.2f}x**")
+ print(f"- 10x target: {payload['ten_x_target_seconds']:.3f}s")
+ print(f"- Target met: **{payload['ten_x_target_met']}**")
+ print()
+ print("### Pipeline stages")
+ for name, milliseconds in sorted(payload["pipeline_timings_ms"].items()):
+ if isinstance(milliseconds, (int, float)):
+ print(f"- {name}: {milliseconds / 1000:.3f}s")
+ PY
+
+ - name: Upload Deep benchmark result
+ uses: actions/upload-artifact@v6
+ with:
+ name: framevitals-deep-8k-180-benchmark
+ path: deep-benchmark-results.json
+ retention-days: 30
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..26001ff
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,45 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+ branches: [main, dev]
+ schedule:
+ - cron: "17 4 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ analyze:
+ name: ${{ matrix.language }} security analysis
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ packages: read
+ security-events: write
+ strategy:
+ fail-fast: false
+ matrix:
+ language:
+ - python
+ - javascript-typescript
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+
+ - name: Analyze
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/developer-guardrails.yml b/.github/workflows/developer-guardrails.yml
new file mode 100644
index 0000000..7b4eecd
--- /dev/null
+++ b/.github/workflows/developer-guardrails.yml
@@ -0,0 +1,57 @@
+name: Developer Guardrails
+
+on:
+ pull_request:
+ paths:
+ - ".pre-commit-config.yaml"
+ - "pyproject.toml"
+ - "src/framevitals/**"
+ - "tests/**"
+ - ".github/workflows/developer-guardrails.yml"
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - ".pre-commit-config.yaml"
+ - "pyproject.toml"
+ - "src/framevitals/**"
+ - "tests/**"
+ - ".github/workflows/developer-guardrails.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ hooks:
+ name: pre-commit + pre-push contracts
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install development environment
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev]"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Run commit-stage hooks
+ run: pre-commit run --all-files --hook-stage pre-commit --show-diff-on-failure
+
+ - name: Run pre-push public contract hooks
+ run: pre-commit run --all-files --hook-stage pre-push --show-diff-on-failure
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..46bf9ae
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,52 @@
+name: Docs
+
+on:
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "docs/**"
+ - "mkdocs.yml"
+ - "pyproject.toml"
+ - ".github/workflows/docs.yml"
+ pull_request:
+ paths:
+ - "docs/**"
+ - "mkdocs.yml"
+ - "pyproject.toml"
+ - ".github/workflows/docs.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: strict MkDocs build
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install documentation dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[docs]"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Build documentation strictly
+ run: mkdocs build --strict
diff --git a/.github/workflows/extensive-scale-benchmark.yml b/.github/workflows/extensive-scale-benchmark.yml
new file mode 100644
index 0000000..7a31e39
--- /dev/null
+++ b/.github/workflows/extensive-scale-benchmark.yml
@@ -0,0 +1,273 @@
+name: Extensive Physical Scale Benchmark
+
+on:
+ push:
+ branches: ["develop/august"]
+ paths:
+ - ".github/workflows/extensive-scale-benchmark.yml"
+ - "benchmarks/benchmark_real_wide_parquet.py"
+ - "src/framevitals/**"
+ - "rust/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ stress:
+ name: ${{ matrix.label }} — native vs fallback full pipeline
+ runs-on: ubuntu-latest
+ timeout-minutes: 90
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - label: tall-250k-x-10k
+ rows: 250000
+ columns: 10000
+ row_group_rows: 10000
+ - label: wide-100k-x-25k
+ rows: 100000
+ columns: 25000
+ row_group_rows: 10000
+ - label: tall-500k-x-10k
+ rows: 500000
+ columns: 10000
+ row_group_rows: 10000
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Configure Rust
+ run: |
+ rustup default stable
+ rustc --version
+ cargo --version
+
+ - name: Show runner capacity
+ run: |
+ nproc
+ free -h
+ df -h .
+
+ - name: Install benchmark and native build runtime
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow]"
+ pip install "maturin>=1.14,<2"
+
+ - name: Build and install FrameVitals native engine
+ run: |
+ maturin build \
+ --release \
+ --manifest-path rust/framevitals-py/Cargo.toml \
+ --interpreter python \
+ --out dist-native
+ pip install --force-reinstall --no-deps dist-native/*.whl
+ FRAMEVITALS_BACKEND=rust python - <<'PY'
+ from framevitals.backends import backend_status
+
+ status = backend_status()
+ assert status["native_available"] is True, status
+ assert status["selected"] == "rust", status
+ print(status)
+ PY
+
+ - name: Generate physical Parquet workload
+ run: |
+ python benchmarks/benchmark_real_wide_parquet.py \
+ --generate-only \
+ --dataset "${{ matrix.label }}.parquet" \
+ --rows ${{ matrix.rows }} \
+ --columns ${{ matrix.columns }} \
+ --row-group-rows ${{ matrix.row_group_rows }} \
+ --output "generation-${{ matrix.label }}.json"
+ ls -lh "${{ matrix.label }}.parquet"
+ df -h .
+
+ - name: Run native Rust full analysis in all modes
+ env:
+ FRAMEVITALS_BACKEND: rust
+ run: |
+ for mode in quick standard deep research; do
+ echo "=== rust / ${{ matrix.label }} / ${mode} ==="
+ python benchmarks/benchmark_real_wide_parquet.py \
+ --dataset "${{ matrix.label }}.parquet" \
+ --mode "${mode}" \
+ --output "${{ matrix.label }}-rust-${mode}.json"
+ done
+
+ - name: Run NumPy fallback full analysis in all modes
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: |
+ for mode in quick standard deep research; do
+ echo "=== numpy / ${{ matrix.label }} / ${mode} ==="
+ python benchmarks/benchmark_real_wide_parquet.py \
+ --dataset "${{ matrix.label }}.parquet" \
+ --mode "${mode}" \
+ --output "${{ matrix.label }}-numpy-${mode}.json"
+ done
+
+ - name: Validate routing, safety, accuracy, and backend parity
+ run: |
+ python - <<'PY'
+ import json
+ from pathlib import Path
+
+ label = "${{ matrix.label }}"
+ failures = []
+ payloads = {}
+
+ for backend in ("rust", "numpy"):
+ for mode in ("quick", "standard", "deep", "research"):
+ path = Path(f"{label}-{backend}-{mode}.json")
+ payload = json.loads(path.read_text())
+ payloads[(backend, mode)] = payload
+
+ observed = payload["backend"]["numeric_backend"]
+ if observed != backend:
+ failures.append(f"{backend}/{mode}: routed to {observed!r}")
+
+ safety = payload["safety"]
+ if safety["full_materialization"] is not False:
+ failures.append(f"{backend}/{mode}: full materialization occurred")
+ if safety["source_rows"] != ${{ matrix.rows }}:
+ failures.append(f"{backend}/{mode}: wrong source row count")
+ if safety["source_columns"] != ${{ matrix.columns }}:
+ failures.append(f"{backend}/{mode}: wrong source column count")
+
+ accuracy = payload["accuracy"]
+ checked = accuracy["profiled_columns_checked"]
+ if accuracy["missing_count_exact_matches"] != checked:
+ failures.append(f"{backend}/{mode}: missing-count parity failed")
+ if accuracy["numeric_count_exact_matches"] != checked:
+ failures.append(f"{backend}/{mode}: numeric-count parity failed")
+ if accuracy["numeric_minmax_exact_matches"] != checked:
+ failures.append(f"{backend}/{mode}: min/max parity failed")
+ if (accuracy["profile_mean_max_abs_error"] or 0.0) > 0.01:
+ failures.append(
+ f"{backend}/{mode}: profile mean max error "
+ f"{accuracy['profile_mean_max_abs_error']}"
+ )
+ if accuracy["health_missing_abs_error"] > 0.02:
+ failures.append(
+ f"{backend}/{mode}: health missingness error "
+ f"{accuracy['health_missing_abs_error']}"
+ )
+
+ deep_checked = accuracy["deep_numeric_columns_checked"]
+ if deep_checked:
+ if accuracy["deep_exact_once_columns"] != deep_checked:
+ failures.append(f"{backend}/{mode}: exact-once reuse failed")
+ if (accuracy["deep_mean_max_abs_error"] or 0.0) > 0.01:
+ failures.append(
+ f"{backend}/{mode}: deep exact mean error "
+ f"{accuracy['deep_mean_max_abs_error']}"
+ )
+
+ # The two backends should agree on the deterministic full-stream facts.
+ for mode in ("quick", "standard", "deep", "research"):
+ r = payloads[("rust", mode)]["accuracy"]
+ n = payloads[("numpy", mode)]["accuracy"]
+ for key in (
+ "profiled_columns_checked",
+ "missing_count_exact_matches",
+ "numeric_count_exact_matches",
+ "numeric_minmax_exact_matches",
+ "deep_exact_once_columns",
+ ):
+ if r[key] != n[key]:
+ failures.append(
+ f"{mode}: backend parity mismatch for {key}: {r[key]} != {n[key]}"
+ )
+ if abs((r["profile_mean_max_abs_error"] or 0.0) - (n["profile_mean_max_abs_error"] or 0.0)) > 0.01:
+ failures.append(f"{mode}: backend profile-mean accuracy diverged")
+
+ if failures:
+ raise SystemExit("\n".join(failures))
+ print("Extensive native/fallback full-pipeline checks passed.")
+ PY
+
+ - name: Publish stress summary
+ if: always()
+ run: |
+ python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import json
+ from pathlib import Path
+
+ label = "${{ matrix.label }}"
+ gen_path = Path(f"generation-{label}.json")
+ if gen_path.exists():
+ g = json.loads(gen_path.read_text())
+ print(f"## {label}")
+ print()
+ print(f"- Physical shape: **{g['rows']:,} × {g['columns']:,}**")
+ print(f"- Logical cells: **{g['cells']:,}**")
+ print(f"- Dense int16 equivalent: **{g['dense_int16_raw_gb']:.2f} GB**")
+ print(f"- Parquet file size: **{g['file_size_mb']:.2f} MB**")
+ print(f"- Generation time: **{g['generation_seconds']:.3f}s**")
+ print()
+
+ print("| Backend | Mode | Wall time | Profiled cols | Sample rows | Profile max mean err | Deep max mean err |")
+ print("|---|---|---:|---:|---:|---:|---:|")
+ for backend in ("rust", "numpy"):
+ for mode in ("quick", "standard", "deep", "research"):
+ path = Path(f"{label}-{backend}-{mode}.json")
+ if not path.exists():
+ print(f"| {backend} | {mode} | failed | - | - | - | - |")
+ continue
+ p = json.loads(path.read_text())
+ s = p["safety"]
+ a = p["accuracy"]
+ print(
+ f"| {backend} | {mode} | {p['analysis_seconds']:.3f}s | "
+ f"{s['profiled_columns']} | {s['working_sample_rows']} | "
+ f"{a['profile_mean_max_abs_error']} | {a['deep_mean_max_abs_error']} |"
+ )
+ print()
+
+ for backend in ("rust", "numpy"):
+ for mode in ("quick", "standard", "deep", "research"):
+ path = Path(f"{label}-{backend}-{mode}.json")
+ if not path.exists():
+ continue
+ p = json.loads(path.read_text())
+ print(f"### {backend} / {mode} stage timings")
+ for name, ms in sorted(p.get("pipeline_timings_ms", {}).items()):
+ if isinstance(ms, (int, float)):
+ print(f"- {name}: {ms / 1000:.3f}s")
+ print()
+ PY
+
+ - name: Upload benchmark results
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: framevitals-extensive-${{ matrix.label }}
+ path: |
+ generation-${{ matrix.label }}.json
+ ${{ matrix.label }}-rust-quick.json
+ ${{ matrix.label }}-rust-standard.json
+ ${{ matrix.label }}-rust-deep.json
+ ${{ matrix.label }}-rust-research.json
+ ${{ matrix.label }}-numpy-quick.json
+ ${{ matrix.label }}-numpy-standard.json
+ ${{ matrix.label }}-numpy-deep.json
+ ${{ matrix.label }}-numpy-research.json
+ if-no-files-found: ignore
+ retention-days: 30
diff --git a/.github/workflows/extreme-streaming-benchmark.yml b/.github/workflows/extreme-streaming-benchmark.yml
new file mode 100644
index 0000000..de67d78
--- /dev/null
+++ b/.github/workflows/extreme-streaming-benchmark.yml
@@ -0,0 +1,108 @@
+name: Extreme Streaming Benchmark
+
+on:
+ push:
+ branches: ["develop/august"]
+ paths:
+ - "src/framevitals/execution.py"
+ - "src/framevitals/planning_api.py"
+ - "src/framevitals/streaming_pipeline.py"
+ - "src/framevitals/health_score.py"
+ - "benchmarks/benchmark_extreme_streaming.py"
+ - ".github/workflows/extreme-streaming-benchmark.yml"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ mode-comparison-100k-x-100k:
+ name: ${{ matrix.mode }} 100k x 100k virtual stream
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ max-parallel: 4
+ matrix:
+ mode: [quick, standard, deep, research]
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install streaming benchmark runtime
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow]"
+
+ - name: Run 100k x 100k benchmark
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: |
+ python benchmarks/benchmark_extreme_streaming.py \
+ --rows 100000 \
+ --columns 100000 \
+ --mode "${{ matrix.mode }}" \
+ --output "extreme-${{ matrix.mode }}-results.json"
+
+ - name: Publish benchmark summary
+ if: always()
+ env:
+ MODE: ${{ matrix.mode }}
+ run: |
+ FILE="extreme-${MODE}-results.json"
+ if [[ ! -f "$FILE" ]]; then
+ echo "## ${MODE} 100k x 100k benchmark" >> "$GITHUB_STEP_SUMMARY"
+ echo >> "$GITHUB_STEP_SUMMARY"
+ echo "Benchmark did not produce a result file." >> "$GITHUB_STEP_SUMMARY"
+ exit 0
+ fi
+ python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import json
+ import os
+ from pathlib import Path
+
+ mode = os.environ["MODE"]
+ payload = json.loads(Path(f"extreme-{mode}-results.json").read_text())
+ workload = payload["workload"]
+ safety = payload["safety"]
+ print(f"## FrameVitals {mode.title()} 100k x 100k benchmark")
+ print()
+ print(f"- Logical cells: **{workload['cells']:,}**")
+ print(f"- Dense float64 equivalent: **{workload['dense_float64_raw_gb']:.1f} GB raw**")
+ print(f"- Plan: **{payload['plan_seconds']:.3f}s**")
+ print(f"- Analysis: **{payload['analysis_seconds']:.3f}s**")
+ print(f"- Scale class: **{payload['scale_class']}**")
+ print(f"- Profiled columns: **{safety['profiled_columns']} / {safety['source_columns']}**")
+ print(f"- Working sample rows: **{safety['working_sample_rows']:,}**")
+ print(f"- All 100,000 source rows scanned: **{safety['all_source_rows_scanned']}**")
+ print(f"- Full materialization: **{safety['full_materialization']}**")
+ print(f"- Unbounded 100k-column request: **{safety['unbounded_width_requested']}**")
+ print()
+ print("### Pipeline stages")
+ for name, milliseconds in sorted(payload.get("pipeline_timings_ms", {}).items()):
+ if isinstance(milliseconds, (int, float)):
+ print(f"- {name}: {milliseconds / 1000:.3f}s")
+ PY
+
+ - name: Upload benchmark result
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: framevitals-100k-x-100k-${{ matrix.mode }}
+ path: extreme-${{ matrix.mode }}-results.json
+ if-no-files-found: ignore
+ retention-days: 30
diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml
new file mode 100644
index 0000000..d3a2462
--- /dev/null
+++ b/.github/workflows/interop.yml
@@ -0,0 +1,62 @@
+name: Interoperability
+
+on:
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "src/framevitals/sources.py"
+ - "src/framevitals/duckdb_source.py"
+ - "src/framevitals/analysis_api.py"
+ - "src/framevitals/streaming_*.py"
+ - "tests/test_arrow_memory.py"
+ - "tests/test_duckdb_source.py"
+ - "pyproject.toml"
+ - ".github/workflows/interop.yml"
+ pull_request:
+ paths:
+ - "src/framevitals/sources.py"
+ - "src/framevitals/duckdb_source.py"
+ - "src/framevitals/analysis_api.py"
+ - "src/framevitals/streaming_*.py"
+ - "tests/test_arrow_memory.py"
+ - "tests/test_duckdb_source.py"
+ - "pyproject.toml"
+ - ".github/workflows/interop.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ arrow-duckdb:
+ name: Arrow + DuckDB (Python 3.12)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install interoperability capabilities
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[duckdb,dev]"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Run interoperability tests
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: pytest -v tests/test_arrow_memory.py tests/test_duckdb_source.py
diff --git a/.github/workflows/minimum-dependencies.yml b/.github/workflows/minimum-dependencies.yml
new file mode 100644
index 0000000..723a871
--- /dev/null
+++ b/.github/workflows/minimum-dependencies.yml
@@ -0,0 +1,86 @@
+name: Minimum Dependencies
+
+on:
+ pull_request:
+ paths:
+ - "pyproject.toml"
+ - "src/framevitals/**"
+ - "tests/**"
+ - ".github/workflows/minimum-dependencies.yml"
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "pyproject.toml"
+ - "src/framevitals/**"
+ - "tests/**"
+ - ".github/workflows/minimum-dependencies.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lower-bounds:
+ name: Python 3.11 lower bounds
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install declared minimum core dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install \
+ "numpy==1.26.0" \
+ "pandas==2.2.0" \
+ "scipy==1.13.0" \
+ "statsmodels==0.14.0" \
+ "scikit-learn==1.5.0" \
+ "pytest>=8.2" \
+ "hypothesis>=6.100"
+ pip install --no-deps -e .
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Verify installed lower bounds
+ run: |
+ python - <<'PY'
+ import numpy
+ import pandas
+ import scipy
+ import sklearn
+ import statsmodels
+
+ expected = {
+ "numpy": "1.26.0",
+ "pandas": "2.2.0",
+ "scipy": "1.13.0",
+ "sklearn": "1.5.0",
+ "statsmodels": "0.14.0",
+ }
+ observed = {
+ "numpy": numpy.__version__,
+ "pandas": pandas.__version__,
+ "scipy": scipy.__version__,
+ "sklearn": sklearn.__version__,
+ "statsmodels": statsmodels.__version__,
+ }
+ assert observed == expected, (observed, expected)
+ PY
+
+ - name: Run core test suite at lower bounds
+ run: pytest -q
diff --git a/.github/workflows/mixed-ground-truth.yml b/.github/workflows/mixed-ground-truth.yml
new file mode 100644
index 0000000..6c9f5b9
--- /dev/null
+++ b/.github/workflows/mixed-ground-truth.yml
@@ -0,0 +1,143 @@
+name: Mixed Ground Truth Validation
+
+on:
+ push:
+ branches: ["develop/august"]
+ paths:
+ - ".github/workflows/mixed-ground-truth.yml"
+ - "tests/test_mixed_ground_truth_pipeline.py"
+ - "tests/test_release_hardening.py"
+ - "tests/test_parquet_streaming.py"
+ - "tests/test_target_intelligence.py"
+ - "tests/test_target_intelligence_v2.py"
+ - "tests/test_semantic_types.py"
+ - "tests/test_relationship_graph.py"
+ - "tests/test_fast_anomaly.py"
+ - "tests/test_deep_statistics.py"
+ - "src/framevitals/**"
+ - "rust/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ validate:
+ name: native/fallback mixed statistical ground truth
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Configure Rust
+ run: |
+ rustup default stable
+ rustc --version
+ cargo --version
+
+ - name: Install test and Arrow dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[dev,arrow]"
+ pip install "maturin>=1.14,<2"
+
+ - name: Build and install FrameVitals native engine
+ run: |
+ maturin build \
+ --release \
+ --manifest-path rust/framevitals-py/Cargo.toml \
+ --interpreter python \
+ --out dist-native
+ pip install --force-reinstall --no-deps dist-native/*.whl
+ FRAMEVITALS_BACKEND=rust python - <<'PY'
+ from framevitals.backends import backend_status
+
+ status = backend_status()
+ assert status["native_available"] is True, status
+ assert status["selected"] == "rust", status
+ print(status)
+ PY
+
+ - name: NumPy fallback mixed ground-truth suite
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: |
+ pytest -q \
+ tests/test_mixed_ground_truth_pipeline.py \
+ tests/test_release_hardening.py \
+ tests/test_parquet_streaming.py \
+ tests/test_target_intelligence.py \
+ tests/test_target_intelligence_v2.py \
+ tests/test_semantic_types.py \
+ tests/test_relationship_graph.py \
+ tests/test_fast_anomaly.py \
+ tests/test_deep_statistics.py \
+ --junitxml=mixed-ground-truth-numpy.xml
+
+ - name: Rust native mixed ground-truth suite
+ env:
+ FRAMEVITALS_BACKEND: rust
+ run: |
+ pytest -q \
+ tests/test_mixed_ground_truth_pipeline.py \
+ tests/test_release_hardening.py \
+ tests/test_parquet_streaming.py \
+ tests/test_target_intelligence.py \
+ tests/test_target_intelligence_v2.py \
+ tests/test_semantic_types.py \
+ tests/test_relationship_graph.py \
+ tests/test_fast_anomaly.py \
+ tests/test_deep_statistics.py \
+ --junitxml=mixed-ground-truth-rust.xml
+
+ - name: Publish validation summary
+ if: always()
+ run: |
+ python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ from pathlib import Path
+ import xml.etree.ElementTree as ET
+
+ print("## Mixed ground-truth validation")
+ print()
+ print("Coverage: mixed Parquet streaming, exact missingness/moments, finite-value fallback semantics, sampling provenance, quantile tolerance, categorical/date detection, target intelligence, semantic types, relationship discovery, anomaly recall, and backend parity through the same test contracts.")
+ print()
+ print("| Backend | Tests | Failures | Errors | Skipped |")
+ print("|---|---:|---:|---:|---:|")
+ for backend in ("numpy", "rust"):
+ path = Path(f"mixed-ground-truth-{backend}.xml")
+ if not path.exists():
+ print(f"| {backend} | unavailable | - | - | - |")
+ continue
+ root = ET.parse(path).getroot()
+ attrs = root.attrib
+ print(
+ f"| {backend} | {attrs.get('tests', '?')} | {attrs.get('failures', '?')} | "
+ f"{attrs.get('errors', '?')} | {attrs.get('skipped', '?')} |"
+ )
+ PY
+
+ - name: Upload validation reports
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: framevitals-mixed-ground-truth
+ path: |
+ mixed-ground-truth-numpy.xml
+ mixed-ground-truth-rust.xml
+ if-no-files-found: ignore
+ retention-days: 30
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index b45eae6..140857d 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -2,9 +2,16 @@ name: Package
on:
push:
- branches: [main, dev]
+ branches: [main, dev, "develop/august"]
pull_request:
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
build:
runs-on: ubuntu-latest
@@ -20,52 +27,108 @@ jobs:
with:
python-version: "3.12"
+ - name: Configure Rust
+ run: |
+ rustup default stable
+ rustc --version
+ cargo --version
+
- name: Install build tools
run: |
python -m pip install --upgrade pip
- pip install build twine
+ pip install build twine "maturin==1.14.1"
- - name: Build package
- run: python -m build
+ - name: Build portable fallback distribution
+ run: python -m build --outdir dist-fallback
- - name: Verify wheel contents
+ - name: Build native wheel
+ run: |
+ maturin build \
+ --locked \
+ --release \
+ --manifest-path rust/framevitals-py/Cargo.toml \
+ --interpreter python \
+ --out dist-native \
+ --compatibility pypi
+
+ - name: Verify fallback and native wheel contents
run: |
python - <<'PY'
from pathlib import Path
import zipfile
- wheels = list(Path("dist").glob("*.whl"))
- assert len(wheels) == 1, f"Expected exactly one wheel, found {len(wheels)}"
+ fallback = list(Path("dist-fallback").glob("*.whl"))
+ native = list(Path("dist-native").glob("*.whl"))
+ assert len(fallback) == 1, f"Expected one fallback wheel, found {len(fallback)}"
+ assert len(native) == 1, f"Expected one native wheel, found {len(native)}"
- wheel = wheels[0]
- with zipfile.ZipFile(wheel) as archive:
- names = archive.namelist()
+ def names(path: Path) -> list[str]:
+ with zipfile.ZipFile(path) as archive:
+ return archive.namelist()
- assert any(name.startswith("framevitals/") for name in names), (
- "framevitals package missing from wheel"
- )
- assert not any(name.startswith("modules/") for name in names), (
- "legacy modules package leaked into wheel"
- )
- print(f"Verified distribution boundary: {wheel.name}")
+ fallback_names = names(fallback[0])
+ native_names = names(native[0])
+
+ for wheel, wheel_names in ((fallback[0], fallback_names), (native[0], native_names)):
+ assert any(name.startswith("framevitals/") for name in wheel_names), (
+ f"framevitals package missing from {wheel.name}"
+ )
+ assert "framevitals/py.typed" in wheel_names, (
+ f"PEP 561 marker missing from {wheel.name}"
+ )
+ assert not any(name.startswith("modules/") for name in wheel_names), (
+ f"legacy modules package leaked into {wheel.name}"
+ )
+
+ native_suffixes = (".so", ".pyd", ".dylib")
+ assert not any(
+ name.startswith("framevitals/_native") and name.endswith(native_suffixes)
+ for name in fallback_names
+ ), "Portable fallback unexpectedly contains a native extension"
+ assert any(
+ name.startswith("framevitals/_native") and name.endswith(native_suffixes)
+ for name in native_names
+ ), "Native wheel does not contain framevitals._native"
+
+ print(f"Portable fallback: {fallback[0].name}")
+ print(f"Native wheel: {native[0].name}")
PY
- name: Validate distributions
- run: python -m twine check dist/*
+ run: twine check dist-fallback/* dist-native/*
- - name: Smoke-test built wheel
+ - name: Verify pip prefers the native wheel when compatible
run: |
+ mkdir -p dist-install
+ cp dist-fallback/*.whl dist-install/
+ cp dist-native/*.whl dist-install/
python -m venv /tmp/framevitals-wheel-smoke
- /tmp/framevitals-wheel-smoke/bin/python -m pip install --no-deps dist/*.whl
+ /tmp/framevitals-wheel-smoke/bin/python -m pip install --upgrade pip
+ /tmp/framevitals-wheel-smoke/bin/python -m pip install numpy pandas
+ VERSION=$(python - <<'PY'
+ import tomllib
+ with open("pyproject.toml", "rb") as handle:
+ print(tomllib.load(handle)["project"]["version"])
+ PY
+ )
cd /tmp
+ /tmp/framevitals-wheel-smoke/bin/python -m pip install \
+ --no-index \
+ --find-links "$GITHUB_WORKSPACE/dist-install" \
+ --no-deps \
+ "framevitals==$VERSION"
/tmp/framevitals-wheel-smoke/bin/python - <<'PY'
from importlib.metadata import version
import framevitals
+ from framevitals.backends import backend_status
installed = version("framevitals")
assert installed == framevitals.__version__, (
f"metadata version {installed} != package version {framevitals.__version__}"
)
- print(f"Imported FrameVitals {installed} from built wheel")
+ status = backend_status()
+ assert status["native_available"] is True, status
+ assert status["selected"] == "rust", status
+ print(f"Imported FrameVitals {installed} with native backend: {status}")
PY
/tmp/framevitals-wheel-smoke/bin/framevitals --version
diff --git a/.github/workflows/performance-guardrail.yml b/.github/workflows/performance-guardrail.yml
new file mode 100644
index 0000000..c06f4be
--- /dev/null
+++ b/.github/workflows/performance-guardrail.yml
@@ -0,0 +1,103 @@
+name: Performance Guardrail
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - "src/framevitals/profiler.py"
+ - "src/framevitals/streaming_profile.py"
+ - "src/framevitals/sources.py"
+ - "src/framevitals/focused.py"
+ - "src/framevitals/native_core.py"
+ - "tests/test_release_hardening.py"
+ - "rust/**"
+ - "benchmarks/performance_guardrail.py"
+ - "benchmarks/performance_budgets.json"
+ - ".github/workflows/performance-guardrail.yml"
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "src/framevitals/profiler.py"
+ - "src/framevitals/streaming_profile.py"
+ - "src/framevitals/sources.py"
+ - "src/framevitals/focused.py"
+ - "src/framevitals/native_core.py"
+ - "tests/test_release_hardening.py"
+ - "rust/**"
+ - "benchmarks/performance_guardrail.py"
+ - "benchmarks/performance_budgets.json"
+ - ".github/workflows/performance-guardrail.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ profile-guardrails:
+ name: profiling time + RSS ceilings
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install profiling capabilities
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow]"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Run performance guardrails
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: |
+ python benchmarks/performance_guardrail.py \
+ --output performance-guardrail.json
+
+ - name: Publish job summary
+ if: always()
+ run: |
+ if [[ ! -f performance-guardrail.json ]]; then
+ exit 0
+ fi
+ python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import json
+ from pathlib import Path
+
+ payload = json.loads(Path("performance-guardrail.json").read_text())
+ print("## FrameVitals performance guardrail")
+ print()
+ print("| Scenario | Time (s) | Time budget | Peak RSS (MB) | RSS budget | Result |")
+ print("| --- | ---: | ---: | ---: | ---: | --- |")
+ for item in payload["measurements"]:
+ budget = item["budget"]
+ result = "PASS" if item["passed"] else "FAIL"
+ print(
+ f"| {item['name']} | {item['elapsed_seconds']:.3f} | "
+ f"{budget['max_seconds']:.1f} | {item['peak_rss_mb']:.1f} | "
+ f"{budget['max_peak_rss_mb']:.1f} | {result} |"
+ )
+ PY
+
+ - name: Upload guardrail measurements
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: performance-guardrail
+ path: performance-guardrail.json
+ if-no-files-found: ignore
+ retention-days: 30
diff --git a/.github/workflows/platform-smoke.yml b/.github/workflows/platform-smoke.yml
new file mode 100644
index 0000000..b750b24
--- /dev/null
+++ b/.github/workflows/platform-smoke.yml
@@ -0,0 +1,84 @@
+name: Platform Smoke
+
+on:
+ pull_request:
+ paths:
+ - "pyproject.toml"
+ - "src/framevitals/**"
+ - "tests/test_public_api.py"
+ - "tests/test_public_surface_contract.py"
+ - "tests/test_source_inspection.py"
+ - "tests/test_diagnostic_results.py"
+ - "tests/test_snapshots.py"
+ - "tests/test_cli.py"
+ - "tests/test_cli_monitoring.py"
+ - "tests/test_cli_system_info.py"
+ - ".github/workflows/platform-smoke.yml"
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "pyproject.toml"
+ - "src/framevitals/**"
+ - "tests/test_public_api.py"
+ - "tests/test_public_surface_contract.py"
+ - "tests/test_source_inspection.py"
+ - "tests/test_diagnostic_results.py"
+ - "tests/test_snapshots.py"
+ - "tests/test_cli.py"
+ - "tests/test_cli_monitoring.py"
+ - "tests/test_cli_system_info.py"
+ - ".github/workflows/platform-smoke.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ smoke:
+ name: ${{ matrix.os }} / Python 3.12
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install core package and test tools
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[dev]"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Verify CLI install
+ run: |
+ framevitals --version
+ framevitals --help
+
+ - name: Run cross-platform public smoke suite
+ run: >-
+ python -m pytest -q
+ tests/test_public_api.py
+ tests/test_public_surface_contract.py
+ tests/test_source_inspection.py
+ tests/test_diagnostic_results.py
+ tests/test_snapshots.py
+ tests/test_cli.py
+ tests/test_cli_monitoring.py
+ tests/test_cli_system_info.py
diff --git a/.github/workflows/plugin-example.yml b/.github/workflows/plugin-example.yml
new file mode 100644
index 0000000..ba735bd
--- /dev/null
+++ b/.github/workflows/plugin-example.yml
@@ -0,0 +1,87 @@
+name: Check Plugin Example
+
+on:
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "src/framevitals/checks.py"
+ - "src/framevitals/plugins.py"
+ - "src/framevitals/operations.py"
+ - "examples/check_plugin/**"
+ - ".github/workflows/plugin-example.yml"
+ pull_request:
+ paths:
+ - "src/framevitals/checks.py"
+ - "src/framevitals/plugins.py"
+ - "src/framevitals/operations.py"
+ - "examples/check_plugin/**"
+ - ".github/workflows/plugin-example.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ smoke:
+ name: install and discover external checks
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: |
+ pyproject.toml
+ examples/check_plugin/pyproject.toml
+
+ - name: Install FrameVitals and example provider
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e .
+ pip install --no-deps -e examples/check_plugin
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Discover and run provider checks
+ run: |
+ python - <<'PY'
+ import pandas as pd
+ import framevitals as fv
+
+ checks = fv.discover_checks()
+ assert [check.name for check in checks] == [
+ "positive revenue",
+ "preferred plan domain",
+ ]
+
+ passing = pd.DataFrame({
+ "revenue": [10.0, 20.0, 30.0],
+ "plan": ["basic", "pro", "enterprise"],
+ })
+ passing_result = fv.gate(passing, custom_checks=checks)
+ assert passing_result.status == "pass"
+
+ failing = pd.DataFrame({
+ "revenue": [10.0, -5.0, 30.0],
+ "plan": ["basic", "legacy", "enterprise"],
+ })
+ failing_result = fv.gate(failing, custom_checks=checks)
+ assert failing_result.status == "fail"
+ assert failing_result["checks"]["custom"]["summary"] == {
+ "checks": 2,
+ "passed": 0,
+ "warnings": 1,
+ "errors": 1,
+ }
+ PY
diff --git a/.github/workflows/polars-interop.yml b/.github/workflows/polars-interop.yml
new file mode 100644
index 0000000..6e8aefa
--- /dev/null
+++ b/.github/workflows/polars-interop.yml
@@ -0,0 +1,61 @@
+name: Polars Interoperability
+
+on:
+ push:
+ branches: [main, dev, "develop/august"]
+ paths:
+ - "src/framevitals/sources.py"
+ - "src/framevitals/analysis_api.py"
+ - "src/framevitals/focused.py"
+ - "src/framevitals/result.py"
+ - "tests/test_arrow_memory.py"
+ - "tests/test_polars_interop.py"
+ - ".github/workflows/polars-interop.yml"
+ pull_request:
+ paths:
+ - "src/framevitals/sources.py"
+ - "src/framevitals/analysis_api.py"
+ - "src/framevitals/focused.py"
+ - "src/framevitals/result.py"
+ - "tests/test_arrow_memory.py"
+ - "tests/test_polars_interop.py"
+ - ".github/workflows/polars-interop.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ polars-arrow:
+ name: Polars via Arrow C Stream (Python 3.12)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install FrameVitals Arrow capability and Polars
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow,dev]"
+ pip install "polars>=1,<2"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Run Polars interoperability tests
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: pytest -v tests/test_arrow_memory.py tests/test_polars_interop.py
diff --git a/.github/workflows/real-wide-parquet-benchmark.yml b/.github/workflows/real-wide-parquet-benchmark.yml
new file mode 100644
index 0000000..8e1da24
--- /dev/null
+++ b/.github/workflows/real-wide-parquet-benchmark.yml
@@ -0,0 +1,229 @@
+name: Real Wide Parquet Benchmark
+
+on:
+ push:
+ branches: ["develop/august"]
+ paths:
+ - "benchmarks/benchmark_real_wide_parquet.py"
+ - ".github/workflows/real-wide-parquet-benchmark.yml"
+ - "src/framevitals/**"
+ - "rust/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ benchmark:
+ name: Native vs fallback full pipeline 100k x 10k real Parquet
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Configure Rust
+ run: |
+ rustup default stable
+ rustc --version
+ cargo --version
+
+ - name: Install benchmark and native build runtime
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow]"
+ pip install "maturin>=1.14,<2"
+
+ - name: Build and install FrameVitals native engine
+ run: |
+ maturin build \
+ --release \
+ --manifest-path rust/framevitals-py/Cargo.toml \
+ --interpreter python \
+ --out dist-native
+ pip install --force-reinstall --no-deps dist-native/*.whl
+ FRAMEVITALS_BACKEND=rust python - <<'PY'
+ from framevitals.backends import backend_status
+
+ status = backend_status()
+ assert status["native_available"] is True, status
+ assert status["selected"] == "rust", status
+ print(status)
+ PY
+
+ - name: Generate real 100k x 10k Parquet dataset
+ run: |
+ python benchmarks/benchmark_real_wide_parquet.py \
+ --generate-only \
+ --dataset wide-100k-x-10k.parquet \
+ --rows 100000 \
+ --columns 10000 \
+ --row-group-rows 10000 \
+ --output generation.json
+
+ - name: Run native Rust full analysis in all modes
+ env:
+ FRAMEVITALS_BACKEND: rust
+ run: |
+ for mode in quick standard deep research; do
+ echo "=== rust / ${mode} ==="
+ python benchmarks/benchmark_real_wide_parquet.py \
+ --dataset wide-100k-x-10k.parquet \
+ --mode "${mode}" \
+ --output "real-wide-rust-${mode}.json"
+ done
+
+ - name: Run NumPy fallback full analysis in all modes
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: |
+ for mode in quick standard deep research; do
+ echo "=== numpy / ${mode} ==="
+ python benchmarks/benchmark_real_wide_parquet.py \
+ --dataset wide-100k-x-10k.parquet \
+ --mode "${mode}" \
+ --output "real-wide-numpy-${mode}.json"
+ done
+
+ - name: Validate backend routing and full-pipeline accuracy
+ run: |
+ python - <<'PY'
+ import json
+ from pathlib import Path
+
+ failures = []
+ for backend in ("rust", "numpy"):
+ for mode in ("quick", "standard", "deep", "research"):
+ payload = json.loads(
+ Path(f"real-wide-{backend}-{mode}.json").read_text()
+ )
+ observed_backend = payload["backend"]["numeric_backend"]
+ if observed_backend != backend:
+ failures.append(
+ f"{backend}/{mode}: routed to {observed_backend!r}"
+ )
+
+ accuracy = payload["accuracy"]
+ checked = accuracy["profiled_columns_checked"]
+ if accuracy["missing_count_exact_matches"] != checked:
+ failures.append(f"{backend}/{mode}: missing-count parity failed")
+ if accuracy["numeric_count_exact_matches"] != checked:
+ failures.append(f"{backend}/{mode}: numeric-count parity failed")
+ if accuracy["numeric_minmax_exact_matches"] != checked:
+ failures.append(f"{backend}/{mode}: min/max parity failed")
+ if (accuracy["profile_mean_max_abs_error"] or 0.0) > 0.01:
+ failures.append(
+ f"{backend}/{mode}: profile mean max error "
+ f"{accuracy['profile_mean_max_abs_error']}"
+ )
+ if accuracy["health_missing_abs_error"] > 0.02:
+ failures.append(
+ f"{backend}/{mode}: health missingness error "
+ f"{accuracy['health_missing_abs_error']}"
+ )
+
+ deep_checked = accuracy["deep_numeric_columns_checked"]
+ if deep_checked:
+ if accuracy["deep_exact_once_columns"] != deep_checked:
+ failures.append(
+ f"{backend}/{mode}: exact-once reuse missing for "
+ f"{deep_checked - accuracy['deep_exact_once_columns']} columns"
+ )
+ if (accuracy["deep_mean_max_abs_error"] or 0.0) > 0.01:
+ failures.append(
+ f"{backend}/{mode}: deep exact mean error "
+ f"{accuracy['deep_mean_max_abs_error']}"
+ )
+
+ if payload["safety"]["full_materialization"] is not False:
+ failures.append(f"{backend}/{mode}: full materialization occurred")
+
+ if failures:
+ raise SystemExit("\n".join(failures))
+ print("Native and fallback full-pipeline accuracy checks passed.")
+ PY
+
+ - name: Publish benchmark summary
+ if: always()
+ run: |
+ python - <<'PY' >> "$GITHUB_STEP_SUMMARY"
+ import json
+ from pathlib import Path
+
+ generation_path = Path("generation.json")
+ if generation_path.exists():
+ generation = json.loads(generation_path.read_text())
+ print("## Real 100k x 10k Parquet dataset")
+ print()
+ print(f"- Logical cells: **{generation['cells']:,}**")
+ print(f"- Dense int16 equivalent: **{generation['dense_int16_raw_gb']:.2f} GB**")
+ print(f"- Parquet file size: **{generation['file_size_mb']:.2f} MB**")
+ print(f"- Generation time: **{generation['generation_seconds']:.3f}s**")
+ print(f"- Row groups: **{generation['row_groups']}**")
+ print()
+
+ print("## Native Rust vs NumPy fallback")
+ print()
+ print("| Backend | Mode | Wall time | Profiled cols | Sample rows | Profile mean max error | Deep mean max error |")
+ print("|---|---|---:|---:|---:|---:|---:|")
+ for backend in ("rust", "numpy"):
+ for mode in ("quick", "standard", "deep", "research"):
+ path = Path(f"real-wide-{backend}-{mode}.json")
+ if not path.exists():
+ print(f"| {backend} | {mode.title()} | failed | - | - | - | - |")
+ continue
+ payload = json.loads(path.read_text())
+ safety = payload["safety"]
+ accuracy = payload["accuracy"]
+ print(
+ f"| {backend} | {mode.title()} | {payload['analysis_seconds']:.3f}s | "
+ f"{safety['profiled_columns']} | {safety['working_sample_rows']} | "
+ f"{accuracy['profile_mean_max_abs_error']} | "
+ f"{accuracy['deep_mean_max_abs_error']} |"
+ )
+ print()
+
+ for backend in ("rust", "numpy"):
+ for mode in ("quick", "standard", "deep", "research"):
+ path = Path(f"real-wide-{backend}-{mode}.json")
+ if not path.exists():
+ continue
+ payload = json.loads(path.read_text())
+ print(f"### {backend} / {mode.title()} pipeline stages")
+ for name, milliseconds in sorted(payload.get("pipeline_timings_ms", {}).items()):
+ if isinstance(milliseconds, (int, float)):
+ print(f"- {name}: {milliseconds / 1000:.3f}s")
+ print()
+ PY
+
+ - name: Upload benchmark results
+ if: always()
+ uses: actions/upload-artifact@v6
+ with:
+ name: framevitals-real-wide-100k-x-10k-native-vs-numpy
+ path: |
+ generation.json
+ real-wide-rust-quick.json
+ real-wide-rust-standard.json
+ real-wide-rust-deep.json
+ real-wide-rust-research.json
+ real-wide-numpy-quick.json
+ real-wide-numpy-standard.json
+ real-wide-numpy-deep.json
+ real-wide-numpy-research.json
+ if-no-files-found: ignore
+ retention-days: 30
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 588cdae..da73af8 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,9 +4,12 @@ on:
release:
types: [published]
+permissions:
+ contents: read
+
jobs:
- build:
- name: Build distribution
+ build-fallback:
+ name: Build portable fallback
runs-on: ubuntu-latest
steps:
@@ -18,41 +21,176 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
- python-version: "3.x"
+ python-version: "3.12"
- name: Install build tools
run: |
python -m pip install --upgrade pip
python -m pip install build twine
- - name: Build distributions
- run: python -m build
+ - name: Verify release version across Python and Rust packages
+ env:
+ RELEASE_TAG: ${{ github.event.release.tag_name }}
+ run: |
+ PYTHONPATH=src python - <<'PY'
+ import os
+ import tomllib
+ from pathlib import Path
+
+ import framevitals
+
+ tag = os.environ["RELEASE_TAG"]
+ release_version = tag[1:] if tag.startswith("v") else tag
+ with Path("pyproject.toml").open("rb") as handle:
+ project_version = tomllib.load(handle)["project"]["version"]
+ with Path("rust/framevitals-core/Cargo.toml").open("rb") as handle:
+ core_version = tomllib.load(handle)["package"]["version"]
+ with Path("rust/framevitals-py/Cargo.toml").open("rb") as handle:
+ bridge_version = tomllib.load(handle)["package"]["version"]
+ package_version = framevitals.__version__
+ assert Path("rust/Cargo.lock").is_file(), "Rust workspace lockfile is missing"
- - name: Validate distributions
+ versions = {
+ "release tag": release_version,
+ "pyproject": project_version,
+ "python package": package_version,
+ "rust core": core_version,
+ "rust bridge": bridge_version,
+ }
+ assert len(set(versions.values())) == 1, f"Release versions disagree: {versions}"
+ print(f"Release version verified across Python/Rust packages: {project_version}")
+ PY
+
+ - name: Build portable fallback wheel and sdist
+ run: python -m build --outdir dist
+
+ - name: Validate fallback distributions
run: python -m twine check dist/*
- - name: Store distributions
- uses: actions/upload-artifact@v5
+ - name: Store fallback distributions
+ uses: actions/upload-artifact@v6
with:
- name: python-package-distributions
+ name: python-package-fallback
path: dist/
+ if-no-files-found: error
+
+ build-native:
+ name: Native wheel — ${{ matrix.name }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: linux-x86_64
+ os: ubuntu-latest
+ target: x86_64-unknown-linux-gnu
+ manylinux: "2_28"
+ - name: macos-arm64
+ os: macos-latest
+ target: aarch64-apple-darwin
+ manylinux: "off"
+ - name: macos-x86_64
+ os: macos-15-intel
+ target: x86_64-apple-darwin
+ manylinux: "off"
+ - name: windows-x86_64
+ os: windows-latest
+ target: x86_64-pc-windows-msvc
+ manylinux: "off"
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python 3.11 ABI floor
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.11"
+
+ - name: Build native abi3 wheel
+ uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
+ with:
+ command: build
+ maturin-version: v1.14.1
+ target: ${{ matrix.target }}
+ manylinux: ${{ matrix.manylinux }}
+ args: >-
+ --locked
+ --release
+ --manifest-path rust/framevitals-py/Cargo.toml
+ --interpreter python
+ --out dist
+ --compatibility pypi
+
+ - name: Verify native wheel payload
+ shell: bash
+ run: |
+ python - <<'PY'
+ from pathlib import Path
+ import zipfile
+
+ wheels = list(Path("dist").glob("*.whl"))
+ assert len(wheels) == 1, f"Expected one native wheel, found {len(wheels)}"
+ wheel = wheels[0]
+ with zipfile.ZipFile(wheel) as archive:
+ names = archive.namelist()
+ suffixes = (".so", ".pyd", ".dylib")
+ assert "framevitals/py.typed" in names, f"PEP 561 marker missing from {wheel.name}"
+ assert any(
+ name.startswith("framevitals/_native") and name.endswith(suffixes)
+ for name in names
+ ), f"Native extension missing from {wheel.name}"
+ print(f"Verified native distribution: {wheel.name}")
+ PY
+
+ - name: Store native wheel
+ uses: actions/upload-artifact@v6
+ with:
+ name: python-package-native-${{ matrix.name }}
+ path: dist/*.whl
+ if-no-files-found: error
publish:
- name: Publish distribution to PyPI
- needs: build
+ name: Publish distributions to PyPI
+ needs: [build-fallback, build-native]
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/framevitals
permissions:
id-token: write
+ contents: read
steps:
- - name: Download distributions
+ - name: Download all release distributions
uses: actions/download-artifact@v6
with:
- name: python-package-distributions
+ pattern: python-package-*
path: dist/
+ merge-multiple: true
+
+ - name: Show release payload
+ run: ls -lh dist/
+
+ - name: Validate complete release payload
+ run: |
+ python -m pip install --upgrade twine
+ python -m twine check dist/*
+ python - <<'PY'
+ from pathlib import Path
+
+ wheels = list(Path("dist").glob("*.whl"))
+ sdists = list(Path("dist").glob("*.tar.gz"))
+ native = [path for path in wheels if "none-any" not in path.name]
+ fallback = [path for path in wheels if "none-any" in path.name]
+
+ assert len(sdists) == 1, f"Expected one sdist, found {len(sdists)}"
+ assert len(fallback) == 1, f"Expected one portable fallback wheel, found {len(fallback)}"
+ assert len(native) == 4, f"Expected four native wheels, found {len(native)}: {native}"
+ print("Release payload contains one fallback wheel, one sdist, and four native wheels.")
+ PY
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 46dde21..3892b7d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -2,9 +2,16 @@ name: Tests
on:
push:
- branches: [main, dev]
+ branches: [main, dev, "develop/august"]
pull_request:
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
quality:
name: package quality
@@ -34,8 +41,7 @@ jobs:
- name: Check undefined-name and scope errors
run: ruff check src/framevitals tests --select F821,F822,F823
- - name: Report broader dead-code lint debt
- continue-on-error: true
+ - name: Check unused imports and assignments
run: ruff check src/framevitals tests --select F401,F841
core:
@@ -86,6 +92,152 @@ jobs:
- name: Run tests
run: pytest -v
+ native-core:
+ name: native core (Rust + Python bridge)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Configure Rust tooling
+ run: |
+ rustup default stable
+ rustup component add rustfmt clippy
+ rustc --version
+ cargo --version
+
+ - name: Check Rust formatting
+ run: cargo fmt --manifest-path rust/Cargo.toml --all -- --check
+
+ - name: Test Rust workspace
+ run: cargo test --manifest-path rust/Cargo.toml --workspace --all-targets --all-features
+
+ - name: Lint Rust workspace
+ run: cargo clippy --manifest-path rust/Cargo.toml --workspace --all-targets --all-features -- -D warnings
+
+ - name: Install native build tooling
+ run: |
+ python -m pip install --upgrade pip
+ pip install \
+ "maturin>=1.14,<2" \
+ "numpy>=1.26" \
+ "pandas>=2.2" \
+ "pyarrow>=25.0,<26"
+
+ - name: Build native Python wheel
+ run: |
+ maturin build \
+ --release \
+ --manifest-path rust/framevitals-py/Cargo.toml \
+ --interpreter python \
+ --out dist-native
+
+ - name: Verify native Python bridge and streaming profiler routing
+ run: |
+ pip install --force-reinstall --no-deps dist-native/*.whl
+ FRAMEVITALS_BACKEND=rust python - <<'PY'
+ from pathlib import Path
+ from tempfile import TemporaryDirectory
+
+ import numpy as np
+ import pandas as pd
+ import pyarrow as pa
+ import pyarrow.parquet as pq
+
+ import framevitals
+ from framevitals import _native
+ from framevitals.profiler import build_profile
+
+ values = np.ascontiguousarray(
+ np.array([1.0, 2.0, np.nan, np.inf, 4.0, 4.0], dtype=np.float64)
+ )
+ state = _native.numeric_state_f64(values)
+ assert state["backend"] == "rust"
+ assert state["count"] == 4
+ assert state["missing"] == 1
+ assert state["infinite"] == 1
+ assert abs(state["mean"] - 2.75) < 1e-12
+
+ native_profile = _native.numeric_profile_f64(values, stream_id=17)
+ assert native_profile["cardinality_estimate"] >= 3
+ assert native_profile["quantiles"]["p50"] is not None
+ assert native_profile["reservoir"]
+
+ accumulator = _native.NumericAccumulator(stream_id=29)
+ accumulator.update_f64(values[:3])
+ accumulator.update_f64(values[3:])
+ accumulated = accumulator.snapshot()
+ assert accumulator.observations == len(values)
+ assert accumulated["count"] == state["count"]
+ assert accumulated["missing"] == state["missing"]
+ assert accumulated["infinite"] == state["infinite"]
+
+ public_profile = build_profile(pd.DataFrame({
+ "value": [1.0, 2.0, None, 4.0, 4.0],
+ "other": [10.0, 20.0, 30.0, 40.0, 50.0],
+ }))
+ assert public_profile["numeric_summary_metadata"]["backend"] == "rust"
+ assert public_profile["numeric_summary_metadata"]["approximate_quantiles"] is True
+ assert public_profile["missing_counts"]["value"] == 1
+ assert public_profile["numeric_summary"]["value"]["count"] == 4
+
+ with TemporaryDirectory() as directory:
+ path = Path(directory) / "native-stream.parquet"
+ frame = pd.DataFrame({
+ "value": np.arange(20_000, dtype=np.float64),
+ "other": np.arange(20_000, dtype=np.float64) * 3.0,
+ "group": [f"g-{index % 5}" for index in range(20_000)],
+ })
+ frame.loc[::97, "value"] = np.nan
+ pq.write_table(pa.Table.from_pandas(frame, preserve_index=False), path, row_group_size=777)
+ streamed = framevitals.profile(path)
+ assert streamed["streaming_metadata"]["full_materialization"] is False
+ assert streamed["streaming_metadata"]["numeric_backend"] == "rust"
+ assert streamed["numeric_summary_metadata"]["backend"] == "rust"
+ assert streamed["missing_counts"]["value"] == int(frame["value"].isna().sum())
+ assert streamed["numeric_summary"]["value"]["count"] == int(frame["value"].notna().sum())
+
+ print(_native.backend_info())
+ PY
+
+ arrow-streaming:
+ name: Arrow streaming (Python 3.12, NumPy fallback)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ cache: pip
+ cache-dependency-path: pyproject.toml
+
+ - name: Install Arrow capability
+ run: |
+ python -m pip install --upgrade pip
+ pip install -e ".[arrow,dev]"
+
+ - name: Verify dependency consistency
+ run: python -m pip check
+
+ - name: Run streaming source tests without native extension
+ env:
+ FRAMEVITALS_BACKEND: numpy
+ run: pytest -v tests/test_parquet_streaming.py tests/test_statistics_streaming.py tests/test_streaming_drift.py tests/test_csv_streaming.py tests/test_arrow_memory.py tests/test_cli_gate_streaming.py
+
optional-features:
name: optional features (Python 3.12)
runs-on: ubuntu-latest
@@ -116,9 +268,15 @@ jobs:
python - <<'PY'
import flask
import lightgbm
+ import matplotlib
import ollama
+ import openpyxl
+ import pydantic
+ import pyarrow
import pyod
+ import seaborn
import shap
+ import xlrd
import xgboost
print("Optional dependency smoke test passed")
PY
@@ -128,7 +286,9 @@ jobs:
pytest -v \
tests/test_ai_agent.py \
tests/test_ml_engine.py \
- tests/test_model_diagnostics.py
+ tests/test_model_diagnostics.py \
+ tests/test_visualization_engine.py \
+ tests/test_web_dependency_boundaries.py
frontend:
name: frontend build
diff --git a/.gitignore b/.gitignore
index e3f92b4..9391314 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,7 @@ frontend/vite.config.js
frontend/vite.config.d.ts
# Runtime artifacts
+.framevitals/
uploads/*
!uploads/.gitkeep
cleaned/*
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..4b87833
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,35 @@
+minimum_pre_commit_version: "4.0.0"
+
+default_install_hook_types:
+ - pre-commit
+ - pre-push
+
+repos:
+ - repo: local
+ hooks:
+ - id: framevitals-critical-ruff
+ name: FrameVitals critical Python lint
+ entry: python -m ruff check --select E9,F821,F822,F823 src/framevitals tests
+ language: system
+ pass_filenames: false
+ files: ^(src/framevitals|tests)/.*\.py$
+
+ - id: framevitals-compile
+ name: FrameVitals Python compile check
+ entry: python -m compileall -q src/framevitals
+ language: system
+ pass_filenames: false
+ files: ^src/framevitals/.*\.py$
+
+ - id: framevitals-public-contracts
+ name: FrameVitals public contract tests
+ entry: >-
+ python -m pytest -q
+ tests/test_public_surface_contract.py
+ tests/test_provenance.py
+ tests/test_source_inspection.py
+ tests/test_check_plugins.py
+ language: system
+ pass_filenames: false
+ always_run: true
+ stages: [pre-push]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 181e82a..d5fc975 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,17 +2,111 @@
All notable user-facing changes to FrameVitals are documented here.
-FrameVitals follows semantic versioning while the public API matures. The 0.x series may still include breaking changes, which will be called out in release notes.
+FrameVitals follows semantic versioning while the public API matures. The 0.x series may still include breaking changes, which are called out in release notes.
## Unreleased
-Development continues on the `dev` branch. Changes intended for the next release should be documented here before they are promoted to `main`.
+No user-facing changes are currently queued beyond 0.2.0.
-### Added
+## 0.2.0 - 2026-08-17
+
+FrameVitals 0.2.0 is the first release built around the source-aware Arrow/Rust execution architecture rather than treating every dataset as a pandas-first workload.
+
+### Highlights
+
+- Added bounded Arrow streaming for large Parquet, CSV/TSV, PyArrow, Arrow C Stream, Polars-through-Arrow, and optional DuckDB relation inputs.
+- Added a native Rust execution backend with direct Arrow `RecordBatch` profiling, mergeable numeric state, native categorical sketches, and full-stream log-quantile sketches.
+- Added exact-once reuse so downstream Deep/Research diagnostics consume already-known full-stream facts instead of recomputing weaker bounded-sample estimates.
+- Added exact full-stream count, missingness, mean, variance/std, min/max, skewness, and excess kurtosis through mergeable central moments up to M4.
+- Replaced fixed evenly spaced row sampling with deterministic stratified-jitter sampling to reduce periodic/structured-data aliasing while preserving reproducibility and row order.
+- Added explicit execution provenance for full-stream, projected-column, sketch, bounded-sample, and materialized operations.
+- Added physical large-scale benchmark coverage up to 500,000 × 10,000 (5 billion logical cells) with native/fallback routing, accuracy, exact-once, and no-full-materialization checks.
+- Added mixed statistical ground-truth validation across native and fallback execution.
+- PyPI publishing now builds native ABI3 wheels for common Linux/macOS/Windows targets while retaining a portable pure-Python fallback wheel and source distribution.
+
+### Analysis and public APIs
+
+- Added focused APIs for `profile`, `roles`, `health`, `ml_readiness`, `quality`, `statistics`, `anomalies`, `relationships`, and `target_analysis` so callers do not need to run unrelated pipeline stages.
+- Added `framevitals.plan()` for previewing execution budgets, source capabilities, applicable work, and bounded planning behavior.
+- Added `framevitals.inspect_source()` and the matching CLI command for source metadata/capability inspection without analysis.
+- Added data-contract inference with `framevitals.infer_contract()` and structured validation with `framevitals.validate()`.
+- Added `framevitals.gate()` plus a reusable GitHub Action for CI-friendly contract, drift, and custom-check verdicts.
+- Added `framevitals.check()`, `run_checks()`, and opt-in third-party check discovery through Python entry points.
+- Added compact versioned analysis snapshots, snapshot history, and snapshot-to-snapshot monitoring diffs.
+- Added structured dict-compatible result objects including analysis, diagnostic, drift, validation, check, and gate results.
+- Added `system-info`, snapshot, monitoring, planning, inspection, validation, and gate CLI workflows.
+
+### Execution architecture
+
+- Full `framevitals.analyze()` now resolves generic dataset sources and uses bounded source-aware execution when the source supports streaming/projection.
+- Ultra-wide sources use deterministic schema projection under explicit cell/column budgets instead of scanning every source cell blindly.
+- Streaming analysis performs one authoritative full-source profile scan and schedules only genuinely row-dependent modules on the bounded working sample.
+- Numeric native execution accepts Arrow record batches directly through the Arrow C Data/PyCapsule interface, avoiding the former Arrow → NumPy float64 → per-column Python bridge on supported types.
+- Streaming numeric profiling uses a specialized moments + log-quantile state instead of paying for unrelated HLL/heavy-hitter/reservoir work on every numeric cell.
+- Parquet sources cache metadata/schema/file handles per source to avoid repeated metadata parsing.
+- Research inference uses adaptive statistically defensible methods rather than blindly running expensive resampling on large bounded samples.
+- Execution budgets now bound expensive quality, deep-statistics, anomaly, time-series, relationship, bootstrap, and distribution work by source scale and analysis mode.
+
+### Correctness and statistical fidelity
+
+- Full-stream missing counts, numeric counts, min/max, and moments are reused downstream with explicit provenance.
+- Deep/Research shape summaries can reuse exact full-stream skewness/kurtosis while sample-dependent distribution fits, tests, intervals, and relationship diagnostics remain explicitly sample-scoped.
+- Streaming ML-readiness, health, roles, quality, relationships, statistics, and anomaly results now distinguish exact/full-stream facts from bounded estimates.
+- Categorical native profiling reports full-stream approximate cardinality/heavy-hitter provenance rather than pretending native sketches were row samples.
+- Anderson-Darling normality diagnostics support modern and legacy SciPy contracts without relying on a fixed critical-value index.
+- Large-source sampling regression tests cover periodic aliasing and native/fallback provenance contracts.
+
+### Performance and scale validation
+
+FrameVitals benchmarks are not advertised as universal speedups. Results depend on mode, backend, source shape, storage, and statistical fidelity.
+
+Validated physical workloads include 1B, 2.5B, and 5B logical cells. On the 5B 500k × 10k workload, the native full-stream profiling kernel remains competitive while retaining full-stream native quantile sketches where the fallback may choose bounded row-sample quantiles for cost. All published stress workflows enforce no full materialization and correctness tolerances.
+
+### Same-dataset release comparison vs 0.1.0
+
+For an end-to-end release comparison, `v0.1.0` (`3da1432`) and the 0.2.0 release candidate (`05b11e5`) were run through the same public `framevitals.analyze(path, mode=..., artifacts=False)` API on one deterministic physical 10,000 × 64 CSV (640,000 cells). Both environments used Python 3.11 with the same pinned NumPy/pandas/SciPy/statsmodels/scikit-learn stack. FrameVitals 0.2.0 used its native Rust backend. Each version/mode received one warm-up followed by three measured runs in interleaved ABBAAB order; all 10,000 rows and all 64 columns were validated in every run.
+
+| Mode | 0.1.0 median wall | 0.2.0 median wall | Speedup | 0.1.0 median peak RSS | 0.2.0 median peak RSS | Memory change |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Quick | 1.644 s | 0.691 s | **2.38×** | 234.7 MB | 239.5 MB | 2.1% higher |
+| Standard | 141.084 s | 0.839 s | **168.06×** | 2736.7 MB | 264.9 MB | **90.3% lower** |
+
+The measured Quick ranges were 1.620–1.648 s for 0.1.0 and 0.686–0.692 s for 0.2.0. Standard ranges were 140.709–141.130 s for 0.1.0 and 0.823–0.841 s for 0.2.0. The large Standard improvement is a release-level architectural result: 0.2.0 replaces 0.1.0's fully materialized, unbounded heavy-statistics path with bounded/adaptive execution and native streaming. It should not be interpreted as a same-algorithm microbenchmark.
+
+The exact same serialized CSV was then graded against independent pandas/SciPy ground truth. The full Standard pipeline still processed all 10,000 rows and all 64 columns in both releases; the accuracy comparison tracks five representative numeric columns (`c000`, `c001`, `c007`, `c031`, `c063`) plus Pearson correlation for `c000`↔`c001`.
+
+| Tracked metric | 0.1.0 max absolute error | 0.2.0 max absolute error |
+| --- | ---: | ---: |
+| Count / missing / min / max | **0** | **0** |
+| Mean | 0.0005000 | 0.0005000 |
+| Standard deviation | 0.0004745 | 0.0004745 |
+| Skewness / excess kurtosis | 4.92×10⁻⁷ | 4.92×10⁻⁷ |
+| Pearson correlation | 0.0001469 | 0.0001469 |
+| q25 / median / q75 | **0** | 4.196 max; 1.589 mean |
+
+For every tested non-quantile metric, 0.2.0 had **exactly the same measured absolute error as 0.1.0 at the published output precision**. The quantile difference is intentional: 0.2.0 uses a full-stream native log-quantile sketch configured for 1% relative accuracy instead of materializing exact profile quartiles. Across the 15 tracked q25/median/q75 values, the maximum absolute error was 4.196 units and the mean was 1.589 units; normalized by each approximately 2,000-unit observed column range, that is **0.210% max** and **0.0795% mean**. Thus the measured 168.06× Standard speedup and 90.3% RSS reduction preserved the tested exact facts, moments, shape statistics, and correlation fidelity while trading exact profile quartiles for explicitly approximate streaming estimates.
+
+Timing/RSS evidence is committed as `benchmarks/results/release_0.2.0_vs_0.1.0_10k_x64.json` from GitHub Actions run `32010158292`. Same-dataset accuracy evidence is committed as `benchmarks/results/release_0.2.0_vs_0.1.0_accuracy_10k_x64.json`; the legacy/current accuracy run was `32014979365`, with a current-only shape-field extractor correction verified in run `32015665811`.
+
+A separate 100,000 × 64 (6.4M-cell) stress run completed all Quick repetitions at a 5.097 s vs 1.447 s median (**3.52× faster**) and 406.1 MB vs 282.7 MB median peak RSS (**30.4% lower**) for 0.1.0 vs 0.2.0. The repeated Standard comparison intentionally has no formal speedup claim: the 30-minute workflow limit expired because completed 0.1.0 Standard passes took about 640–649 s and roughly 12.4 GB peak RSS each, while observed 0.2.0 Standard passes completed in about 1.58–2.06 s at roughly 303–308 MB. That incomplete stress evidence is GitHub Actions run `32008727705`.
+
+### Packaging and compatibility
+
+- Added Python 3.11, 3.12, and 3.13 core coverage plus Windows/macOS smoke testing.
+- Added dedicated Arrow fallback, Rust/native, minimum-dependency, optional-feature, frontend, package-quality, documentation, and performance CI lanes.
+- Added PEP 561 `py.typed` packaging support.
+- Moved Excel, plotting, AI, Arrow, DuckDB, and other heavier capabilities behind explicit optional dependency groups where appropriate.
+- Release CI verifies the GitHub tag, Python package version, `pyproject.toml`, Rust core version, and Rust bridge version before publication.
+- Package CI verifies both fallback/native wheel contents and proves that pip prefers a compatible native wheel when both are available.
+
+### Fixed
-- Data-contract inference through `framevitals.infer_contract()`
-- Structured contract validation through `framevitals.validate()`
-- `framevitals infer-contract` and `framevitals validate` CLI commands for CI-friendly data gates
+- Fixed periodic sampling aliasing caused by fixed evenly spaced global-row samples.
+- Fixed projected streaming missingness/quality semantics so denominators describe the profiled projection rather than silently using the true source width.
+- Fixed duplicate core recomputation in the streaming pipeline.
+- Fixed stale sampling/native-categorical provenance assertions and public execution labels.
+- Fixed SciPy Anderson-Darling compatibility and a pandas string-dtype selection deprecation.
+- Fixed several optional-dependency/import boundaries so core/lightweight usage does not eagerly require plotting, AI, web, or Excel stacks.
## 0.1.0 - 2026-08-15
@@ -20,40 +114,12 @@ First public alpha release of FrameVitals.
### Added
-- Installable `framevitals` package under `src/framevitals/`
-- Public `framevitals.analyze()` API for pandas DataFrames and supported dataset files
-- Public `framevitals.compare()` API for reference-vs-current drift analysis
-- `framevitals` command-line interface with analyze and compare commands
-- Data-health and ML-readiness scoring
-- Structural profiling and semantic column-role inference
-- Missingness, duplicate, cardinality, statistical, anomaly, target-aware, time-series, and text diagnostics
-- Drift comparison using PSI plus numeric/categorical statistical tests
-- Optional artifact generation for reusable Python workflows
-- Optional ML, AI, and Flask/React web dependency groups
-- Python 3.11, 3.12, and 3.13 test matrix
-- Package-build, wheel-boundary, Twine, and clean-install validation
-- PyPI Trusted Publishing release workflow
-- Dependabot configuration for Python, npm, and GitHub Actions
-- Open-source contributor, security, conduct, issue, and pull-request guidance
-
-### Changed
-
-- Project identity and package documentation moved from DataLens AI to FrameVitals
-- Reusable dashboard/report helpers moved into the canonical package
-- Top-level package import and CLI version path load the analytics pipeline lazily
-- Heavy ML, Ollama, and web dependencies moved into optional extras
-- Reusable Python analysis no longer writes cleaned datasets or charts unless `artifacts=True`
-- Repository layout is package-first, with the Flask API and React dashboard kept as optional interfaces
-- Frontend module documentation now points directly at canonical `src/framevitals/` implementations
-- Development setup standardized on `.venv`
-
-### Removed
-
-- Unused runtime dependencies including Optuna, imbalanced-learn, Pingouin, Plotly, Missingno, fpdf2, Jinja2, Joblib, and Loguru
-- Academic report, presentation, and whitepaper artifacts
-- Generated TypeScript compiler metadata and generated Vite JavaScript/config declarations
-- Tracked runtime-output directories for uploads, reports, and cleaned datasets
-- Large legacy demo CSVs and their one-off inspection harness
-- Redundant Streamlit console and its configuration
-- Legacy shell launcher/install scripts and duplicate `requirements.txt` development wrapper
-- Deprecated top-level `modules/` compatibility namespace after application imports moved to `framevitals.*`
+- Installable `framevitals` package under `src/framevitals/`.
+- Public `framevitals.analyze()` API for pandas DataFrames and supported dataset files.
+- Public `framevitals.compare()` API for reference-vs-current drift analysis.
+- `framevitals` command-line interface with analyze and compare commands.
+- Data-health and ML-readiness scoring.
+- Structural profiling and semantic column-role inference.
+- Missingness, duplicate, cardinality, statistical, anomaly, target-aware, time-series, and text diagnostics.
+- Drift comparison using PSI plus numeric/categorical statistical tests.
+- Optional artifact generation and ML/AI/web dependency groups.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 213dde2..4af8d37 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,6 +2,8 @@
Thanks for considering a contribution to FrameVitals.
+FrameVitals is being built as a package-first data-health and quality-gate library. Contributions are most valuable when they make the public workflow more reliable, source-aware, transparent, and maintainable rather than simply adding more unrelated diagnostics.
+
## Development setup
```bash
@@ -12,6 +14,8 @@ python -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
pip install -e ".[all,dev]"
+pre-commit install
+pre-commit install --hook-type pre-push
```
On Windows PowerShell, activate the environment with:
@@ -20,7 +24,14 @@ On Windows PowerShell, activate the environment with:
.venv\Scripts\Activate.ps1
```
-Run the test suite before making changes:
+Run the same fast local guardrails that CI enforces:
+
+```bash
+pre-commit run --all-files
+pre-commit run --all-files --hook-stage pre-push
+```
+
+Run the full test suite before opening a substantial pull request:
```bash
pytest
@@ -36,11 +47,57 @@ pytest
4. Open the pull request against `dev`.
5. Promote tested release changes from `dev` to `main` through a release pull request.
-## Source layout
+## Architecture boundaries
`src/framevitals/` is the canonical Python package. New reusable code belongs there and should import through the `framevitals.*` namespace.
-The Flask API and React dashboard are optional interfaces around the package. Product logic should stay in `src/framevitals/` rather than being duplicated in application code.
+The main layers have distinct responsibilities:
+
+| Layer | Responsibility |
+| --- | --- |
+| `sources.py` | Normalize inputs and expose metadata, projection, loading, and optional streaming |
+| `focused.py` | Run one requested diagnostic without invoking unrelated pipeline stages |
+| `analysis_api.py` | Dispatch full analysis through the appropriate source-aware execution path |
+| `streaming_pipeline.py` | Orchestrate bounded full analysis for streaming-capable sources |
+| `planning_api.py` | Preview execution decisions without running heavy analyses |
+| `operations.py` | Cleaning, drift, contracts, validation, and quality gates |
+| `result.py` / `quality_results.py` | Dict-compatible public result objects and helpers |
+| `cli.py` | Thin command-line interface over canonical package APIs |
+| `app.py` / `frontend/` | Optional interfaces; they must not duplicate package logic |
+
+A compatibility module may delegate to these layers, but it should not contain a second implementation of the same public operation.
+
+## Source-aware execution
+
+Do not call `source.load()` automatically just because an analysis accepts a file path.
+
+When a `StreamingDatasetSource` can safely support the operation:
+
+1. inspect source metadata first;
+2. derive execution budgets from the **true** source shape;
+3. project only required columns where practical;
+4. stream or sample deterministically within the budget;
+5. expose execution provenance in the returned payload.
+
+Approximate work must be labelled honestly. If a metric comes from a bounded row sample, do not present it as exact. Conversely, checks such as uniqueness, allowed-value validation, or hard numeric bounds must not silently become sampled checks when exactness is part of the public contract.
+
+## Optional dependencies
+
+The base package should not import optional stacks unless their feature is actually requested.
+
+Current capability groups include:
+
+- `framevitals[arrow]` for Arrow-backed streaming and Arrow-compatible table producers;
+- `framevitals[duckdb]` for lazy DuckDB relations through Arrow transport;
+- `framevitals[excel]` for XLS/XLSX readers;
+- `framevitals[plot]` for chart/PDF plotting support;
+- `framevitals[ml]` for heavier model/explainability integrations;
+- `framevitals[ai]` for agentic AI integrations;
+- `framevitals[web]` for the Flask runtime;
+- `framevitals[docs]` for the MkDocs documentation toolchain;
+- `framevitals[all]` for all optional runtime capabilities.
+
+If a new optional dependency is introduced, prefer a lazy import plus a clear install hint. Add a dependency-boundary test when accidental eager imports would make the base install heavier or break another extra.
## Tests
@@ -49,7 +106,9 @@ Useful checks include:
```bash
pytest
pytest tests/test_public_api.py
+pytest tests/test_public_surface_contract.py
pytest tests/test_framevitals_pipeline.py
+pytest tests/test_optional_dependency_boundaries.py
pytest tests/test_package_boundary.py
python -m compileall src/framevitals app.py
python -m build
@@ -57,6 +116,15 @@ python -m twine check dist/*
framevitals --version
```
+For source-aware changes, also run the relevant focused suites. Examples:
+
+```bash
+pytest tests/test_parquet_streaming.py
+pytest tests/test_csv_streaming.py
+pytest tests/test_streaming_drift.py
+pytest tests/test_statistics_streaming.py
+```
+
For the optional React dashboard:
```bash
@@ -65,13 +133,22 @@ npm ci
npm run build
```
+Performance-sensitive changes should use the benchmark harness rather than intuition alone:
+
+```bash
+python benchmarks/benchmark_profile_scale.py --rows 50000 --scenarios numpy auto parquet
+```
+
## Style
- Prefer clear Python over clever Python.
- Keep the public API deliberate and small.
- Avoid hidden global state in reusable library code.
- Return structured, JSON-friendly values where practical.
+- Preserve dict compatibility for public result objects during the 0.x series unless a breaking change is explicitly planned.
- Optional analyses should fail gracefully when a dependency is unavailable.
+- Keep source shape, sample shape, and materialization state distinct in execution metadata.
+- Use Rust only for measured hot paths; keep orchestration and public semantics readable in Python.
- Do not commit generated reports, uploads, cleaned datasets, caches, virtual environments, build output, or frontend compiler artifacts.
## Pull requests
@@ -82,7 +159,8 @@ A good pull request explains:
- why the chosen approach is appropriate;
- what behavior changed;
- what tests were run;
-- any compatibility or performance implications.
+- any compatibility or performance implications;
+- whether the change affects exactness, sampling, optional dependencies, or public result schemas.
For large features or public-API changes, open an issue first so the design can be discussed before implementation.
diff --git a/README.md b/README.md
index 270765e..92f969f 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
### Know if your data is healthy, stable, and ML-ready — before your model finds out.
-**A Python toolkit for data-quality diagnostics, drift detection, anomaly analysis, and ML-readiness checks on pandas and tabular data.**
+**FrameVitals is a source-aware Python toolkit for data health, drift detection, anomaly analysis, data contracts, quality gates, and ML-readiness diagnostics on tabular data.**
[](https://github.com/parthdongre/FrameVitals/actions/workflows/test.yml)
[](https://pypi.org/project/framevitals/)
@@ -12,394 +12,316 @@
[](LICENSE)
[](https://github.com/parthdongre/FrameVitals)
-[Install](#installation) · [Quick start](#quick-start) · [CLI](#command-line-interface) · [Roadmap](#roadmap) · [Contributing](#contributing)
+[Installation](#installation) · [Quick Start](#quick-start) · [Workflows](#common-workflows) · [CLI](#command-line) · [Docs](docs/) · [Contributing](CONTRIBUTING.md)
---
-FrameVitals turns a pandas DataFrame or tabular dataset into a **structured health report** you can inspect, serialize, compare, and eventually enforce in CI.
+FrameVitals helps you decide whether a dataset is **healthy enough to trust** before it reaches a model, analytics workflow, dashboard, or production pipeline.
-Instead of stitching together separate profiling, quality, drift, anomaly, and ML-readiness tools, FrameVitals gives you one deliberately small entry point:
+It provides one consistent API for inspecting data quality, ML readiness, anomalies, drift, contracts, validation, snapshots, and CI-friendly quality gates.
```python
import framevitals as fv
-report = fv.analyze(df)
-drift = fv.compare(reference_df, current_df)
-contract = fv.infer_contract(reference_df)
-validation = fv.validate(current_df, contract)
-```
-
-The goal is simple: **catch bad data before it becomes a bad model, a broken dashboard, or a production incident.**
-
-```text
- ┌──────────────────────────┐
-DataFrame / file ─► ANALYZE │
- │ profile · health · ML │
- │ stats · anomalies · risk │
- └────────────┬─────────────┘
- │
- ▼
- structured report
-
-Reference + current ───────────► COMPARE ─────► drift verdict
+report = fv.analyze(data)
+drift = fv.compare(reference, current)
+contract = fv.infer_contract(reference)
+validation = fv.validate(current, contract)
+gate = fv.gate(current, reference=reference, contract=contract)
```
-## Why FrameVitals?
-
-Most data checks answer one narrow question. FrameVitals is designed around the questions that show up repeatedly in real data and ML workflows:
-
-| Question | FrameVitals |
-| --- | --- |
-| Is this dataset structurally healthy? | Missingness, duplicates, cardinality, schema and quality diagnostics |
-| Is it ready for modelling? | ML-readiness scoring, target-aware checks and model diagnostics |
-| Are there suspicious rows or features? | Statistical diagnostics, anomaly detection, leakage and multicollinearity checks |
-| Has production data changed? | Reference-vs-current drift analysis with numeric and categorical tests |
-| Can I use the result in code? | JSON-friendly structured output through a Python API and CLI |
-| Will analysis unexpectedly write files? | No — filesystem artifacts are opt-in |
-
-FrameVitals is **package-first**. The core library lives under `src/framevitals/`; the Flask API and React dashboard are optional interfaces around the same analysis engine.
+The goal is simple: **catch bad data before it becomes a bad model, broken dashboard, or production incident.**
## Installation
-FrameVitals supports **Python 3.11, 3.12, and 3.13**. The current public release is **0.1.0 (alpha)** and is available on PyPI.
+Install the package from PyPI:
```bash
pip install framevitals
```
-Optional feature groups keep heavier dependencies out of the default install:
+FrameVitals supports **Python 3.11, 3.12, and 3.13**.
+
+Optional capabilities are available as extras:
```bash
-pip install "framevitals[ml]" # XGBoost, LightGBM, PyOD, SHAP
-pip install "framevitals[ai]" # Ollama-backed AI features
-pip install "framevitals[web]" # Flask web runtime
-pip install "framevitals[all]" # all optional runtime features
+pip install "framevitals[arrow]" # Arrow and Parquet interoperability
+pip install "framevitals[duckdb]" # DuckDB relations
+pip install "framevitals[plot]" # plotting and report charts
+pip install "framevitals[ml]" # optional ML diagnostics
+pip install "framevitals[ai]" # Ollama-backed AI capabilities
+pip install "framevitals[web]" # Flask web runtime
+pip install "framevitals[all]" # all optional runtime capabilities
```
-## Quick start
+## Quick Start
-### Analyze a DataFrame
+Analyze a file directly:
```python
-import pandas as pd
import framevitals as fv
-customers = pd.read_csv("customers.csv")
-report = fv.analyze(customers)
+report = fv.analyze("customers.csv")
-print(report["health"]["overall_score"])
-print(report["ml_readiness"])
+print(report.health["overall_score"])
+print(report.ml_readiness)
+print(report.findings[:3])
```
-File paths work too:
+Or pass a pandas DataFrame:
```python
-report = fv.analyze("customers.csv", mode="quick")
+import pandas as pd
+import framevitals as fv
+
+customers = pd.read_csv("customers.csv")
+report = fv.analyze(customers)
```
-FrameVitals supports pandas DataFrames and common tabular file formats including CSV, TSV, Excel, and JSON.
+FrameVitals also supports Parquet, PyArrow data, and lazy DuckDB relations when the corresponding optional dependencies are installed.
-### Add a supervised-learning target
+## Common Workflows
-```python
-report = fv.analyze(
- customers,
- target="churn",
- mode="deep",
-)
+### Run only the diagnostic you need
-print(report["model_leaderboard"])
-print(report["explainability"])
-```
+The focused APIs let you inspect one part of a dataset without running the complete analysis pipeline:
-Target-aware analysis can surface modelling risks such as leakage, imbalance, redundant features, unstable relationships, and weak baselines.
+```python
+fv.profile(data)
+fv.health(data)
+fv.quality(data)
+fv.ml_readiness(data)
+fv.statistics(data)
+fv.anomalies(data)
+fv.relationships(data)
+```
### Compare datasets for drift
```python
-reference = pd.read_csv("training_data.csv")
-current = pd.read_csv("production_batch.csv")
-
result = fv.compare(reference, current)
-print(result["summary"]["overall_verdict"])
+print(result.severity)
print(result["columns"][:3])
```
-Numeric drift uses **PSI, Kolmogorov-Smirnov statistics, and standardized mean shift**. Categorical drift uses **PSI and chi-square diagnostics**.
+Use this to compare training and production data, historical batches, pipeline outputs, or any reference/current pair.
-### Validate a data contract
-
-Infer a contract once from a trusted reference dataset, then validate later
-batches before they reach downstream jobs:
+### Infer and validate a data contract
```python
contract = fv.infer_contract(reference)
result = fv.validate(current, contract)
-if not result["valid"]:
- for finding in result["errors"]:
+if result.status == "fail":
+ for finding in result.findings:
print(finding["message"])
```
-Contracts capture required columns, broad data types, nullability, and finite
-numeric bounds. They are plain JSON-friendly dictionaries, so a contract can
-be committed with a pipeline or stored with a dataset baseline.
+Contracts can capture expectations such as schema, data types, nullability, numeric bounds, allowed values, and uniqueness.
-## The public API
+### Add a quality gate
-The public API is intentionally small while FrameVitals is in alpha.
+```python
+result = fv.gate(
+ current,
+ reference=reference,
+ contract=contract,
+)
-| API | Status | Purpose |
-| --- | --- | --- |
-| `framevitals.analyze(...)` | Available in `0.1.0` | Profile and diagnose one dataset |
-| `framevitals.compare(...)` | Available in `0.1.0` | Compare reference and current data for drift |
-| `framevitals.infer_contract(...)` | Available on `dev` | Infer a reusable data contract from reference data |
-| `framevitals.validate(...)` | Available on `dev` | Validate data against an inferred or explicit contract |
-| snapshots / monitoring | Roadmap | Reuse baselines for recurring schema and drift checks |
+print(result.status) # pass / warn / fail
+print(result.passed)
+```
-This keeps the library easy to learn while leaving room for the result model and validation system to mature before `1.0`.
+A gate combines the checks you choose into one verdict that can be used in scripts, pipelines, and CI.
-## What FrameVitals checks
+### Add domain-specific checks
-| Area | Examples |
-| --- | --- |
-| **Structure** | shape, dtypes, semantic column roles, date/text detection |
-| **Data quality** | missingness, duplicates, constants, cardinality, outliers |
-| **Health scoring** | overall dataset health plus component-level diagnostics |
-| **ML readiness** | modelling readiness, risky columns, preprocessing recommendations |
-| **Statistics** | distribution checks, normality, correlations, effect-size style diagnostics |
-| **Anomalies** | multivariate and robust outlier detectors, optional ensemble methods |
-| **Target intelligence** | task inference, leakage hints, multicollinearity, feature/model diagnostics |
-| **Drift** | PSI, KS, chi-square, mean shift, new or disappearing categories |
-| **Time series** | date-aware diagnostics, stationarity, decomposition and forecast previews |
-| **Text** | text-column profiling, vocabulary and lightweight semantic diagnostics |
-| **Explainability** | model feature importance and SHAP when the optional ML stack is installed |
+```python
+@fv.check("positive revenue", severity="error")
+def positive_revenue(df):
+ return {
+ "passed": bool((df["revenue"] >= 0).all()),
+ "message": "Negative revenue values were found.",
+ }
+
+result = fv.gate(data, custom_checks=[positive_revenue])
+```
-Not every analysis runs on every dataset. FrameVitals uses dataset signals, selected mode, target availability, and installed optional dependencies to decide what is useful and safe to execute.
+Custom checks make it possible to enforce application-specific rules without modifying FrameVitals itself.
-## Analysis modes
+### Run target-aware analysis
```python
-fv.analyze(df, mode="quick")
-fv.analyze(df, mode="standard")
-fv.analyze(df, mode="deep")
-fv.analyze(df, mode="research")
+report = fv.analyze(
+ data,
+ target="churn",
+ mode="deep",
+)
```
-| Mode | Best for |
-| --- | --- |
-| `quick` | Fast structural, quality, and ML-readiness checks |
-| `standard` | Everyday analysis with broader diagnostics |
-| `deep` | Target-aware and heavier statistical analysis |
-| `research` | Largest analysis budget for exploratory work |
+Target-aware analysis can surface modelling risks such as leakage, imbalance, redundant features, multicollinearity, and weak baseline relationships.
+
+### Create monitoring snapshots
-## Filesystem artifacts are opt-in
+```python
+report = fv.analyze(current)
+snapshot = report.snapshot("snapshot.json")
+```
-FrameVitals is designed to behave like a library first. Calling the Python API does not need to scatter reports and cleaned files around your working directory.
+Compare compact snapshots later without retaining every raw dataset:
```python
-report = fv.analyze(df)
-assert report["cleaning"]["output_path"] is None
+previous = fv.load_snapshot("previous.json")
+latest = fv.load_snapshot("snapshot.json")
+change = fv.compare_snapshots(previous, latest)
+```
-report = fv.analyze(df, artifacts=True)
-print(report["cleaning"]["output_path"])
+## Analysis Modes
+
+Choose how much work FrameVitals should perform:
+
+```python
+fv.analyze(data, mode="quick")
+fv.analyze(data, mode="standard")
+fv.analyze(data, mode="deep")
+fv.analyze(data, mode="research")
```
-## Command-line interface
+Use `quick` for fast checks and the deeper modes when you want broader statistical or modelling diagnostics.
-FrameVitals also ships with a CLI for scripts, terminals, and future CI workflows.
+## Source-Aware Execution
-Start by discovering the available commands and options:
+FrameVitals is designed to work with more than pandas alone. Supported sources can include DataFrames, files, Arrow-native data, and DuckDB relations.
-```bash
-framevitals --help
-framevitals analyze --help
-framevitals compare --help
-framevitals --version
-```
+Where semantics allow it, large or lazy sources can use bounded or streaming execution instead of being loaded fully into pandas. Operations that require exact results can still materialize the full dataset, and execution metadata reports those decisions.
+
+## Command Line
+
+The Python package also includes a CLI.
Analyze a dataset:
```bash
-framevitals analyze dataset.csv
-framevitals analyze dataset.csv --mode quick
-framevitals analyze dataset.csv --target churn --mode deep
-framevitals analyze dataset.csv --output report.json
-framevitals analyze dataset.csv --artifacts
+framevitals analyze customers.csv
```
-A useful end-to-end smoke test is:
+Compare two datasets:
```bash
-framevitals analyze dataset.csv --target churn --mode deep --artifacts --output report.json
+framevitals compare reference.csv current.csv
```
-Compare two datasets for drift:
+Infer a contract:
```bash
-framevitals compare train.csv production.csv
-framevitals compare train.csv production.csv --columns age,income
-framevitals compare train.csv production.csv --output drift.json
+framevitals infer-contract reference.csv
```
-Create and use a contract from the terminal:
+Create a monitoring snapshot:
```bash
-framevitals infer-contract training_data.csv --output contract.json
-framevitals validate production_batch.csv --contract contract.json
+framevitals snapshot customers.csv
```
-The validation command exits with status `1` when the contract has errors,
-which makes it suitable for CI jobs and scheduled ingestion checks.
-
-In `0.1.0`, `framevitals analyze` prints a compact analysis summary to the terminal. `--output report.json` writes that CLI summary to the requested path. `--artifacts` enables generated files in the current working directory, including a cleaned dataset under `cleaned/` and generated charts under `static/charts/`. The full structured analysis result is available through the Python API with `framevitals.analyze(...)`.
-
-## Optional ML and AI features
-
-The default package contains the core data-health engine. Heavier features are separated into extras so a simple install stays predictable.
+Inspect dataset execution capabilities:
```bash
-pip install "framevitals[ml]"
+framevitals inspect customers.csv
```
-Adds optional integrations including XGBoost, LightGBM, PyOD and SHAP.
+See all commands and options with:
```bash
-pip install "framevitals[ai]"
+framevitals --help
```
-Adds Ollama-backed interpretation and question-answering features. AI is treated as an optional explanation layer; computed diagnostics remain usable without a reachable model.
-
-## Web dashboard
+## CI and GitHub Actions
-The repository includes an optional **Flask API + React/TypeScript dashboard** for interactive exploration.
+FrameVitals can sit between your data pipeline and downstream work:
-```bash
-pip install -e ".[web]"
-python app.py
+```text
+Data / ETL
+ ↓
+FrameVitals Gate
+ ↓
+PASS / WARN / FAIL
+ ↓
+Training / Analytics / Production
```
-Then in another terminal:
+The repository includes a reusable GitHub Action:
-```bash
-cd frontend
-npm ci
-npm run dev
+```yaml
+- uses: parthdongre/FrameVitals@v0.2.0
+ id: framevitals
+ with:
+ current: data/production.parquet
+ reference: data/training.parquet
+ contract: data/contract.json
+ output: framevitals-gate.json
```
-Typical local endpoints:
-
-- Flask API: `http://127.0.0.1:5055`
-- React dashboard: `http://127.0.0.1:5173`
+For production workflows, pin the action to a released tag or commit.
-The public project website will remain separate from the package runtime so the library does not depend on a hosted service.
+## Python API
-## Design principles
+The main workflow entry points are available directly from `framevitals`:
-FrameVitals is being built around a few constraints that are easy to lose in analytics projects:
-
-- **DataFrame first** — use it directly from Python without routing through a web app.
-- **Structured results** — return reusable data, not only screenshots or prose.
-- **Safe defaults** — no unexpected artifact writes and graceful optional-feature fallbacks.
-- **Small public API** — make the common path obvious before exposing every internal module.
-- **Optional heavy dependencies** — ML, AI, and web features should not bloat a basic install.
-- **Production direction** — drift, contracts, snapshots, and CI quality gates are first-class roadmap items.
-
-## Project layout
-
-```text
-.
-├── src/framevitals/ # canonical installable Python package
-├── tests/ # automated test suite
-├── frontend/ # optional React + TypeScript dashboard
-├── templates/ # Flask report pages
-├── static/ # web/report assets
-├── app.py # optional Flask API/server
-├── pyproject.toml # package metadata and dependency groups
-└── .github/workflows/ # CI, package validation and publishing
+```python
+fv.analyze(...)
+fv.plan(...)
+fv.profile(...)
+fv.health(...)
+fv.quality(...)
+fv.ml_readiness(...)
+fv.statistics(...)
+fv.anomalies(...)
+fv.relationships(...)
+fv.compare(...)
+fv.infer_contract(...)
+fv.validate(...)
+fv.check(...)
+fv.run_checks(...)
+fv.gate(...)
+fv.create_snapshot(...)
+fv.compare_snapshots(...)
```
-New reusable Python code belongs in `src/framevitals/` and should import through the `framevitals.*` namespace.
+For detailed API behaviour, configuration, source semantics, performance notes, and advanced usage, see [`docs/`](docs/).
## Development
+Clone the repository and install it in development mode:
+
```bash
git clone https://github.com/parthdongre/FrameVitals.git
cd FrameVitals
-git switch dev
-
-python -m venv .venv
-source .venv/bin/activate
-python -m pip install --upgrade pip
pip install -e ".[all,dev]"
-
-pytest
-python -m build
-python -m twine check dist/*
```
-On Windows PowerShell:
-
-```powershell
-.venv\Scripts\Activate.ps1
-```
+Run the test suite:
-CI validates the core package across Python 3.11–3.13, optional features, the React build, wheel contents, distribution metadata, and a clean-wheel install.
-
-Development is integrated through `dev`; `main` is kept release-ready.
-
-## Roadmap
-
-FrameVitals is moving toward a complete data-health quality gate:
-
-```text
-0.1 ANALYZE + COMPARE
- data health · ML readiness · target diagnostics · drift
-
-0.2 VALIDATE + SNAPSHOTS
- data contracts · CI gates · reusable baselines
-
-0.3 RESULT OBJECTS + ADVANCED DRIFT
- stronger result model · large-data handling · richer monitoring
-
-0.4 EXTENSIBILITY + INTEGRATIONS
- configurable checks · adapters · monitoring workflows
-
-1.0 STABLE DATA-HEALTH API
- dependable analyze → compare → validate → monitor workflow
+```bash
+pytest
```
-Near-term work is tracked through issues and the `dev` branch.
-
-## Project status
-
-FrameVitals `0.1.x` is **alpha software**. The core API is usable, but the project is intentionally still refining naming, result schemas, thresholds, and extension points before `1.0`.
-
-If you are using FrameVitals in a project, feedback about real datasets, false positives, missing diagnostics, performance, and API ergonomics is especially valuable.
-
## Contributing
-Contributions are welcome.
+Contributions are welcome, including bug fixes, diagnostics, tests, documentation, integrations, and performance improvements.
-A good contribution is focused, tested, and improves either the reliability of a diagnostic or the clarity of the public workflow.
+Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
-Start with [CONTRIBUTING.md](CONTRIBUTING.md), and please read the [Code of Conduct](CODE_OF_CONDUCT.md) and [Security Policy](SECURITY.md).
+## Documentation
-## Releases
+Detailed documentation lives in [`docs/`](docs/).
-Releases are built and validated in GitHub Actions and published through PyPI Trusted Publishing. See [RELEASING.md](RELEASING.md) and [CHANGELOG.md](CHANGELOG.md).
+- [Changelog](CHANGELOG.md)
+- [Contributing Guide](CONTRIBUTING.md)
+- [Issue Tracker](https://github.com/parthdongre/FrameVitals/issues)
## License
-FrameVitals is open source under the [MIT License](LICENSE).
-
----
-
-
-
-**If FrameVitals is useful to you, consider starring the repository — it helps the project grow.**
-
-
+FrameVitals is released under the [MIT License](LICENSE).
diff --git a/RELEASING.md b/RELEASING.md
index 4b21ff5..6505253 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -1,44 +1,94 @@
# Releasing FrameVitals
-FrameVitals is configured to publish to PyPI through GitHub Actions Trusted Publishing. No long-lived PyPI API token is required.
+FrameVitals publishes to PyPI through GitHub Actions Trusted Publishing. No long-lived PyPI API token is required.
## Branch model
-- `main` is the release-ready branch. Published releases and version tags come from `main`.
-- `dev` is the integration branch for ongoing development.
-- Feature and dependency-update pull requests should target `dev`.
-- When a release is ready, promote the tested release changes from `dev` to `main`, update the version/changelog, and publish from `main`.
+- `main` is release-ready; version tags and published GitHub releases come from `main`.
+- `dev` is the normal integration branch.
+- Stabilization branches such as `develop/august` are promoted into `dev` only after their contracts and specialized CI lanes are clean.
+- A stabilization-branch merge is not itself a PyPI release.
-## One-time PyPI setup
+## Release candidate checklist
+
+Before promoting a release candidate toward `main`:
+
+1. Core Tests, Package, minimum-dependency, platform, Arrow/fallback, native Rust, optional-feature, frontend/package-quality, docs, and security-relevant CI must be green.
+2. Performance-sensitive changes must have benchmark evidence and accuracy/safety checks; do not publish unsupported universal speed claims.
+3. Review generated files, temporary artifacts, stale compatibility shims, and branch-only experiments before promotion.
+4. Update `CHANGELOG.md` with the release section and any known 0.x compatibility changes.
+5. Keep all release-version sources synchronized:
+ - GitHub release tag, e.g. `v0.2.0`;
+ - `pyproject.toml` → `[project].version`;
+ - `src/framevitals/__init__.py` → `__version__`;
+ - `rust/framevitals-core/Cargo.toml` → `[package].version`;
+ - `rust/framevitals-py/Cargo.toml` → `[package].version`.
+6. Confirm the package workflow builds and validates both the portable fallback wheel and a compatible native wheel.
+7. Confirm the clean-environment package smoke proves pip prefers the native wheel when both compatible native and universal fallback wheels are present.
+
+## Distribution model
+
+FrameVitals intentionally publishes two kinds of Python wheels:
+
+- **Native ABI3 wheels** for supported/common platforms. These include `framevitals._native` and are preferred automatically by pip when compatible.
+- **Portable fallback wheel** (`py3-none-any`) for environments without a published native wheel.
+
+The release workflow also publishes one source distribution.
-Before the first release:
+Current native release targets are:
-1. Create or sign in to your PyPI account.
-2. Open your PyPI account's **Publishing** page and configure a pending Trusted Publisher for project name `framevitals`.
+- Linux x86_64;
+- macOS arm64;
+- macOS x86_64;
+- Windows x86_64.
+
+The native extension uses a Python 3.11 ABI3 floor so one compatible wheel can serve supported Python 3.11+ versions on the same platform.
+
+## One-time PyPI setup
+
+1. Create/sign in to the PyPI account.
+2. Configure a pending Trusted Publisher for project `framevitals`.
3. GitHub owner: `parthdongre`.
4. Repository: `FrameVitals`.
5. Workflow filename: `release.yml`.
6. GitHub environment: `pypi`.
-7. In GitHub repository settings, create a `pypi` environment. Requiring manual approval for production publishing is recommended.
+7. Create the matching `pypi` environment in GitHub repository settings; production approval is recommended.
+
+If the repository is renamed, update the PyPI Trusted Publisher configuration before publishing again.
+
+## Publish checklist
+
+1. Promote the reviewed release candidate to `main` through the normal branch process.
+2. Verify the exact `main` commit has the intended release versions and changelog.
+3. Create a GitHub release from that exact commit with a matching tag such as `v0.2.0`.
+4. Publishing the GitHub release triggers `.github/workflows/release.yml`.
+5. The workflow must:
+ - verify tag/Python/Rust version consistency;
+ - build one fallback wheel and one source distribution;
+ - build the configured native ABI3 wheels;
+ - verify each native wheel actually contains `framevitals._native`;
+ - run `twine check` across the complete payload;
+ - publish through Trusted Publishing only after all build jobs pass.
+6. Confirm the expected files appear on PyPI.
+7. In a brand-new environment, run:
-A pending publisher does not reserve the package name until the first successful publication. If the repository is renamed later, update the PyPI Trusted Publisher configuration before publishing again.
+ ```bash
+ python -m pip install --upgrade framevitals
+ python -c "import framevitals; print(framevitals.__version__)"
+ framevitals --version
+ ```
-## Release checklist
+8. On a supported native platform, also verify:
-1. Confirm the release commit is on `main` and the Tests and Package workflows are green.
-2. Confirm `CHANGELOG.md` contains the release notes and date.
-3. Confirm `pyproject.toml` and `src/framevitals/__init__.py` contain the same release version.
-4. Confirm the built wheel imports successfully and `framevitals --version` reports the release version.
-5. Create a GitHub release from `main` with a matching tag such as `v0.1.0`.
-6. Publishing the GitHub release triggers `.github/workflows/release.yml`.
-7. Confirm the PyPI publish workflow succeeds.
-8. Install the published package in a clean environment and run `framevitals --version`.
+ ```bash
+ python -c "from framevitals.backends import backend_status; print(backend_status())"
+ ```
-## Version consistency
+ `native_available` should be `True` and automatic backend selection should choose `rust`.
+9. Smoke-test one normal analysis and one source-aware/Arrow path from the published wheel.
-Until version metadata is centralized, keep these two values identical:
+## Release discipline
-- `pyproject.toml` → `[project].version`
-- `src/framevitals/__init__.py` → `__version__`
+Do not bump versions merely to merge a stabilization branch into `dev`. Version bumps belong to an intentional release-candidate commit.
-A future cleanup can derive the package version from one canonical source.
+Do not publish a release because the feature list is long. Publish when correctness, packaging, provenance, platform compatibility, and performance-sensitive behavior have all passed their release gates.
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..4995e30
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,97 @@
+# FrameVitals support
+
+FrameVitals is alpha software and the fastest way to fix a problem is a small,
+reproducible report that shows **what input/source path was used, which optional
+capabilities were installed, and what FrameVitals actually executed**.
+
+## Before opening an issue
+
+1. Reproduce the problem on the newest compatible FrameVitals release or the relevant
+ development branch.
+2. Reduce the input to the smallest dataset that still reproduces the behavior.
+3. Check whether the same call behaves differently with a pandas DataFrame versus the
+ original source adapter.
+4. Include execution/source metadata when the problem involves memory, sampling,
+ streaming, or performance.
+
+Useful commands:
+
+```bash
+framevitals --version
+framevitals system-info --no-probe-gpu --format json
+framevitals inspect path/to/dataset.parquet --format json
+```
+
+If the problem specifically involves CUDA/GPU probing, run `framevitals system-info`
+without `--no-probe-gpu` as well.
+
+## What to include in a bug report
+
+Please provide:
+
+- FrameVitals version or commit SHA;
+- Python version and operating system;
+- the exact command or Python call;
+- the smallest reproducible input shape/schema;
+- which optional extras are installed (`arrow`, `duckdb`, `excel`, `plot`, `ml`, `ai`,
+ or `web`);
+- `framevitals system-info --no-probe-gpu --format json` output when relevant;
+- `framevitals inspect ... --format json` output for source/streaming issues;
+- the complete exception traceback;
+- expected behavior and actual behavior.
+
+For a focused diagnostic, include its execution metadata when possible:
+
+```python
+result = fv.statistics(data, mode="quick")
+print(result.execution)
+```
+
+This tells maintainers whether the operation sampled, streamed, or fully materialized
+its source.
+
+## Reproduction datasets
+
+Do **not** upload confidential, personal, regulated, proprietary, or customer data to a
+public issue. Prefer one of these:
+
+- a tiny synthetic DataFrame;
+- a short CSV created specifically for the reproduction;
+- code that deterministically generates the failing data pattern;
+- schema/source metadata with sensitive values removed.
+
+## Performance reports
+
+For performance regressions, include:
+
+- dataset rows/columns and approximate file size;
+- source format and whether `inspect_source()` reports streaming/projection support;
+- analysis mode;
+- `result.execution` when available;
+- wall-clock time and peak memory if measured;
+- whether `FRAMEVITALS_BACKEND` was configured explicitly;
+- native-core availability from `system-info`.
+
+The repository also contains reproducible benchmark and catastrophic-regression
+workflows under `benchmarks/` and `.github/workflows/`.
+
+## Feature requests
+
+A strong feature request explains the workflow problem before proposing a new module.
+FrameVitals deliberately prefers a small analyze → compare → validate → gate → monitor
+surface over accumulating unrelated analytics features.
+
+For source integrations, first check whether the producer already supports the Arrow C
+Stream / PyCapsule protocol; a standard interoperability boundary is preferable to a
+library-specific dependency when it preserves the required semantics.
+
+## Security issues
+
+Do not report vulnerabilities, credential exposure, or sensitive-data security issues
+through a normal public support thread. Follow the repository's `SECURITY.md` process.
+
+## General questions
+
+Questions that can become durable documentation are welcome. When possible, include a
+small code example and the desired outcome so the answer can be turned into a docs or
+example improvement for future users.
diff --git a/action.yml b/action.yml
new file mode 100644
index 0000000..ab45c5a
--- /dev/null
+++ b/action.yml
@@ -0,0 +1,138 @@
+name: FrameVitals Data Quality Gate
+description: Run FrameVitals drift and contract checks as a CI quality gate.
+author: Parth Dongre
+
+branding:
+ icon: activity
+ color: blue
+
+inputs:
+ current:
+ description: Path to the current dataset in the caller workspace.
+ required: true
+ reference:
+ description: Optional reference dataset path for drift checks.
+ required: false
+ default: ""
+ contract:
+ description: Optional FrameVitals contract JSON path for exact validation.
+ required: false
+ default: ""
+ columns:
+ description: Optional comma-separated columns to include in drift checks.
+ required: false
+ default: ""
+ max-columns:
+ description: Maximum shared columns to compare for drift.
+ required: false
+ default: "30"
+ drift-warn-on:
+ description: Drift severity that changes a passing gate to warning.
+ required: false
+ default: moderate
+ drift-fail-on:
+ description: Drift severity that fails the gate.
+ required: false
+ default: severe
+ fail-on-validation-warning:
+ description: Set to true to promote contract warnings to failures.
+ required: false
+ default: "false"
+ output:
+ description: JSON result path written in the caller workspace.
+ required: false
+ default: framevitals-gate.json
+ python-version:
+ description: Python version used to run FrameVitals.
+ required: false
+ default: "3.12"
+
+outputs:
+ status:
+ description: FrameVitals gate status (pass, warn, or fail).
+ value: ${{ steps.gate.outputs.status }}
+ passed:
+ description: Whether the gate avoided a failing verdict.
+ value: ${{ steps.gate.outputs.passed }}
+ result-path:
+ description: Path to the JSON gate result.
+ value: ${{ steps.gate.outputs.result-path }}
+
+runs:
+ using: composite
+ steps:
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: ${{ inputs.python-version }}
+
+ - name: Install FrameVitals action runtime
+ shell: bash
+ env:
+ ACTION_PATH: ${{ github.action_path }}
+ run: |
+ python -m pip install --upgrade pip
+ cd "$ACTION_PATH"
+ python -m pip install ".[arrow]"
+
+ - name: Run FrameVitals quality gate
+ id: gate
+ shell: bash
+ env:
+ CURRENT: ${{ inputs.current }}
+ REFERENCE: ${{ inputs.reference }}
+ CONTRACT: ${{ inputs.contract }}
+ COLUMNS: ${{ inputs.columns }}
+ MAX_COLUMNS: ${{ inputs.max-columns }}
+ DRIFT_WARN_ON: ${{ inputs.drift-warn-on }}
+ DRIFT_FAIL_ON: ${{ inputs.drift-fail-on }}
+ FAIL_ON_VALIDATION_WARNING: ${{ inputs.fail-on-validation-warning }}
+ OUTPUT: ${{ inputs.output }}
+ run: |
+ if [[ -z "$REFERENCE" && -z "$CONTRACT" ]]; then
+ echo "FrameVitals gate requires at least one of reference or contract." >&2
+ exit 2
+ fi
+
+ args=(
+ gate "$CURRENT"
+ --max-columns "$MAX_COLUMNS"
+ --drift-warn-on "$DRIFT_WARN_ON"
+ --drift-fail-on "$DRIFT_FAIL_ON"
+ --format json
+ --output "$OUTPUT"
+ )
+
+ if [[ -n "$REFERENCE" ]]; then
+ args+=(--reference "$REFERENCE")
+ fi
+ if [[ -n "$CONTRACT" ]]; then
+ args+=(--contract "$CONTRACT")
+ fi
+ if [[ -n "$COLUMNS" ]]; then
+ args+=(--columns "$COLUMNS")
+ fi
+ if [[ "$FAIL_ON_VALIDATION_WARNING" == "true" ]]; then
+ args+=(--fail-on-validation-warning)
+ fi
+
+ set +e
+ framevitals "${args[@]}"
+ exit_code=$?
+ set -e
+
+ python - "$OUTPUT" "$GITHUB_OUTPUT" <<'PY'
+ import json
+ import sys
+ from pathlib import Path
+
+ result_path = Path(sys.argv[1])
+ github_output = Path(sys.argv[2])
+ payload = json.loads(result_path.read_text(encoding="utf-8"))
+ with github_output.open("a", encoding="utf-8") as handle:
+ handle.write(f"status={payload.get('status', 'unknown')}\n")
+ handle.write(f"passed={str(bool(payload.get('passed'))).lower()}\n")
+ handle.write(f"result-path={result_path}\n")
+ PY
+
+ exit "$exit_code"
diff --git a/app.py b/app.py
index d022c28..f202231 100644
--- a/app.py
+++ b/app.py
@@ -5,39 +5,21 @@
server-rendered report routes available for local use.
"""
-import os
import math
-from pathlib import Path
+import os
from copy import deepcopy
-from time import perf_counter
+from pathlib import Path
from threading import Lock, Thread
+from time import perf_counter
-from flask import Flask, render_template, request, redirect, url_for, send_file, session, jsonify
+from flask import Flask, jsonify, redirect, render_template, request, send_file, session, url_for
from werkzeug.exceptions import ClientDisconnected
-from framevitals.loader import (
- load_dataset,
- save_uploaded_file,
-)
-from framevitals.pipeline import (
- run_full_analysis,
-)
-from framevitals.ai_insights import (
- answer_dataset_question,
-)
-from framevitals.ai_agent import (
- answer_with_agent,
-)
-from framevitals.report_generator import (
- generate_pdf_report,
-)
-from framevitals.frontend_api import (
- build_dashboard_payload,
-)
-from framevitals.drift_analysis import (
- compare_datasets,
- split_by_date,
-)
+from framevitals.ai_insights import answer_dataset_question
+from framevitals.drift_analysis import split_by_date
+from framevitals.frontend_api import build_dashboard_payload
+from framevitals.loader import load_dataset, save_uploaded_file
+from framevitals.pipeline import run_full_analysis
# ---------------------------------------------------------------------------
@@ -48,7 +30,6 @@
# JSON.parse both do), which makes the frontend silently fall back to an
# empty payload. We walk every payload recursively and replace those values
# with None so the wire format is RFC-8259 compliant.
-
def _is_nonfinite(v) -> bool:
return isinstance(v, float) and not math.isfinite(v)
@@ -73,7 +54,7 @@ def safe_jsonify(payload):
app = Flask(__name__)
app.secret_key = os.environ.get(
"FRAMEVITALS_SECRET_KEY",
- "development-only-secret"
+ "development-only-secret",
)
app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024
@@ -90,6 +71,7 @@ def safe_jsonify(payload):
class DotDict(dict):
"""Allow dict.key access for Jinja templates."""
+
__getattr__ = dict.get
__setattr__ = dict.__setitem__
__delattr__ = dict.__delitem__
@@ -112,7 +94,12 @@ def _get_report_job(dataset_id: str) -> dict:
return dict(REPORT_JOBS.get(dataset_id, {}))
-def _set_report_job(dataset_id: str, status: str, pdf_path: Path | None = None, error: str | None = None) -> dict:
+def _set_report_job(
+ dataset_id: str,
+ status: str,
+ pdf_path: Path | None = None,
+ error: str | None = None,
+) -> dict:
job = {
"status": status,
"pdf_path": str(pdf_path) if pdf_path else None,
@@ -142,6 +129,10 @@ def _queue_pdf_generation(dataset_id: str, result: dict | None = None) -> dict:
def worker():
_set_report_job(dataset_id, "running")
try:
+ # PDF/report dependencies are intentionally optional for the Flask
+ # runtime. Import them only when a report is actually requested.
+ from framevitals.report_generator import generate_pdf_report
+
pdf_path = generate_pdf_report(deepcopy(cached_result))
_set_report_job(dataset_id, "ready", pdf_path=pdf_path)
except Exception as exc:
@@ -198,7 +189,10 @@ def analyze():
except ClientDisconnected:
return render_template(
"error.html",
- message="The upload was interrupted before Flask finished reading the file. Please try again.",
+ message=(
+ "The upload was interrupted before Flask finished reading the file. "
+ "Please try again."
+ ),
), 400
if not uploaded_file or uploaded_file.filename == "":
@@ -220,7 +214,7 @@ def analyze():
with REPORT_LOCK:
ANALYSIS_CACHE[dataset_id] = deepcopy(result)
- report_job = _queue_pdf_generation(dataset_id, result)
+ _queue_pdf_generation(dataset_id, result)
result["report_status"] = _report_status_payload(dataset_id)
session["dataset_id"] = dataset_id
@@ -229,13 +223,12 @@ def analyze():
session["analysis_mode"] = analysis_mode
session["target_column"] = target_column
- # Convert to DotDict for Jinja template dot-access
result_dot = DotDict.from_dict(result)
-
return render_template("report.html", result=result_dot)
except Exception as exc:
import traceback
+
traceback.print_exc()
return render_template("error.html", message=str(exc))
@@ -249,7 +242,12 @@ def api_analyze():
analysis_mode = request.form.get("analysis_mode", "standard")
target_column = request.form.get("target_column") or None
except ClientDisconnected:
- return jsonify({"error": "The upload was interrupted before the server finished reading it. Please try again."}), 400
+ return jsonify({
+ "error": (
+ "The upload was interrupted before the server finished reading it. "
+ "Please try again."
+ )
+ }), 400
if analysis_mode not in {"quick", "standard", "deep", "research"}:
analysis_mode = "standard"
@@ -331,9 +329,11 @@ def ask():
_queue_pdf_generation(dataset_id, result)
result["report_status"] = _report_status_payload(dataset_id)
- # Try agentic answer first; fall back to the legacy single-shot answerer
- # if anything goes wrong (Ollama offline, model errors, etc.).
+ # Agentic AI is an optional capability. If it is not installed or the
+ # model is unavailable, fall back to the lightweight answerer.
try:
+ from framevitals.ai_agent import answer_with_agent
+
agent_response = answer_with_agent(
question=question,
df=load_dataset(Path(file_path)),
@@ -375,7 +375,6 @@ def api_ask():
if not question:
return jsonify({"error": "Missing 'question' in request body."}), 400
- # Look up the cached analysis result first
with REPORT_LOCK:
cached_result = ANALYSIS_CACHE.get(dataset_id)
@@ -384,7 +383,6 @@ def api_ask():
"error": "No cached analysis was found for this dataset. Run /api/analyze first.",
}), 404
- # Reload dataframe (cheap; uploaded files live under uploads/)
file_path = session.get("file_path")
df = None
if file_path:
@@ -394,9 +392,8 @@ def api_ask():
df = None
try:
- # Fast mode by default (single writer call, no critic/repair).
- # Pass {"mode": "full"} in the body to opt back in to the full
- # planner→executor→critic→writer loop.
+ from framevitals.ai_agent import answer_with_agent
+
mode = (body.get("mode") or "fast").lower().strip()
response = answer_with_agent(
question=question,
@@ -413,7 +410,11 @@ def api_ask():
ml_readiness=cached_result["ml_readiness"],
advanced=cached_result.get("advanced"),
)
- response = {"source": response.get("source", "fallback"), "answer": response.get("answer", str(exc)), "trace": {}}
+ response = {
+ "source": response.get("source", "fallback"),
+ "answer": response.get("answer", str(exc)),
+ "trace": {},
+ }
return safe_jsonify({
"question": question,
@@ -425,6 +426,7 @@ def api_ask():
except Exception as exc:
import traceback
+
traceback.print_exc()
return jsonify({"error": str(exc)}), 500
@@ -450,11 +452,11 @@ def api_ai_report():
cached = ANALYSIS_CACHE.get(dataset_id)
if cached is None:
- return jsonify({"error": "No cached analysis for this dataset. Re-run /api/analyze."}), 404
+ return jsonify({
+ "error": "No cached analysis for this dataset. Re-run /api/analyze."
+ }), 404
- from framevitals.ai_insights import (
- generate_ai_report,
- )
+ from framevitals.ai_insights import generate_ai_report
try:
ai_report = generate_ai_report(
@@ -469,7 +471,6 @@ def api_ai_report():
except Exception as exc:
ai_report = {"source": f"error: {exc}", "text": str(exc)}
- # Re-cache so subsequent /api/analyze calls (or PDF regen) include it.
with REPORT_LOCK:
cached["ai_report"] = ai_report
ANALYSIS_CACHE[dataset_id] = cached
@@ -478,6 +479,7 @@ def api_ai_report():
except Exception as exc:
import traceback
+
traceback.print_exc()
return jsonify({"error": str(exc)}), 500
@@ -539,19 +541,23 @@ def api_compare():
_, ref_path, _ = save_uploaded_file(ref_file)
_, cur_path, _ = save_uploaded_file(cur_file)
- df_ref = load_dataset(ref_path)
- df_cur = load_dataset(cur_path)
-
columns_param = request.form.get("columns", "").strip()
- columns = [c.strip() for c in columns_param.split(",") if c.strip()] if columns_param else None
+ columns = (
+ [c.strip() for c in columns_param.split(",") if c.strip()]
+ if columns_param
+ else None
+ )
- report = compare_datasets(df_ref, df_cur, columns=columns)
+ from framevitals.operations import compare
+
+ report = compare(ref_path, cur_path, columns=columns)
report["reference_filename"] = ref_file.filename
report["current_filename"] = cur_file.filename
return safe_jsonify(report)
except Exception as exc:
import traceback
+
traceback.print_exc()
return jsonify({"error": str(exc)}), 500
@@ -592,7 +598,9 @@ def api_compare_self():
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
- report = compare_datasets(df_ref, df_cur)
+ from framevitals.operations import compare
+
+ report = compare(df_ref, df_cur)
report["reference_filename"] = f"{ds_name} (older {ratio:.0%})"
report["current_filename"] = f"{ds_name} (newer {1 - ratio:.0%})"
report["split_by"] = date_column
@@ -601,6 +609,7 @@ def api_compare_self():
except Exception as exc:
import traceback
+
traceback.print_exc()
return jsonify({"error": str(exc)}), 500
@@ -639,11 +648,17 @@ def download_report(dataset_id):
if report_status["ready"] and pdf_path.exists() and pdf_path.stat().st_size > 0:
return send_file(pdf_path, as_attachment=True)
- message = "The PDF report is generating in the background. Please try again in a few seconds."
+ message = (
+ "The PDF report is generating in the background. "
+ "Please try again in a few seconds."
+ )
if report_status["status"] == "failed":
message = f"PDF generation failed: {report_status.get('error', 'Unknown error')}"
elif result is None:
- message = "No cached analysis was found for this dataset. Please run analysis again first."
+ message = (
+ "No cached analysis was found for this dataset. "
+ "Please run analysis again first."
+ )
return render_template("error.html", message=message), 202
diff --git a/benchmarks/benchmark_deep_pipeline.py b/benchmarks/benchmark_deep_pipeline.py
new file mode 100644
index 0000000..4026f04
--- /dev/null
+++ b/benchmarks/benchmark_deep_pipeline.py
@@ -0,0 +1,122 @@
+"""Reproducible 8k x 180 Deep pipeline benchmark.
+
+This workload is the stable performance target for the August optimization
+track. It is intentionally numeric and wide so profiling, adaptive deep
+statistics, anomaly screening, and pairwise diagnostics all receive work.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+import time
+
+import numpy as np
+import pandas as pd
+
+from framevitals.pipeline import run_full_analysis
+
+
+DEFAULT_ROWS = 8_000
+DEFAULT_COLUMNS = 180
+HISTORICAL_ORIGINAL_SECONDS = 76.17
+TEN_X_TARGET_SECONDS = HISTORICAL_ORIGINAL_SECONDS / 10.0
+
+
+def make_workload(
+ rows: int = DEFAULT_ROWS,
+ columns: int = DEFAULT_COLUMNS,
+ *,
+ seed: int = 42,
+) -> pd.DataFrame:
+ if rows < 100 or columns < 32:
+ raise ValueError("benchmark requires at least 100 rows and 32 columns")
+
+ rng = np.random.default_rng(seed)
+ values = rng.normal(size=(rows, columns))
+
+ # Make a bounded set of columns diagnostically interesting so deep triage
+ # consistently exercises skew, missingness, constants, and relationships.
+ skewed = min(12, columns)
+ values[:, :skewed] = rng.lognormal(
+ mean=0.0,
+ sigma=1.5,
+ size=(rows, skewed),
+ )
+
+ for index in range(12, min(24, columns)):
+ values[::11, index] = np.nan
+
+ for index in range(24, min(28, columns)):
+ values[:, index] = float(index)
+
+ for offset, index in enumerate(range(30, min(40, columns))):
+ source = offset % min(10, columns)
+ values[:, index] = values[:, source] * 0.97 + rng.normal(
+ scale=0.03,
+ size=rows,
+ )
+
+ return pd.DataFrame(
+ values,
+ columns=[f"n{index:03d}" for index in range(columns)],
+ )
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
+ parser.add_argument("--columns", type=int, default=DEFAULT_COLUMNS)
+ parser.add_argument("--output", type=Path)
+ args = parser.parse_args()
+
+ os.environ.setdefault("FRAMEVITALS_BACKEND", "numpy")
+ frame = make_workload(args.rows, args.columns)
+
+ started = time.perf_counter()
+ result = run_full_analysis(
+ "benchmark-8k-180-deep",
+ original_filename="synthetic-8k-180",
+ analysis_mode="deep",
+ skip_ai=True,
+ parallel_workers=4,
+ dataframe=frame,
+ write_artifacts=False,
+ )
+ elapsed = time.perf_counter() - started
+
+ payload = {
+ "benchmark_schema_version": 1,
+ "workload": {
+ "rows": int(args.rows),
+ "columns": int(args.columns),
+ "kind": "deterministic_wide_numeric_deep",
+ "seed": 42,
+ "backend": os.environ.get("FRAMEVITALS_BACKEND", "auto"),
+ },
+ "elapsed_seconds": round(float(elapsed), 6),
+ "historical_original_seconds": HISTORICAL_ORIGINAL_SECONDS,
+ "ten_x_target_seconds": round(TEN_X_TARGET_SECONDS, 6),
+ "speedup_vs_historical_original": round(
+ HISTORICAL_ORIGINAL_SECONDS / max(elapsed, 1.0e-12),
+ 4,
+ ),
+ "ten_x_target_met": bool(elapsed <= TEN_X_TARGET_SECONDS),
+ "pipeline_timings_ms": result.get("timings_ms", {}),
+ "deep_execution": (
+ result.get("deep_statistics_v2", {}).get("execution", {})
+ if isinstance(result.get("deep_statistics_v2"), dict)
+ else {}
+ ),
+ }
+
+ encoded = json.dumps(payload, indent=2, sort_keys=True)
+ print(encoded)
+ if args.output is not None:
+ args.output.write_text(encoded + "\n", encoding="utf-8")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/benchmark_extreme_streaming.py b/benchmarks/benchmark_extreme_streaming.py
new file mode 100644
index 0000000..1557575
--- /dev/null
+++ b/benchmarks/benchmark_extreme_streaming.py
@@ -0,0 +1,169 @@
+"""Virtual ultra-wide streaming benchmark.
+
+A dense materialization of the target shapes is intentionally avoided. The
+benchmark models a projection-capable streaming source and fails if FrameVitals
+ever requests the complete ultra-wide schema during analysis.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import time
+from types import SimpleNamespace
+
+import numpy as np
+import pyarrow as pa
+
+import framevitals as fv
+from framevitals.sources import DatasetMetadata
+
+
+DEFAULT_ROWS = 1_000_000
+DEFAULT_COLUMNS = 100_000
+VALID_MODES = ("quick", "standard", "deep", "research")
+
+
+class VirtualSchema:
+ def __init__(self, columns: int):
+ self.columns = int(columns)
+ self.dtype = pa.float64()
+
+ def __iter__(self):
+ for index in range(self.columns):
+ yield SimpleNamespace(name=f"n{index:05d}", type=self.dtype)
+
+ def field(self, name: str):
+ index = int(name[1:])
+ if index < 0 or index >= self.columns:
+ raise KeyError(name)
+ return SimpleNamespace(name=name, type=self.dtype)
+
+
+class VirtualExtremeSource:
+ def __init__(self, rows: int, columns: int):
+ self.rows = int(rows)
+ self.columns = int(columns)
+ self.schema_view = VirtualSchema(columns)
+ self.max_requested_columns = 0
+ self.unbounded_requests = 0
+ self.batches_yielded = 0
+ self.rows_yielded = 0
+
+ def inspect(self):
+ return DatasetMetadata(
+ name=f"virtual-{self.rows}-x-{self.columns}",
+ kind="virtual",
+ format="synthetic",
+ rows=self.rows,
+ columns=self.columns,
+ size_bytes=self.rows * self.columns * 8,
+ materialized=False,
+ supports_projection=True,
+ supports_streaming=True,
+ )
+
+ def schema(self):
+ return self.schema_view
+
+ def iter_batches(self, *, batch_size=65_536, columns=None):
+ if columns is None:
+ self.unbounded_requests += 1
+ raise RuntimeError(
+ f"FrameVitals attempted an unbounded {self.columns:,}-column scan."
+ )
+
+ names = list(columns)
+ self.max_requested_columns = max(self.max_requested_columns, len(names))
+ offset = 0
+ while offset < self.rows:
+ take = min(int(batch_size), self.rows - offset)
+ base = np.arange(offset, offset + take, dtype=np.float64)
+ values = pa.array((base % 10_007) / 10_007.0)
+ batch = pa.RecordBatch.from_arrays([values] * len(names), names=names)
+ self.batches_yielded += 1
+ self.rows_yielded += take
+ yield batch
+ offset += take
+
+ def load(self):
+ raise RuntimeError("Virtual extreme source must never materialize fully.")
+
+ def reset_observations(self) -> None:
+ self.max_requested_columns = 0
+ self.unbounded_requests = 0
+ self.batches_yielded = 0
+ self.rows_yielded = 0
+
+
+def run_benchmark(*, rows: int, columns: int, mode: str) -> dict:
+ source = VirtualExtremeSource(rows, columns)
+
+ plan_started = time.perf_counter()
+ plan = fv.plan(source, mode=mode, workers=4)
+ plan_seconds = time.perf_counter() - plan_started
+
+ source.reset_observations()
+
+ analysis_started = time.perf_counter()
+ result = fv.analyze(
+ source,
+ mode=mode,
+ artifacts=False,
+ workers=4,
+ )
+ analysis_seconds = time.perf_counter() - analysis_started
+
+ streaming = result.get("execution", {}).get("streaming", {})
+ working_sample_rows = int(streaming.get("working_sample_rows", 0) or 0)
+ return {
+ "benchmark_schema_version": 2,
+ "workload": {
+ "rows": rows,
+ "columns": columns,
+ "cells": rows * columns,
+ "dense_float64_raw_gb": round(rows * columns * 8 / 1_000_000_000, 3),
+ "mode": mode,
+ "source": "virtual_projection_capable_stream",
+ },
+ "plan_seconds": round(plan_seconds, 6),
+ "analysis_seconds": round(analysis_seconds, 6),
+ "scale_class": plan.get("execution_budget", {}).get("scale_class"),
+ "planning": plan.get("planning_data", {}),
+ "streaming_execution": streaming,
+ "pipeline_timings_ms": result.get("timings_ms", {}),
+ "source_observed": {
+ "max_requested_columns": source.max_requested_columns,
+ "unbounded_requests": source.unbounded_requests,
+ "batches_yielded": source.batches_yielded,
+ "rows_yielded": source.rows_yielded,
+ },
+ "safety": {
+ "full_materialization": streaming.get("full_materialization"),
+ "column_sampled": streaming.get("column_sampled"),
+ "profiled_columns": streaming.get("profiled_columns"),
+ "source_columns": streaming.get("source_columns"),
+ "working_sample_rows": working_sample_rows,
+ "all_source_rows_scanned": source.rows_yielded == rows,
+ "unbounded_width_requested": source.unbounded_requests > 0,
+ },
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
+ parser.add_argument("--columns", type=int, default=DEFAULT_COLUMNS)
+ parser.add_argument("--mode", choices=VALID_MODES, default="deep")
+ parser.add_argument("--output", type=Path, default=Path("extreme-benchmark-results.json"))
+ args = parser.parse_args()
+
+ payload = run_benchmark(rows=args.rows, columns=args.columns, mode=args.mode)
+ encoded = json.dumps(payload, indent=2, sort_keys=True)
+ args.output.write_text(encoded + "\n", encoding="utf-8")
+ print(encoded)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/benchmark_profile_scale.py b/benchmarks/benchmark_profile_scale.py
new file mode 100644
index 0000000..945bee7
--- /dev/null
+++ b/benchmarks/benchmark_profile_scale.py
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+"""Reproducible FrameVitals scale benchmark.
+
+The harness runs each scenario in a fresh subprocess so process peak RSS values
+are comparable and one scenario's allocator state cannot contaminate another.
+It intentionally records measurements rather than enforcing fragile absolute
+performance thresholds in CI.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from pathlib import Path
+import platform
+import resource
+import subprocess
+import sys
+import tempfile
+import time
+from typing import Any
+
+import numpy as np
+import pandas as pd
+
+
+DEFAULT_ROWS = 100_000
+DEFAULT_NUMERIC_COLUMNS = 100
+DEFAULT_CATEGORICAL_COLUMNS = 5
+DEFAULT_SEED = 42
+SCENARIOS = ("numpy", "auto", "parquet")
+
+
+def _peak_rss_mb() -> float:
+ raw = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
+ # Linux reports KiB; macOS reports bytes.
+ if platform.system() == "Darwin":
+ return raw / (1024 * 1024)
+ return raw / 1024
+
+
+def _frame(
+ rows: int,
+ numeric_columns: int,
+ categorical_columns: int,
+ seed: int,
+) -> pd.DataFrame:
+ rng = np.random.default_rng(seed)
+ values = rng.standard_normal((rows, numeric_columns), dtype=np.float64)
+ if numeric_columns:
+ values[::97, ::7] = np.nan
+ frame = pd.DataFrame(
+ values,
+ columns=[f"metric_{index:03d}" for index in range(numeric_columns)],
+ copy=False,
+ )
+ for index in range(categorical_columns):
+ cardinality = 7 + index * 3
+ frame[f"category_{index:02d}"] = np.asarray(
+ [f"g{index}_{row % cardinality}" for row in range(rows)],
+ dtype=object,
+ )
+ return frame
+
+
+def _write_parquet_streaming(
+ path: Path,
+ *,
+ rows: int,
+ numeric_columns: int,
+ categorical_columns: int,
+ seed: int,
+ chunk_rows: int = 20_000,
+) -> None:
+ try:
+ import pyarrow as pa
+ import pyarrow.parquet as pq
+ except ImportError as exc:
+ raise RuntimeError(
+ 'Parquet benchmark requires: pip install "framevitals[arrow]"'
+ ) from exc
+
+ writer = None
+ try:
+ offset = 0
+ chunk_index = 0
+ while offset < rows:
+ current = min(chunk_rows, rows - offset)
+ frame = _frame(
+ current,
+ numeric_columns,
+ categorical_columns,
+ seed + chunk_index,
+ )
+ table = pa.Table.from_pandas(frame, preserve_index=False)
+ if writer is None:
+ writer = pq.ParquetWriter(path, table.schema)
+ writer.write_table(table, row_group_size=min(current, 10_000))
+ offset += current
+ chunk_index += 1
+ finally:
+ if writer is not None:
+ writer.close()
+
+
+def _result_metadata(result: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "shape": result.get("shape"),
+ "numeric_summary_metadata": result.get("numeric_summary_metadata"),
+ "correlation_metadata": result.get("correlation_metadata"),
+ "duplicate_metadata": result.get("duplicate_metadata"),
+ "streaming_metadata": result.get("streaming_metadata"),
+ "source_metadata": result.get("source_metadata"),
+ }
+
+
+def _worker(args: argparse.Namespace) -> int:
+ import framevitals
+ from framevitals.backends import backend_status
+
+ started = time.perf_counter()
+ source_memory_mb = None
+
+ if args.scenario in {"numpy", "auto"}:
+ if args.scenario == "numpy":
+ os.environ["FRAMEVITALS_BACKEND"] = "numpy"
+ else:
+ os.environ.pop("FRAMEVITALS_BACKEND", None)
+ frame = _frame(
+ args.rows,
+ args.numeric_columns,
+ args.categorical_columns,
+ args.seed,
+ )
+ source_memory_mb = round(
+ float(frame.memory_usage(index=True, deep=True).sum()) / (1024 * 1024),
+ 3,
+ )
+ result = framevitals.profile(frame)
+ elif args.scenario == "parquet":
+ if not args.parquet_path:
+ raise ValueError("parquet worker requires --parquet-path")
+ os.environ.pop("FRAMEVITALS_BACKEND", None)
+ result = framevitals.profile(Path(args.parquet_path))
+ else:
+ raise ValueError(f"Unknown benchmark scenario: {args.scenario}")
+
+ elapsed = time.perf_counter() - started
+ payload = {
+ "scenario": args.scenario,
+ "rows": args.rows,
+ "numeric_columns": args.numeric_columns,
+ "categorical_columns": args.categorical_columns,
+ "total_columns": args.numeric_columns + args.categorical_columns,
+ "seed": args.seed,
+ "elapsed_seconds": round(elapsed, 6),
+ "peak_rss_mb": round(_peak_rss_mb(), 3),
+ "source_memory_mb": source_memory_mb,
+ "backend_status": backend_status(),
+ "result": _result_metadata(result),
+ "python": platform.python_version(),
+ "platform": platform.platform(),
+ }
+ print(json.dumps(payload, sort_keys=True))
+ return 0
+
+
+def _run_scenario(
+ script: Path,
+ scenario: str,
+ *,
+ rows: int,
+ numeric_columns: int,
+ categorical_columns: int,
+ seed: int,
+ parquet_path: Path | None,
+) -> dict[str, Any]:
+ command = [
+ sys.executable,
+ str(script),
+ "--worker",
+ "--scenario",
+ scenario,
+ "--rows",
+ str(rows),
+ "--numeric-columns",
+ str(numeric_columns),
+ "--categorical-columns",
+ str(categorical_columns),
+ "--seed",
+ str(seed),
+ ]
+ if parquet_path is not None:
+ command.extend(["--parquet-path", str(parquet_path)])
+
+ completed = subprocess.run(
+ command,
+ check=True,
+ capture_output=True,
+ text=True,
+ env=os.environ.copy(),
+ )
+ lines = [line for line in completed.stdout.splitlines() if line.strip()]
+ if not lines:
+ raise RuntimeError(f"Benchmark worker {scenario} produced no JSON output.")
+ return json.loads(lines[-1])
+
+
+def _parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
+ parser.add_argument("--numeric-columns", type=int, default=DEFAULT_NUMERIC_COLUMNS)
+ parser.add_argument(
+ "--categorical-columns",
+ type=int,
+ default=DEFAULT_CATEGORICAL_COLUMNS,
+ )
+ parser.add_argument("--seed", type=int, default=DEFAULT_SEED)
+ parser.add_argument(
+ "--scenarios",
+ nargs="+",
+ choices=SCENARIOS,
+ default=list(SCENARIOS),
+ )
+ parser.add_argument("--output", type=Path, default=None)
+ parser.add_argument("--worker", action="store_true", help=argparse.SUPPRESS)
+ parser.add_argument("--scenario", choices=SCENARIOS, default=None, help=argparse.SUPPRESS)
+ parser.add_argument("--parquet-path", default=None, help=argparse.SUPPRESS)
+ args = parser.parse_args()
+ if args.rows < 1:
+ parser.error("--rows must be at least 1")
+ if args.numeric_columns < 0 or args.categorical_columns < 0:
+ parser.error("column counts must be non-negative")
+ if args.numeric_columns + args.categorical_columns < 1:
+ parser.error("at least one column is required")
+ if args.worker and args.scenario is None:
+ parser.error("--worker requires --scenario")
+ return args
+
+
+def main() -> int:
+ args = _parse_args()
+ if args.worker:
+ return _worker(args)
+
+ script = Path(__file__).resolve()
+ results: list[dict[str, Any]] = []
+ with tempfile.TemporaryDirectory(prefix="framevitals-benchmark-") as directory:
+ parquet_path: Path | None = None
+ if "parquet" in args.scenarios:
+ parquet_path = Path(directory) / "scale.parquet"
+ prepare_started = time.perf_counter()
+ _write_parquet_streaming(
+ parquet_path,
+ rows=args.rows,
+ numeric_columns=args.numeric_columns,
+ categorical_columns=args.categorical_columns,
+ seed=args.seed,
+ )
+ parquet_prepare_seconds = round(time.perf_counter() - prepare_started, 6)
+ else:
+ parquet_prepare_seconds = None
+
+ for scenario in args.scenarios:
+ results.append(
+ _run_scenario(
+ script,
+ scenario,
+ rows=args.rows,
+ numeric_columns=args.numeric_columns,
+ categorical_columns=args.categorical_columns,
+ seed=args.seed,
+ parquet_path=parquet_path if scenario == "parquet" else None,
+ )
+ )
+
+ payload = {
+ "benchmark_schema_version": 1,
+ "workload": {
+ "rows": args.rows,
+ "numeric_columns": args.numeric_columns,
+ "categorical_columns": args.categorical_columns,
+ "total_columns": args.numeric_columns + args.categorical_columns,
+ "seed": args.seed,
+ },
+ "parquet_prepare_seconds": parquet_prepare_seconds,
+ "measurements": results,
+ "notes": [
+ "Each measurement runs in a fresh process.",
+ "Peak RSS includes imports and in-memory source construction for DataFrame scenarios.",
+ "Parquet file preparation occurs outside the measured Parquet worker.",
+ "No absolute timing threshold is enforced; compare like-for-like machines and commits.",
+ ],
+ }
+ serialized = json.dumps(payload, indent=2, sort_keys=True)
+ if args.output is not None:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(serialized + "\n", encoding="utf-8")
+ print(serialized)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/benchmark_real_wide_parquet.py b/benchmarks/benchmark_real_wide_parquet.py
new file mode 100644
index 0000000..f0f8880
--- /dev/null
+++ b/benchmarks/benchmark_real_wide_parquet.py
@@ -0,0 +1,295 @@
+"""Generate and benchmark a real 100,000 x 10,000 Parquet dataset.
+
+The dataset is written in bounded row groups, so generation never materializes
+all one billion cells in RAM. The on-disk Parquet file is then analyzed through
+the public ``framevitals.analyze`` API.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+import time
+from typing import Any
+
+import numpy as np
+import pyarrow as pa
+import pyarrow.parquet as pq
+
+import framevitals as fv
+
+
+DEFAULT_ROWS = 100_000
+DEFAULT_COLUMNS = 10_000
+PATTERN_COUNT = 32
+VALID_MODES = ("quick", "standard", "deep", "research")
+
+
+def _pattern_numpy(pattern_id: int, row_ids: np.ndarray) -> tuple[np.ndarray, np.ndarray | None]:
+ """Return one deterministic int16 pattern and optional null mask."""
+ pattern_id = int(pattern_id) % PATTERN_COUNT
+ if pattern_id == 0:
+ values = row_ids % 101
+ elif pattern_id == 1:
+ values = 2 * (row_ids % 101)
+ elif pattern_id == 2:
+ values = (row_ids * 7 + 3) % 211
+ elif pattern_id == 3:
+ values = np.full(row_ids.shape, 7, dtype=np.int64)
+ elif pattern_id == 4:
+ values = (row_ids * 13) % 503
+ values = values.copy()
+ values[row_ids % 997 == 0] = 30_000
+ elif pattern_id == 5:
+ values = (row_ids * 5) % 307
+ return values.astype(np.int16, copy=False), (row_ids % 10 == 0)
+ elif pattern_id == 6:
+ values = row_ids % 2
+ elif pattern_id == 7:
+ values = np.where(row_ids % 100 < 50, 10, 200)
+ else:
+ multiplier = pattern_id * 2 + 1
+ values = (row_ids * multiplier + pattern_id * 17) % 997
+ return values.astype(np.int16, copy=False), None
+
+
+def _pattern_arrow(pattern_id: int, row_ids: np.ndarray) -> pa.Array:
+ values, mask = _pattern_numpy(pattern_id, row_ids)
+ return pa.array(values, mask=mask, type=pa.int16())
+
+
+def generate_dataset(
+ path: Path,
+ *,
+ rows: int = DEFAULT_ROWS,
+ columns: int = DEFAULT_COLUMNS,
+ row_group_rows: int = 10_000,
+) -> dict[str, Any]:
+ """Write the full logical dataset to Parquet using bounded-memory row groups."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ names = [f"n{index:05d}" for index in range(columns)]
+ schema = pa.schema([pa.field(name, pa.int16()) for name in names])
+
+ started = time.perf_counter()
+ writer = pq.ParquetWriter(
+ path,
+ schema=schema,
+ compression="snappy",
+ use_dictionary=True,
+ write_statistics=False,
+ )
+ try:
+ for start in range(0, rows, row_group_rows):
+ stop = min(rows, start + row_group_rows)
+ row_ids = np.arange(start, stop, dtype=np.int64)
+ patterns = [_pattern_arrow(index, row_ids) for index in range(PATTERN_COUNT)]
+ arrays = [patterns[index % PATTERN_COUNT] for index in range(columns)]
+ batch = pa.RecordBatch.from_arrays(arrays, schema=schema)
+ writer.write_table(
+ pa.Table.from_batches([batch], schema=schema),
+ row_group_size=len(row_ids),
+ )
+ finally:
+ writer.close()
+
+ elapsed = time.perf_counter() - started
+ parquet = pq.ParquetFile(path)
+ return {
+ "path": str(path),
+ "rows": int(parquet.metadata.num_rows),
+ "columns": int(parquet.metadata.num_columns),
+ "cells": int(parquet.metadata.num_rows) * int(parquet.metadata.num_columns),
+ "file_size_mb": round(path.stat().st_size / 1_000_000, 3),
+ "dense_int16_raw_gb": round(rows * columns * 2 / 1_000_000_000, 3),
+ "generation_seconds": round(elapsed, 6),
+ "row_groups": int(parquet.metadata.num_row_groups),
+ "pattern_count": PATTERN_COUNT,
+ }
+
+
+def _expected_metrics(rows: int) -> dict[int, dict[str, Any]]:
+ row_ids = np.arange(rows, dtype=np.int64)
+ expected: dict[int, dict[str, Any]] = {}
+ for pattern_id in range(PATTERN_COUNT):
+ values, mask = _pattern_numpy(pattern_id, row_ids)
+ valid = values if mask is None else values[~mask]
+ expected[pattern_id] = {
+ "missing": 0 if mask is None else int(mask.sum()),
+ "count": int(valid.size),
+ "mean": float(valid.astype(np.float64).mean()) if valid.size else None,
+ "min": int(valid.min()) if valid.size else None,
+ "max": int(valid.max()) if valid.size else None,
+ }
+ return expected
+
+
+def _accuracy_checks(result: dict[str, Any], *, rows: int) -> dict[str, Any]:
+ profile = result.get("profile", {})
+ columns = list(profile.get("columns", []))
+ missing_counts = profile.get("missing_counts", {})
+ numeric_summary = profile.get("numeric_summary", {})
+ expected = _expected_metrics(rows)
+
+ missing_matches = 0
+ count_matches = 0
+ minmax_matches = 0
+ mean_errors: list[float] = []
+ expected_missing_total = 0
+
+ for column in columns:
+ index = int(str(column)[1:])
+ truth = expected[index % PATTERN_COUNT]
+ expected_missing_total += int(truth["missing"])
+ if int(missing_counts.get(column, -1)) == int(truth["missing"]):
+ missing_matches += 1
+
+ summary = numeric_summary.get(column, {})
+ if int(summary.get("count", -1)) == int(truth["count"]):
+ count_matches += 1
+ if summary.get("min") == truth["min"] and summary.get("max") == truth["max"]:
+ minmax_matches += 1
+ observed_mean = summary.get("mean")
+ if observed_mean is not None and truth["mean"] is not None:
+ mean_errors.append(abs(float(observed_mean) - float(truth["mean"])))
+
+ expected_health_missing = expected_missing_total / max(rows * len(columns), 1) * 100.0
+ observed_health_missing = float(
+ result.get("health", {}).get("details", {}).get("missing_percent", 0.0) or 0.0
+ )
+
+ deep_stats = result.get("deep_statistics_v2", {})
+ numeric_stats = deep_stats.get("numeric_statistics", {}) if isinstance(deep_stats, dict) else {}
+ deep_mean_errors: list[float] = []
+ exact_once_columns = 0
+ for column, summary in numeric_stats.items():
+ if not isinstance(summary, dict) or summary.get("mean") is None:
+ continue
+ index = int(str(column)[1:])
+ truth_mean = expected[index % PATTERN_COUNT]["mean"]
+ if truth_mean is not None:
+ deep_mean_errors.append(abs(float(summary["mean"]) - float(truth_mean)))
+ provenance = summary.get("summary_provenance", {})
+ if isinstance(provenance, dict) and provenance.get("scope") == "full_stream":
+ exact_once_columns += 1
+
+ timings = result.get("timings_ms", {})
+ phase3 = timings.get("phase3_tasks", {}) if isinstance(timings, dict) else {}
+ return {
+ "profiled_columns_checked": len(columns),
+ "missing_count_exact_matches": missing_matches,
+ "numeric_count_exact_matches": count_matches,
+ "numeric_minmax_exact_matches": minmax_matches,
+ "profile_mean_max_abs_error": round(max(mean_errors), 6) if mean_errors else None,
+ "profile_mean_mean_abs_error": round(float(np.mean(mean_errors)), 6) if mean_errors else None,
+ "expected_projected_missing_percent": round(expected_health_missing, 6),
+ "health_missing_percent": round(observed_health_missing, 6),
+ "health_missing_abs_error": round(abs(observed_health_missing - expected_health_missing), 6),
+ "deep_numeric_columns_checked": len(numeric_stats),
+ "deep_exact_once_columns": exact_once_columns,
+ "deep_mean_max_abs_error": round(max(deep_mean_errors), 6) if deep_mean_errors else None,
+ "deep_mean_mean_abs_error": round(float(np.mean(deep_mean_errors)), 6) if deep_mean_errors else None,
+ "phase3_tasks_present": sorted(phase3) if isinstance(phase3, dict) else [],
+ "full_pipeline_sections_present": sorted(
+ key
+ for key in (
+ "profile",
+ "column_roles",
+ "health",
+ "ml_readiness",
+ "quality_diagnostics",
+ "advanced",
+ "deep_statistics_v2",
+ "anomalies_v2",
+ "time_series",
+ )
+ if key in result
+ ),
+ }
+
+
+def benchmark_dataset(path: Path, *, mode: str) -> dict[str, Any]:
+ parquet = pq.ParquetFile(path)
+ rows = int(parquet.metadata.num_rows)
+ columns = int(parquet.metadata.num_columns)
+
+ started = time.perf_counter()
+ result = fv.analyze(path, mode=mode, artifacts=False, workers=4)
+ analysis_seconds = time.perf_counter() - started
+
+ streaming = result.get("execution", {}).get("streaming", {})
+ profile = result.get("profile", {})
+ numeric_metadata = profile.get("numeric_summary_metadata", {})
+ streaming_metadata = profile.get("streaming_metadata", {})
+ return {
+ "benchmark_schema_version": 2,
+ "workload": {
+ "rows": rows,
+ "columns": columns,
+ "cells": rows * columns,
+ "file_size_mb": round(path.stat().st_size / 1_000_000, 3),
+ "dense_int16_raw_gb": round(rows * columns * 2 / 1_000_000_000, 3),
+ "mode": mode,
+ "source": "real_parquet_file",
+ },
+ "backend": {
+ "numeric_backend": streaming_metadata.get("numeric_backend"),
+ "numeric_method": (
+ numeric_metadata.get("method")
+ if isinstance(numeric_metadata, dict)
+ else None
+ ),
+ },
+ "analysis_seconds": round(analysis_seconds, 6),
+ "pipeline_timings_ms": result.get("timings_ms", {}),
+ "streaming_execution": streaming,
+ "accuracy": _accuracy_checks(result, rows=rows),
+ "safety": {
+ "full_materialization": streaming.get("full_materialization"),
+ "source_rows": streaming.get("source_rows"),
+ "source_columns": streaming.get("source_columns"),
+ "profiled_columns": streaming.get("profiled_columns"),
+ "working_sample_rows": streaming.get("working_sample_rows"),
+ "column_sampled": streaming.get("column_sampled"),
+ },
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--dataset", type=Path, default=Path("wide-100k-x-10k.parquet"))
+ parser.add_argument("--rows", type=int, default=DEFAULT_ROWS)
+ parser.add_argument("--columns", type=int, default=DEFAULT_COLUMNS)
+ parser.add_argument("--row-group-rows", type=int, default=10_000)
+ parser.add_argument("--mode", choices=VALID_MODES)
+ parser.add_argument("--generate-only", action="store_true")
+ parser.add_argument("--output", type=Path)
+ args = parser.parse_args()
+
+ if args.generate_only:
+ payload = generate_dataset(
+ args.dataset,
+ rows=args.rows,
+ columns=args.columns,
+ row_group_rows=args.row_group_rows,
+ )
+ else:
+ if not args.dataset.exists():
+ generate_dataset(
+ args.dataset,
+ rows=args.rows,
+ columns=args.columns,
+ row_group_rows=args.row_group_rows,
+ )
+ if args.mode is None:
+ parser.error("--mode is required unless --generate-only is used")
+ payload = benchmark_dataset(args.dataset, mode=args.mode)
+
+ encoded = json.dumps(payload, indent=2, sort_keys=True)
+ if args.output is not None:
+ args.output.write_text(encoded + "\n", encoding="utf-8")
+ print(encoded)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/benchmarks/performance_budgets.json b/benchmarks/performance_budgets.json
new file mode 100644
index 0000000..505bb40
--- /dev/null
+++ b/benchmarks/performance_budgets.json
@@ -0,0 +1,14 @@
+{
+ "schema_version": "1",
+ "notes": "Initial ceilings are intentionally generous. Tighten only after collecting stable hosted-run history.",
+ "scenarios": {
+ "core_profile_150k_x12": {
+ "max_seconds": 25.0,
+ "max_peak_rss_mb": 1200.0
+ },
+ "parquet_profile_150k_x12": {
+ "max_seconds": 30.0,
+ "max_peak_rss_mb": 1400.0
+ }
+ }
+}
diff --git a/benchmarks/performance_guardrail.py b/benchmarks/performance_guardrail.py
new file mode 100644
index 0000000..e81d5ed
--- /dev/null
+++ b/benchmarks/performance_guardrail.py
@@ -0,0 +1,198 @@
+"""Deterministic catastrophic-regression guardrails for FrameVitals profiling.
+
+These checks are deliberately broader than microbenchmarks. Hosted CI hardware is
+noisy, so the initial budgets only fail large time/RSS regressions. Raw measurements
+are written as JSON so the ceilings can be tightened later from observed history.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import resource
+import tempfile
+import time
+from pathlib import Path
+from typing import Any, Callable
+
+import numpy as np
+import pandas as pd
+
+import framevitals as fv
+
+
+ROWS = 150_000
+NUMERIC_COLUMNS = 8
+CATEGORICAL_COLUMNS = 4
+
+
+def _peak_rss_mb() -> float:
+ usage = float(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss)
+ # Linux reports KiB; macOS reports bytes. CI is Linux, but keep the script
+ # useful locally as well.
+ if os.uname().sysname.lower() == "darwin":
+ return usage / (1024.0 * 1024.0)
+ return usage / 1024.0
+
+
+def _frame(rows: int = ROWS) -> pd.DataFrame:
+ rng = np.random.default_rng(42)
+ payload: dict[str, Any] = {}
+ for index in range(NUMERIC_COLUMNS):
+ values = rng.normal(loc=index, scale=1.0 + index / 10.0, size=rows)
+ values[::997] = np.nan
+ payload[f"value_{index}"] = values
+ for index in range(CATEGORICAL_COLUMNS):
+ payload[f"group_{index}"] = np.take(
+ np.array([f"g{index}-{item}" for item in range(12)], dtype=object),
+ np.arange(rows) % 12,
+ )
+ return pd.DataFrame(payload)
+
+
+def _measure(name: str, operation: Callable[[], dict[str, Any]]) -> dict[str, Any]:
+ before_rss = _peak_rss_mb()
+ started = time.perf_counter()
+ result = operation()
+ elapsed = time.perf_counter() - started
+ after_rss = _peak_rss_mb()
+ return {
+ "name": name,
+ "elapsed_seconds": round(float(elapsed), 6),
+ "peak_rss_mb": round(float(after_rss), 3),
+ "rss_growth_mb": round(max(0.0, float(after_rss - before_rss)), 3),
+ "result": result,
+ }
+
+
+def _core_profile() -> dict[str, Any]:
+ frame = _frame()
+ profile = fv.profile(frame)
+ assert profile["shape"] == {"rows": ROWS, "columns": NUMERIC_COLUMNS + CATEGORICAL_COLUMNS}
+ return {
+ "rows": ROWS,
+ "columns": NUMERIC_COLUMNS + CATEGORICAL_COLUMNS,
+ "diagnostic": profile.diagnostic,
+ }
+
+
+def _parquet_profile() -> dict[str, Any]:
+ try:
+ import pyarrow as pa
+ import pyarrow.parquet as pq
+ except ImportError as exc: # pragma: no cover - workflow installs arrow
+ raise RuntimeError(
+ 'Parquet guardrail requires: pip install "framevitals[arrow]"'
+ ) from exc
+
+ frame = _frame()
+ with tempfile.TemporaryDirectory(prefix="framevitals-perf-") as directory:
+ path = Path(directory) / "guardrail.parquet"
+ pq.write_table(
+ pa.Table.from_pandas(frame, preserve_index=False),
+ path,
+ row_group_size=8_192,
+ )
+ del frame
+ profile = fv.profile(path)
+
+ assert profile["shape"] == {"rows": ROWS, "columns": NUMERIC_COLUMNS + CATEGORICAL_COLUMNS}
+ streaming = profile.get("streaming_metadata", {})
+ assert streaming.get("enabled") is True
+ assert streaming.get("full_materialization") is False
+ return {
+ "rows": ROWS,
+ "columns": NUMERIC_COLUMNS + CATEGORICAL_COLUMNS,
+ "diagnostic": profile.diagnostic,
+ "streaming": True,
+ }
+
+
+SCENARIOS: dict[str, Callable[[], dict[str, Any]]] = {
+ "core_profile_150k_x12": _core_profile,
+ "parquet_profile_150k_x12": _parquet_profile,
+}
+
+
+def _load_budgets(path: Path) -> dict[str, dict[str, float]]:
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ if payload.get("schema_version") != "1":
+ raise ValueError("Unsupported performance budget schema version.")
+ scenarios = payload.get("scenarios")
+ if not isinstance(scenarios, dict):
+ raise ValueError("Performance budget file is missing scenarios.")
+ return scenarios
+
+
+def run_guardrails(budget_path: Path) -> dict[str, Any]:
+ budgets = _load_budgets(budget_path)
+ measurements: list[dict[str, Any]] = []
+ failures: list[dict[str, Any]] = []
+
+ for name, operation in SCENARIOS.items():
+ if name not in budgets:
+ raise ValueError(f"Missing performance budget for scenario: {name}")
+ budget = budgets[name]
+ measurement = _measure(name, operation)
+ max_seconds = float(budget["max_seconds"])
+ max_peak_rss_mb = float(budget["max_peak_rss_mb"])
+ measurement["budget"] = {
+ "max_seconds": max_seconds,
+ "max_peak_rss_mb": max_peak_rss_mb,
+ }
+ measurement["passed"] = (
+ measurement["elapsed_seconds"] <= max_seconds
+ and measurement["peak_rss_mb"] <= max_peak_rss_mb
+ )
+ measurements.append(measurement)
+
+ if measurement["elapsed_seconds"] > max_seconds:
+ failures.append({
+ "scenario": name,
+ "metric": "elapsed_seconds",
+ "observed": measurement["elapsed_seconds"],
+ "budget": max_seconds,
+ })
+ if measurement["peak_rss_mb"] > max_peak_rss_mb:
+ failures.append({
+ "scenario": name,
+ "metric": "peak_rss_mb",
+ "observed": measurement["peak_rss_mb"],
+ "budget": max_peak_rss_mb,
+ })
+
+ return {
+ "schema_version": "1",
+ "rows": ROWS,
+ "scenario_count": len(measurements),
+ "passed": not failures,
+ "measurements": measurements,
+ "failures": failures,
+ }
+
+
+def _parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--budgets",
+ type=Path,
+ default=Path(__file__).with_name("performance_budgets.json"),
+ )
+ parser.add_argument("--output", type=Path, default=None)
+ return parser
+
+
+def main() -> int:
+ args = _parser().parse_args()
+ result = run_guardrails(args.budgets)
+ rendered = json.dumps(result, indent=2, default=str)
+ if args.output is not None:
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(rendered + "\n", encoding="utf-8")
+ print(rendered)
+ return 0 if result["passed"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/results/release_0.2.0_vs_0.1.0_10k_x64.json b/benchmarks/results/release_0.2.0_vs_0.1.0_10k_x64.json
new file mode 100644
index 0000000..3640920
--- /dev/null
+++ b/benchmarks/results/release_0.2.0_vs_0.1.0_10k_x64.json
@@ -0,0 +1,322 @@
+{
+ "benchmark_schema_version": 1,
+ "comparison": "FrameVitals 0.2.0 vs 0.1.0",
+ "old_ref": "v0.1.0@3da1432168fbfcb3dbe99fcfb6f6200f5e63214b",
+ "new_ref": "develop/august@05b11e594995a3833ec08f5b4a8a145197bf4cab",
+ "dataset": {
+ "rows": 10000,
+ "columns": 64,
+ "cells": 640000,
+ "format": "csv",
+ "bytes": 2811188,
+ "c000": {
+ "count": 10000,
+ "mean": -1.4965,
+ "min": -1000,
+ "max": 998
+ }
+ },
+ "methodology": {
+ "runner": "GitHub Actions ubuntu-latest",
+ "python": "3.11",
+ "warmups_per_version_mode": 1,
+ "measured_repetitions_per_version_mode": 3,
+ "measurement_order": "ABBAAB",
+ "public_api": "framevitals.analyze(path, mode=..., artifacts=False)",
+ "scope": "all 10,000 rows and all 64 columns in both releases",
+ "dependency_stack": {
+ "numpy": "1.26.0",
+ "pandas": "2.2.0",
+ "scipy": "1.13.0",
+ "statsmodels": "0.14.0",
+ "scikit-learn": "1.5.0",
+ "pyarrow": "25.0.0"
+ }
+ },
+ "modes": {
+ "quick": {
+ "0.1.0": {
+ "wall_seconds": [
+ 1.6478449049999995,
+ 1.619613931999993,
+ 1.6437554540000008
+ ],
+ "median_wall_seconds": 1.6437554540000008,
+ "min_wall_seconds": 1.619613931999993,
+ "max_wall_seconds": 1.6478449049999995,
+ "peak_rss_mb": [
+ 234.6953125,
+ 234.5390625,
+ 234.6796875
+ ],
+ "median_peak_rss_mb": 234.6796875,
+ "backend": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "profiled_columns": 64,
+ "full_materialization": null
+ },
+ "0.2.0": {
+ "wall_seconds": [
+ 0.6861838410000018,
+ 0.6908588409999936,
+ 0.691870496000007
+ ],
+ "median_wall_seconds": 0.6908588409999936,
+ "min_wall_seconds": 0.6861838410000018,
+ "max_wall_seconds": 0.691870496000007,
+ "peak_rss_mb": [
+ 239.72265625,
+ 238.875,
+ 239.515625
+ ],
+ "median_peak_rss_mb": 239.515625,
+ "backend": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "profiled_columns": 64,
+ "full_materialization": null
+ },
+ "speedup_x": 2.3792927823297783,
+ "wall_time_reduction_percent": 57.970704260246166,
+ "peak_rss_reduction_percent": -2.0606544825060746
+ },
+ "standard": {
+ "0.1.0": {
+ "wall_seconds": [
+ 141.083503924,
+ 141.13010660999998,
+ 140.708747796
+ ],
+ "median_wall_seconds": 141.083503924,
+ "min_wall_seconds": 140.708747796,
+ "max_wall_seconds": 141.13010660999998,
+ "peak_rss_mb": [
+ 2737.171875,
+ 2736.421875,
+ 2736.7265625
+ ],
+ "median_peak_rss_mb": 2736.7265625,
+ "backend": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "profiled_columns": 64,
+ "full_materialization": null
+ },
+ "0.2.0": {
+ "wall_seconds": [
+ 0.8411333409999884,
+ 0.822897117000025,
+ 0.8394920780000348
+ ],
+ "median_wall_seconds": 0.8394920780000348,
+ "min_wall_seconds": 0.822897117000025,
+ "max_wall_seconds": 0.8411333409999884,
+ "peak_rss_mb": [
+ 265.33984375,
+ 264.4296875,
+ 264.8984375
+ ],
+ "median_peak_rss_mb": 264.8984375,
+ "backend": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "profiled_columns": 64,
+ "full_materialization": null
+ },
+ "speedup_x": 168.05817186519556,
+ "wall_time_reduction_percent": 99.40496794121852,
+ "peak_rss_reduction_percent": 90.32060998969457
+ }
+ },
+ "measurements": [
+ {
+ "version": "0.1.0",
+ "mode": "quick",
+ "elapsed_seconds": 1.6478449049999995,
+ "peak_rss_mb": 234.6953125,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.1.0",
+ "mode": "quick",
+ "elapsed_seconds": 1.619613931999993,
+ "peak_rss_mb": 234.5390625,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.1.0",
+ "mode": "quick",
+ "elapsed_seconds": 1.6437554540000008,
+ "peak_rss_mb": 234.6796875,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.2.0",
+ "mode": "quick",
+ "elapsed_seconds": 0.6861838410000018,
+ "peak_rss_mb": 239.72265625,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.2.0",
+ "mode": "quick",
+ "elapsed_seconds": 0.6908588409999936,
+ "peak_rss_mb": 238.875,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.2.0",
+ "mode": "quick",
+ "elapsed_seconds": 0.691870496000007,
+ "peak_rss_mb": 239.515625,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.1.0",
+ "mode": "standard",
+ "elapsed_seconds": 141.083503924,
+ "peak_rss_mb": 2737.171875,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.1.0",
+ "mode": "standard",
+ "elapsed_seconds": 141.13010660999998,
+ "peak_rss_mb": 2736.421875,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.1.0",
+ "mode": "standard",
+ "elapsed_seconds": 140.708747796,
+ "peak_rss_mb": 2736.7265625,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "legacy-python",
+ "native_available": false
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.2.0",
+ "mode": "standard",
+ "elapsed_seconds": 0.8411333409999884,
+ "peak_rss_mb": 265.33984375,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.2.0",
+ "mode": "standard",
+ "elapsed_seconds": 0.822897117000025,
+ "peak_rss_mb": 264.4296875,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "full_materialization": null
+ },
+ {
+ "version": "0.2.0",
+ "mode": "standard",
+ "elapsed_seconds": 0.8394920780000348,
+ "peak_rss_mb": 264.8984375,
+ "profiled_columns": 64,
+ "backend_status": {
+ "selected": "rust",
+ "native_available": true,
+ "environment_override": "rust",
+ "eligible": [
+ "numpy",
+ "rust"
+ ]
+ },
+ "full_materialization": null
+ }
+ ]
+}
diff --git a/benchmarks/results/release_0.2.0_vs_0.1.0_accuracy_10k_x64.json b/benchmarks/results/release_0.2.0_vs_0.1.0_accuracy_10k_x64.json
new file mode 100644
index 0000000..c4bd357
--- /dev/null
+++ b/benchmarks/results/release_0.2.0_vs_0.1.0_accuracy_10k_x64.json
@@ -0,0 +1,104 @@
+{
+ "benchmark_schema_version": 1,
+ "comparison": "FrameVitals 0.2.0 vs 0.1.0 same-dataset statistical accuracy",
+ "dataset": {
+ "bytes": 2811188,
+ "cells": 640000,
+ "columns": 64,
+ "format": "csv",
+ "generator": "((row * (col + 3) + col * 17) % 2001 - 1000).astype(int16)",
+ "rows": 10000,
+ "same_as_release_performance_run": 32010158292
+ },
+ "delta_new_minus_old": {
+ "max_exact_fact_absolute_error": 0.0,
+ "max_mean_absolute_error": 0.0,
+ "max_quantile_absolute_error": 4.196000000000026,
+ "max_shape_absolute_error": 0.0,
+ "max_std_absolute_error": 0.0,
+ "pearson_absolute_error": 0.0
+ },
+ "evidence_runs": {
+ "accuracy_full_legacy_and_initial_native_run": 32014979365,
+ "native_shape_corrected_run": 32015665811,
+ "same_dataset_performance_run": 32010158292
+ },
+ "methodology": {
+ "dependency_stack": {
+ "numpy": "1.26.0",
+ "pandas": "2.2.0",
+ "pyarrow": "25.0.0",
+ "scikit-learn": "1.5.0",
+ "scipy": "1.13.0",
+ "statsmodels": "0.14.0"
+ },
+ "notes": [
+ "0.1.0 output came from accuracy run 32014979365.",
+ "0.2.0 shape fields were reprobed in run 32015665811 because the initial extractor looked only in deep_statistics_v2 instead of the profile's full-stream M3/M4 fields.",
+ "The product engine source under test is develop/august@13e16791; temporary benchmark-only files did not modify FrameVitals engine code."
+ ],
+ "public_api": "framevitals.analyze(path, mode='standard', artifacts=False)",
+ "python": "3.11",
+ "runner": "GitHub Actions ubuntu-latest",
+ "scope": "five representative numeric columns plus Pearson correlation; full 10,000 x 64 Standard analysis in both releases",
+ "truth_source": "serialized CSV recomputed independently with pandas/scipy"
+ },
+ "new_ref": "develop/august@13e16791f6f3e1fee74ade79c68cbeaf60cee9dc",
+ "old_ref": "v0.1.0@3da1432168fbfcb3dbe99fcfb6f6200f5e63214b",
+ "quantile_error_context": {
+ "max_absolute_error_units": 4.196000000000026,
+ "max_error_percent_of_column_range": 0.21001001001001132,
+ "mean_absolute_error_units": 1.589200000000014,
+ "mean_error_percent_of_column_range": 0.07949557557557627,
+ "native_quantile_relative_accuracy_setting": 0.01,
+ "tracked_quantile_values": 15
+ },
+ "tracked_columns": ["c000", "c001", "c007", "c031", "c063"],
+ "truth_method": "independent pandas/scipy calculation from the serialized CSV",
+ "versions": {
+ "0.1.0": {
+ "backend": {"native_available": false, "selected": "legacy-python"},
+ "column_absolute_errors": {
+ "c000": {"count": 0.0, "kurtosis": 2.565117664943273e-07, "max": 0.0, "mean": 0.0004999999999999449, "median": 0.0, "min": 0.0, "missing": 0.0, "q25": 0.0, "q75": 0.0, "skewness": 9.411780260499463e-08, "std": 0.0004745087312585383},
+ "c001": {"count": 0.0, "kurtosis": 3.374129284861027e-07, "max": 0.0, "mean": 0.00040000000000001146, "median": 0.0, "min": 0.0, "missing": 0.0, "q25": 0.0, "q75": 0.0, "skewness": 3.7097656380637127e-07, "std": 0.0003787506919934458},
+ "c007": {"count": 0.0, "kurtosis": 4.12596387722175e-07, "max": 0.0, "mean": 0.0005000000000000004, "median": 0.0, "min": 0.0, "missing": 0.0, "q25": 0.0, "q75": 0.0, "skewness": 3.5892954760251804e-07, "std": 0.00033771722223718825},
+ "c031": {"count": 0.0, "kurtosis": 4.915813716088735e-07, "max": 0.0, "mean": 0.0005000000000000004, "median": 0.0, "min": 0.0, "missing": 0.0, "q25": 0.0, "q75": 0.0, "skewness": 2.2877938012566199e-07, "std": 5.640241624860209e-07},
+ "c063": {"count": 0.0, "kurtosis": 1.1517126541349398e-07, "max": 0.0, "mean": 0.0, "median": 0.0, "min": 0.0, "missing": 0.0, "q25": 0.0, "q75": 0.0, "skewness": 4.42799937819564e-07, "std": 6.505523697342142e-05}
+ },
+ "summary": {
+ "max_exact_fact_absolute_error": 0.0,
+ "max_mean_absolute_error": 0.0005000000000000004,
+ "max_quantile_absolute_error": 0.0,
+ "max_shape_absolute_error": 4.915813716088735e-07,
+ "max_std_absolute_error": 0.0004745087312585383,
+ "mean_mean_absolute_error": 0.00037999999999999146,
+ "mean_quantile_absolute_error": 0.0,
+ "mean_shape_absolute_error": 3.1088769516840826e-07,
+ "pearson_absolute_error": 0.00014689456555781744,
+ "shape_values_unavailable": 0
+ }
+ },
+ "0.2.0": {
+ "backend": {"eligible": ["numpy", "rust"], "environment_override": "rust", "native_available": true, "selected": "rust"},
+ "column_absolute_errors": {
+ "c000": {"count": 0.0, "kurtosis": 2.565117664943273e-07, "max": 0.0, "mean": 0.0004999999999999449, "median": 0.010000000000000009, "min": 0.0, "missing": 0.0, "q25": 4.196000000000026, "q75": 0.053999999999973625, "skewness": 9.411780260499463e-08, "std": 0.0004745087312585383},
+ "c001": {"count": 0.0, "kurtosis": 3.374129284861027e-07, "max": 0.0, "mean": 0.00040000000000001146, "median": 0.0, "min": 0.0, "missing": 0.0, "q25": 2.1960000000000264, "q75": 2.1960000000000264, "skewness": 3.7097656380637127e-07, "std": 0.0003787506919934458},
+ "c007": {"count": 0.0, "kurtosis": 4.12596387722175e-07, "max": 0.0, "mean": 0.0005000000000000004, "median": 0.5, "min": 0.0, "missing": 0.0, "q25": 1.4460000000000264, "q75": 2.4460000000000264, "skewness": 3.5892954760251804e-07, "std": 0.00033771722223718825},
+ "c031": {"count": 0.0, "kurtosis": 4.915813716088735e-07, "max": 0.0, "mean": 0.0005000000000000004, "median": 0.5, "min": 0.0, "missing": 0.0, "q25": 1.4460000000000264, "q75": 2.4460000000000264, "skewness": 2.2877938012566199e-07, "std": 5.640241624860209e-07},
+ "c063": {"count": 0.0, "kurtosis": 1.1517126541349398e-07, "max": 0.0, "mean": 0.0, "median": 0.010000000000000009, "min": 0.0, "missing": 0.0, "q25": 4.196000000000026, "q75": 2.1960000000000264, "skewness": 4.42799937819564e-07, "std": 6.505523697342142e-05}
+ },
+ "summary": {
+ "max_exact_fact_absolute_error": 0.0,
+ "max_mean_absolute_error": 0.0005000000000000004,
+ "max_quantile_absolute_error": 4.196000000000026,
+ "max_shape_absolute_error": 4.915813716088735e-07,
+ "max_std_absolute_error": 0.0004745087312585383,
+ "mean_mean_absolute_error": 0.00037999999999999146,
+ "mean_quantile_absolute_error": 1.589200000000014,
+ "mean_shape_absolute_error": 3.1088769516840826e-07,
+ "pearson_absolute_error": 0.00014689456555781744,
+ "shape_values_unavailable": 0
+ }
+ }
+ }
+}
diff --git a/docs/architecture-proposal/BENCHMARK_ACCEPTANCE_PLAN.md b/docs/architecture-proposal/BENCHMARK_ACCEPTANCE_PLAN.md
new file mode 100644
index 0000000..f5c153d
--- /dev/null
+++ b/docs/architecture-proposal/BENCHMARK_ACCEPTANCE_PLAN.md
@@ -0,0 +1,203 @@
+# FrameVitals Benchmark and Acceptance Plan
+
+A comprehensive analysis library needs explicit gates for performance and correctness. This document proposes how new modules should be evaluated before they enter standard/deep/exhaustive presets.
+
+## Why this matters
+
+FrameVitals should not equate “more analysis” with “better analysis.” A new method can be valuable only if it:
+- produces useful additional evidence
+- behaves correctly on edge cases
+- has predictable resource cost
+- degrades gracefully
+- does not make ordinary analysis disproportionately slower
+
+## Benchmark dimensions
+
+Each candidate analysis should be measured across:
+
+### Correctness
+- expected findings on golden datasets
+- no false structural assumptions on unsupported data
+- stable JSON/schema output
+- deterministic results when seeded
+- correct handling of missing/infinite/mixed values
+
+### Runtime
+- stage wall time
+- percentage of total analysis time
+- cold vs warm/cache-aware time where relevant
+
+### Memory
+- peak resident memory where measurable
+- temporary dataframe/array copies
+- model/runtime memory for optional ML/DL features
+
+### Scalability
+- tall datasets
+- wide datasets
+- high-cardinality categorical data
+- long text columns
+- large numeric matrices
+
+### Reproducibility
+- package version
+- config hash
+- random seed
+- backend/runtime
+- optional pack versions
+- model ID/version/checksum
+
+## Proposed benchmark dataset families
+
+Synthetic/golden fixtures should cover at least:
+
+1. **Clean mixed tabular** — representative numeric + categorical + date columns.
+2. **Missingness-heavy** — MCAR-like, patterned, and column-dependent missingness.
+3. **Duplicate/key issues** — duplicate rows, duplicate candidate IDs, conflicting records.
+4. **High cardinality** — IDs, codes, near-unique categoricals.
+5. **Outlier/anomaly** — univariate and multivariate injected anomalies.
+6. **Drift numeric** — mean/variance/tail/shape changes.
+7. **Drift categorical** — frequency shifts, new/missing categories.
+8. **Target classification** — balanced and imbalanced labels, leakage and proxy leakage.
+9. **Target regression** — nonlinear and linear signal, leakage cases.
+10. **Time series** — regular/irregular timestamps, trend, seasonality, gaps, anomalies.
+11. **Text** — short categories, long text, duplicates, semantic shift where model packs are tested.
+12. **Adversarial dtype** — numeric-looking strings, mixed dates, booleans, infinities, malformed input.
+13. **Wide** — hundreds/thousands of columns with modest rows.
+14. **Tall** — large row counts with a small/medium number of columns.
+
+## Standard size classes
+
+Use named classes rather than hardcoding expectations around one machine:
+
+| Class | Example purpose |
+|---|---|
+| Tiny | unit/smoke behavior |
+| Small | interactive notebook/CLI usage |
+| Medium | common local analytics workload |
+| Large | sampling/streaming/backend strategy validation |
+| Wide | column-scaling validation |
+
+Exact row/column counts should be defined in the benchmark suite and can evolve as performance improves.
+
+## Per-analysis cost classes
+
+Every analysis registry entry should declare an expected cost class:
+
+- `tiny` — metadata/simple cached values
+- `low` — one lightweight vectorized pass
+- `medium` — correlations/statistical tests/model-like operations
+- `high` — ensemble, large pairwise relationships, expensive transforms
+- `very_high` — deep models, embeddings, exhaustive pairwise/search procedures
+
+The planner uses this declaration together with observed dimensions and configuration budgets.
+
+## Default-preset admission rules
+
+### Quick
+An analysis belongs in `quick` only if it is:
+- highly reliable
+- broadly applicable
+- low cost
+- useful without extensive interpretation
+
+### Standard
+An analysis belongs in `standard` when:
+- value is high for ordinary datasets
+- cost is bounded/predictable
+- it has strong fallback behavior
+
+### Deep
+An analysis belongs in `deep` when:
+- it adds meaningful evidence beyond standard
+- higher cost is justified
+- output is still interpretable
+
+### Exhaustive
+An analysis can be available in `exhaustive` when:
+- applicability is explicit
+- resource estimates exist
+- optional requirements are declared
+- failures can be isolated
+
+Being present in the codebase does not automatically qualify an analysis for any preset.
+
+## Regression policy
+
+Performance changes should be reviewed at the stage level rather than only using total runtime.
+
+Flag changes when:
+- an existing stage becomes materially slower without an intentional tradeoff
+- memory copies increase unexpectedly
+- a default preset begins running a previously optional expensive module
+- cache/context reuse regresses
+
+Do not enforce overly tight microbenchmark thresholds in noisy CI environments. Prefer broad regression bands, repeated samples, and trend reporting.
+
+## Model acceptance rules
+
+An optional ML/DL diagnostic should have a stronger acceptance bar.
+
+Before inclusion:
+1. define exactly which analytical question it answers
+2. compare against simpler deterministic/classical baselines
+3. measure incremental detection/value, not only model metrics
+4. measure CPU runtime and memory
+5. document GPU behavior if supported
+6. test deterministic seeds where possible
+7. document failure/fallback behavior
+8. verify model/runtime license compatibility
+9. record model/version metadata in results
+
+A deep model should not be enabled in the standard preset simply because it performs slightly better on one benchmark.
+
+## Report-quality tests
+
+Generated output is part of correctness.
+
+Test:
+- JSON serialization
+- HTML contains required sections
+- no missing/invalid links/assets for self-contained reports
+- terminal renderer handles narrow/non-color terminals
+- privacy mode does not expose raw sensitive examples
+- report generation does not re-run expensive analysis
+
+## Backend parity
+
+As Polars/PyArrow/Narwhals support grows, parity tests should focus on **semantic equivalence**, not byte-for-byte output.
+
+Examples:
+- same column semantic roles
+- same missing/duplicate counts
+- same contract verdicts
+- same drift severity class within numerical tolerance
+- same findings where algorithms are equivalent
+
+Backend-specific optimized approximations should clearly record their method.
+
+## Suggested benchmark command surface
+
+Future developer tooling could expose:
+
+```bash
+python -m benchmarks.run --suite core
+python -m benchmarks.run --suite drift
+python -m benchmarks.run --suite large
+python -m benchmarks.compare baseline.json candidate.json
+```
+
+A user-facing `framevitals benchmark` command is not necessary initially; these are primarily development/release tools.
+
+## Release gate summary
+
+Before a significant analysis capability moves into a default preset:
+- correctness fixtures pass
+- serialization/report tests pass
+- runtime/memory impact is understood
+- fallback behavior is tested
+- applicability is registered
+- documentation explains the result
+- no silent install/download behavior exists
+
+This keeps “complete and exhaustive” compatible with “fast and trustworthy.”
diff --git a/docs/architecture-proposal/CAPABILITY_PACKS.md b/docs/architecture-proposal/CAPABILITY_PACKS.md
new file mode 100644
index 0000000..820d2d9
--- /dev/null
+++ b/docs/architecture-proposal/CAPABILITY_PACKS.md
@@ -0,0 +1,328 @@
+# FrameVitals Capability Pack Specification
+
+This document defines how optional capabilities can grow without turning the base install into a huge dependency bundle.
+
+## Design goals
+
+- The base `framevitals` install must remain useful by itself.
+- Optional packs should add clearly defined capabilities.
+- Installation and enablement are separate states.
+- Model weights should not be bundled into PyPI wheels unless tiny and justified.
+- Deep-learning frameworks must stay optional.
+- Every optional capability must fail gracefully and preserve deterministic fallbacks when possible.
+
+## Proposed packs
+
+### `core`
+Installed by default.
+
+Scope:
+- CSV/TSV/basic tabular loading
+- structural profiling
+- data quality
+- health score
+- core statistics
+- contracts/validation
+- drift basics
+- result schema
+- standard CLI
+
+Goal: a strong data-analysis/data-health library even with no extras.
+
+---
+
+### `viz`
+Purpose: rendering and rich human-facing reports.
+
+Potential contents:
+- plotting dependencies
+- standalone HTML report helpers
+- PDF/chart rendering
+- notebook visual components
+
+CLI/TUI label: **Visual Reports**
+
+---
+
+### `excel`
+Purpose: XLS/XLSX ingestion.
+
+Potential contents:
+- `openpyxl`
+- `xlrd` where still required
+
+CLI/TUI label: **Excel Support**
+
+---
+
+### `ml`
+Purpose: classical ML-based diagnostics.
+
+Potential capabilities:
+- baseline models
+- feature importance
+- model diagnostics
+- Isolation Forest
+- LOF
+- ECOD/COPOD/HBOS or equivalent anomaly methods
+- optional gradient boosting diagnostics
+- optional SHAP-style explanation layer
+
+This pack is diagnostic, not a general AutoML system.
+
+CLI/TUI label: **ML Diagnostics**
+
+---
+
+### `deep`
+Purpose: optional nonlinear/deep diagnostics where they add real value.
+
+Potential capabilities:
+- MLP autoencoder anomaly detector
+- DeepSVDD-style anomaly detector
+- variational autoencoder experiment path
+- small temporal CNN/TCN models
+- optional tabular nonlinear baseline
+
+Framework policy:
+- do not require a deep-learning framework in the core package
+- choose one supported runtime initially rather than supporting everything
+- CPU must remain supported for small models where practical
+- GPU use should be opt-in/auto-detected and never required for ordinary FrameVitals analysis
+
+CLI/TUI label: **Deep Models**
+
+---
+
+### `text`
+Purpose: advanced text and semantic diagnostics.
+
+Potential capabilities:
+- embedding-based text drift
+- semantic similarity/grouping
+- advanced PII helpers
+- language detection
+- text quality/duplication signals
+
+Large embedding weights should be managed by the model registry, not bundled directly into the wheel.
+
+CLI/TUI label: **Advanced Text / NLP**
+
+---
+
+### `polars`
+Purpose: Polars input/backend support.
+
+Potential capabilities:
+- DataFrame/LazyFrame adapters
+- projection/predicate-aware analysis paths where practical
+- backend-native operations for large data
+
+CLI/TUI label: **Polars Backend**
+
+---
+
+### `arrow`
+Purpose: PyArrow and columnar I/O.
+
+Potential capabilities:
+- Arrow Table/RecordBatch support
+- Parquet scans
+- batch/streaming analysis paths
+- projection/filter support
+
+CLI/TUI label: **Arrow / Parquet Backend**
+
+---
+
+### `sql`
+Purpose: database/query-backed analysis.
+
+Potential capabilities:
+- DuckDB adapter
+- SQLAlchemy-compatible sources where useful
+- pushdown of lightweight aggregates/projections
+
+The initial goal should be analysis of query results and large local files rather than becoming a database management tool.
+
+CLI/TUI label: **SQL Adapters**
+
+---
+
+### `cloud`
+Purpose: remote object/file storage integration.
+
+Potential capabilities:
+- fsspec-based inputs
+- S3-compatible paths
+- selected cloud filesystem adapters
+
+Credentials must be supplied by the user's environment/provider tooling; FrameVitals should not invent its own secret store.
+
+CLI/TUI label: **Cloud Filesystems**
+
+---
+
+### `tui`
+Purpose: interactive terminal application if kept separate from core.
+
+Potential contents:
+- terminal UI framework
+- interactive tables/forms/progress views
+
+If the dependency footprint is small enough, this pack may eventually become part of the default install. Until then, the standard argparse CLI remains available without it.
+
+CLI/TUI label: **Interactive Terminal UI**
+
+---
+
+### `ai`
+Purpose: optional natural-language interpretation/agent features.
+
+Rules:
+- deterministic analysis remains the source of factual metrics
+- AI summarizes/interprets structured findings
+- no AI dependency required for normal analysis
+- provider-specific behavior stays isolated
+
+CLI/TUI label: **AI Interpretation**
+
+## Example extras layout
+
+Conceptually:
+
+```toml
+[project.optional-dependencies]
+viz = ["..."]
+excel = ["..."]
+ml = ["..."]
+deep = ["..."]
+text = ["..."]
+polars = ["..."]
+arrow = ["..."]
+sql = ["..."]
+cloud = ["..."]
+tui = ["..."]
+ai = ["..."]
+```
+
+The exact dependency choices should be benchmarked and reviewed before modifying `pyproject.toml`.
+
+## Capability registry
+
+FrameVitals should expose capability metadata independently from the Python packaging implementation.
+
+Example conceptual record:
+
+```python
+Capability(
+ id="deep",
+ name="Deep Models",
+ installed=False,
+ enabled=False,
+ extra="deep",
+ provides=[
+ "anomaly.autoencoder",
+ "anomaly.deep_svdd",
+ "timeseries.tcn",
+ ],
+ resource_class="high",
+)
+```
+
+This registry powers:
+- `framevitals addons list`
+- TUI install/toggle screens
+- planner applicability checks
+- `framevitals doctor`
+- error messages when an optional analysis was explicitly requested but is unavailable
+
+## Installed vs enabled
+
+These states must not be conflated.
+
+| Installed | Enabled | Meaning |
+|---|---|---|
+| No | No | capability unavailable |
+| Yes | No | dependency exists but planner will not use it automatically |
+| Yes | Yes | planner may use capability when applicable |
+| No | Yes | invalid state; config should warn and treat as unavailable |
+
+Users should be able to keep a pack installed but disabled for speed/reproducibility.
+
+## Model registry
+
+Model files should have a separate lifecycle from Python packages.
+
+Suggested cache organization:
+
+```text
+/
+ models/
+ semantic-types-small/
+ 1.0.0/
+ model...
+ manifest.json
+ temporal-anomaly-tiny/
+ 1.0.0/
+ model...
+ manifest.json
+```
+
+Each manifest should include:
+- model ID/version
+- FrameVitals compatibility
+- task
+- framework/runtime
+- checksum
+- source URL
+- license
+- file sizes
+- expected input contract
+
+## Download policy
+
+Model download should occur only through explicit actions such as:
+
+```bash
+framevitals models install semantic-types-small
+```
+
+or the equivalent TUI **Download** button.
+
+Ordinary analysis can suggest a model but should not automatically download it.
+
+Example:
+
+```text
+Semantic type confidence is low for 3 columns.
+Optional model 'semantic-types-small' could provide a second opinion.
+[ Download & enable ] [ Ignore ]
+```
+
+Even there, user confirmation is required.
+
+## Reproducibility
+
+Every analysis result should record optional capability/model metadata when used:
+- pack ID/version
+- relevant dependency versions
+- model ID/version/checksum
+- backend
+- random seed
+- device (CPU/GPU)
+
+This makes deep/model-assisted diagnostics reproducible and explainable.
+
+## Dependency policy
+
+Before adding a package to any optional pack, evaluate:
+1. user value
+2. wheel size and install reliability
+3. Python/platform compatibility
+4. maintenance activity
+5. license compatibility
+6. startup/import cost
+7. whether FrameVitals can implement the needed behavior with an existing dependency instead
+
+A large dependency should not enter the base install merely because one analysis can use it.
diff --git a/docs/architecture-proposal/CLI_TUI_SPEC.md b/docs/architecture-proposal/CLI_TUI_SPEC.md
new file mode 100644
index 0000000..5cde425
--- /dev/null
+++ b/docs/architecture-proposal/CLI_TUI_SPEC.md
@@ -0,0 +1,326 @@
+# FrameVitals CLI / TUI Specification
+
+This document defines the intended command-line and interactive terminal experience for future FrameVitals releases.
+
+## Goals
+
+The CLI should satisfy two very different users without forcing either into the other's workflow:
+
+1. **Interactive user** — wants to explore configuration, install optional capabilities, select analyses, and inspect reports through a terminal UI.
+2. **Automation user** — wants stable commands, exit codes, JSON output, and no prompts for scripts/CI.
+
+The same underlying configuration and execution engine must power both experiences.
+
+## Entry behavior
+
+```bash
+framevitals
+```
+
+If an interactive TTY is available and the TUI capability is installed, open the FrameVitals terminal application.
+
+If the TUI is unavailable, show normal CLI help and a short message explaining how to install the interactive capability.
+
+Explicit subcommands must never unexpectedly open the TUI:
+
+```bash
+framevitals analyze data.csv
+framevitals compare reference.csv current.csv
+framevitals validate data.csv --contract contract.json
+```
+
+## Main navigation
+
+```text
+FrameVitals
+
+ Analyze Dataset
+ Compare Datasets
+ Validate Contract
+ Reports & Snapshots
+ Configuration
+ Add-ons
+ Models
+ Doctor
+ Help
+ Exit
+```
+
+## Analysis screen
+
+```text
+Dataset: /Users/me/data/customers.csv
+Target: churn
+
+Preset
+( ) Quick (*) Standard ( ) Deep ( ) Exhaustive ( ) Custom
+
+Analysis categories
+[x] Structural profile
+[x] Data quality
+[x] Statistics
+[x] Relationships
+[x] ML readiness
+[x] Target intelligence
+[x] Drift / comparison auto when reference exists
+[x] Time-series analysis auto when applicable
+[x] Text analysis auto when applicable
+[x] Privacy / PII checks
+[ ] Deep models optional pack
+[ ] AI interpretation optional pack
+
+Resources
+Backend Auto
+Workers 4
+Memory limit 4 GB
+Time limit 120 s
+GPU Auto
+Sampling Adaptive
+
+[ Show Plan ] [ Run Analysis ] [ Save Profile ]
+```
+
+## Plan screen
+
+Before expensive runs, users should be able to see what FrameVitals intends to execute.
+
+```text
+Execution Plan
+
+RUN structural_profile essential ~low
+RUN missingness_analysis essential ~low
+RUN relationship_matrix high ~medium
+RUN anomaly_ensemble high ~medium
+SKIP tcn_temporal_anomaly not installed
+SKIP text_embeddings no applicable text column
+SKIP target_leakage no target selected
+
+Estimated class: medium
+Expected peak memory: < 2 GB
+
+[ Run ] [ Configure ] [ Back ]
+```
+
+Every skipped item should have an explicit reason.
+
+## Add-on manager
+
+Optional functionality must be easy to discover and install from the TUI.
+
+```text
+Add-ons
+
+Core
+[x] Core analysis Built in
+[x] Contracts Built in
+
+Optional
+[x] Visual reports Installed Enabled
+[ ] Excel support Not installed [ Install ]
+[x] ML diagnostics Installed Enabled
+[ ] Deep models Not installed [ Install ]
+[ ] Advanced text/NLP Not installed [ Install ]
+[ ] Polars backend Not installed [ Install ]
+[ ] Arrow backend Not installed [ Install ]
+[ ] SQL adapters Not installed [ Install ]
+[ ] Cloud filesystems Not installed [ Install ]
+
+Space Toggle enabled state
+Enter Install / Open details
+R Remove
+```
+
+### Installation flow
+
+Selecting `Install` should show exactly what will happen:
+
+```text
+Install: Deep Models
+
+Provides
+- Autoencoder anomaly detector
+- DeepSVDD anomaly detector
+- Optional temporal CNN/TCN diagnostics
+
+Python packages to install
+ framevitals[deep]
+
+Estimated package download: 220 MB
+Model downloads: none until a model is explicitly selected
+
+Command
+ /path/to/python -m pip install "framevitals[deep]"
+
+[ Install ] [ Cancel ]
+```
+
+Rules:
+- Never install implicitly during `import framevitals`.
+- Never install from a normal non-interactive analysis command.
+- Interactive install requires confirmation.
+- Show which Python environment will be modified.
+- Display installation errors instead of hiding them.
+- Allow user to copy the command and run it manually.
+
+## Model manager
+
+Python add-ons and model weights are different resources and should be managed separately.
+
+```text
+Models
+
+Semantic Type Classifier
+[ ] semantic-types-small 24 MB [ Download ]
+
+Text
+[ ] text-embeddings-small 90 MB [ Download ]
+
+Time Series
+[ ] temporal-anomaly-tiny 18 MB [ Download ]
+
+Anomaly
+[ ] tabular-ae-reference 12 MB [ Download ]
+```
+
+Model registry metadata should include:
+- model ID
+- model version
+- compatible FrameVitals versions
+- task
+- file size
+- checksum
+- license
+- source
+- framework/runtime requirement
+- expected hardware
+
+Downloaded weights should be cached in an OS-appropriate FrameVitals data directory, not in the working project directory.
+
+## Configuration screen
+
+Configuration should be editable interactively but stored in a human-readable file.
+
+Suggested conceptual config:
+
+```toml
+[analysis]
+preset = "standard"
+statistics = true
+relationships = true
+ml_readiness = true
+deep_models = false
+ai_interpretation = false
+
+[resources]
+workers = 4
+max_memory = "4GB"
+max_time_seconds = 120
+gpu = "auto"
+sampling = "adaptive"
+
+[reporting]
+terminal = true
+html = false
+json = false
+
+[privacy]
+pii_detection = true
+show_raw_sensitive_examples = false
+```
+
+## Configuration precedence
+
+Highest priority wins:
+
+1. Explicit Python arguments / command flags
+2. Project configuration (`framevitals.toml`)
+3. User configuration
+4. Environment variables
+5. Selected preset
+6. Built-in defaults
+
+`framevitals config explain` should show where each effective value came from.
+
+## Scriptable command design
+
+Interactive actions should have deterministic command equivalents.
+
+```bash
+# analysis
+framevitals analyze data.csv --preset deep
+framevitals analyze data.csv --target churn --html report.html --json report.json
+framevitals plan data.csv --preset exhaustive --explain
+
+# configuration
+framevitals config show
+framevitals config set analysis.deep_models true
+framevitals config profile use laptop
+framevitals config explain
+
+# add-ons
+framevitals addons list
+framevitals addons install deep
+framevitals addons enable deep
+framevitals addons disable deep
+framevitals addons remove deep
+
+# models
+framevitals models list
+framevitals models install semantic-types-small
+framevitals models remove semantic-types-small
+
+# environment diagnostics
+framevitals doctor
+framevitals doctor --json
+```
+
+## Exit codes
+
+Suggested stable policy:
+- `0` — command completed / validation passed
+- `1` — validation or configured quality gate failed
+- `2` — invalid arguments/configuration
+- `3` — input/data loading failure
+- `4` — optional capability unavailable
+- `5` — internal execution failure
+
+Exact values can change before stabilization, but the policy should become documented and tested before 1.0.
+
+## Non-interactive safety
+
+When stdin/stdout is not interactive:
+- never prompt
+- never install packages
+- never download models unless an explicit install command was invoked
+- emit deterministic output
+- respect `--json`/`--quiet`
+- use stable exit codes
+
+## Doctor screen
+
+`framevitals doctor` should answer common environment questions:
+
+```text
+FrameVitals 0.x
+Python 3.13.7
+Platform macOS arm64
+Core OK
+ML pack Installed
+Deep pack Missing
+Polars Missing
+PyArrow Installed
+GPU runtime Not detected
+Cache /Users/me/Library/Caches/framevitals
+Config ~/.config/framevitals/config.toml
+
+No blocking problems detected.
+```
+
+## UX principle
+
+The TUI should hide accidental complexity, not analytical detail. Users should be able to make common decisions with a toggle or button while still being able to inspect:
+- exactly what analysis is being run
+- why it was selected
+- what it costs
+- which optional dependency/model it uses
+- what command/change an install button performs
diff --git a/docs/architecture-proposal/DECISIONS.md b/docs/architecture-proposal/DECISIONS.md
new file mode 100644
index 0000000..396b0a3
--- /dev/null
+++ b/docs/architecture-proposal/DECISIONS.md
@@ -0,0 +1,136 @@
+# FrameVitals Architecture Decisions
+
+This file records decisions that were explicitly accepted during architecture planning so future implementation does not repeatedly reopen the same product questions without new evidence.
+
+These are direction-setting principles, not a frozen public API contract. Current package behavior and stability guarantees are defined by the maintained code, tests, and release documentation.
+
+## D-001 — Exhaustive capability, selective execution
+
+**Status:** Accepted
+
+FrameVitals may aim for a very broad and eventually exhaustive catalog of tabular-data diagnostics. A normal analysis must not run everything blindly.
+
+Default execution is selected by:
+- data applicability
+- preset/configuration
+- installed and enabled capabilities
+- resource budget
+- analysis dependencies
+
+`exhaustive` means every applicable installed analysis permitted by the active resource policy.
+
+---
+
+## D-002 — Interactive terminal UI plus scriptable CLI
+
+**Status:** Accepted direction
+
+Running `framevitals` with no explicit subcommand may eventually open an interactive terminal application when that capability is installed.
+
+Explicit commands such as `framevitals analyze`, `compare`, and `validate` remain deterministic and suitable for scripts/CI.
+
+The TUI and CLI should share the same configuration and execution engine.
+
+---
+
+## D-003 — Optional features use install actions/toggles
+
+**Status:** Accepted direction
+
+Optional capabilities should be discoverable rather than forcing users to research dependency groups manually.
+
+Any interactive installation action must still show the exact command/environment being modified and request confirmation.
+
+Installed and enabled are separate states.
+
+---
+
+## D-004 — No implicit package/model downloads
+
+**Status:** Accepted
+
+FrameVitals never installs Python packages or downloads model weights during normal import or ordinary analysis.
+
+Downloads occur only through explicit add-on/model installation actions or equivalent dedicated commands.
+
+---
+
+## D-005 — Deep learning is optional and task-specific
+
+**Status:** Accepted
+
+ML/DL is used only where it adds analytical value, for example:
+- nonlinear multivariate anomaly detection
+- temporal CNN/TCN diagnostics for ordered time-series windows
+- optional semantic-type second opinion
+- embedding-based text drift
+- nonlinear learnability diagnostics
+
+CNNs are not applied to generic unordered tabular rows merely because they are available.
+
+Deterministic/statistical fallbacks remain first-class.
+
+---
+
+## D-006 — FrameVitals is not an AutoML platform
+
+**Status:** Accepted
+
+Baseline models and nonlinear diagnostic models can measure learnability, leakage, feature behavior, or explainability. The product does not optimize around training/deploying a production model fleet.
+
+The central question remains: **what is true about this dataset, what is wrong, what changed, and what should the user investigate next?**
+
+---
+
+## D-007 — Result/report UX precedes algorithm count
+
+**Status:** Accepted
+
+Stable result objects/schema, normalized findings, full JSON output, good terminal rendering, and useful reports are higher priority than continuously adding isolated algorithms.
+
+New analysis is only valuable when users can consume and trust the result.
+
+---
+
+## D-008 — One reusable analysis context / exact-once facts
+
+**Status:** Accepted direction
+
+Modules should consume reusable facts from the execution context rather than independently rescanning the same dataset for missingness, dtypes, semantic roles, samples, moments, correlations, and related facts.
+
+When the source pass has already established an exact sufficient statistic, downstream diagnostics should reuse it rather than replace it with a bounded-sample estimate.
+
+---
+
+## D-009 — Cleaning is plan-first
+
+**Status:** Accepted direction
+
+FrameVitals may recommend/simulate cleaning operations. It should not silently mutate user data.
+
+Preferred workflow:
+1. detect issue
+2. propose cleaning action
+3. estimate/simulate impact when possible
+4. user explicitly applies approved transformations
+5. record an audit trail
+
+---
+
+## D-010 — Public project website is separate from analysis runtime
+
+**Status:** Accepted
+
+The public website is primarily for product explanation, demos, installation, documentation, and benchmark/transparency information.
+
+It does not need to become a dataset-upload service. The website and generated reports can share a coherent visual language while remaining separate products.
+
+---
+
+## D-011 — Planning material is preserved but not release contract
+
+**Status:** Accepted
+
+Architecture proposal material is preserved under `docs/architecture-proposal/` in the integration history. It should not be treated as the current release contract.
+
+Implementation changes still land through normal development/review and must earn their place through correctness, resource, compatibility, and benchmark evidence.
diff --git a/docs/architecture-proposal/IMPLEMENTATION_ROADMAP.md b/docs/architecture-proposal/IMPLEMENTATION_ROADMAP.md
new file mode 100644
index 0000000..2e75725
--- /dev/null
+++ b/docs/architecture-proposal/IMPLEMENTATION_ROADMAP.md
@@ -0,0 +1,274 @@
+# FrameVitals Implementation Roadmap
+
+This document turns the future architecture proposal into a practical sequence of pull requests. It is a planning reference, not a promise that every item ships in the named release.
+
+## Operating rule
+
+FrameVitals should become **exhaustive in capability and selective in execution**. New capabilities should be added behind stable interfaces, configuration, applicability checks, and resource budgets rather than wired directly into one ever-growing pipeline.
+
+## Build order
+
+### PR 1 — Result model foundation
+**Priority:** P0
+
+Create a stable internal/public result layer without breaking the current dictionary-returning API.
+
+Proposed modules:
+- `src/framevitals/result.py`
+- `src/framevitals/findings.py`
+- `src/framevitals/metadata.py`
+
+Deliverables:
+- `AnalysisResult` or an internal result object with mapping compatibility.
+- Normalized `Finding` structure: code, title, severity, confidence, evidence, affected columns, recommendation, method.
+- `to_dict()` and JSON-safe serialization.
+- Existing `fv.analyze()` output remains backward compatible during migration.
+
+Acceptance criteria:
+- Existing tests still pass.
+- Old dictionary access remains available.
+- Result schema receives an explicit version.
+
+---
+
+### PR 2 — Full report export and terminal renderer
+**Priority:** P0
+
+Fix the current gap between the rich Python result and the summary-only CLI output.
+
+Proposed modules:
+- `src/framevitals/reporting/terminal.py`
+- `src/framevitals/reporting/json.py`
+
+Deliverables:
+- Human-friendly terminal summary.
+- Full JSON export from CLI.
+- Clear distinction between terminal summary and machine-readable full result.
+- Stable exit behavior.
+
+Acceptance criteria:
+- `framevitals analyze data.csv --output report.json` can intentionally write the full report.
+- Terminal output is concise and does not dump enormous nested JSON by default.
+
+---
+
+### PR 3 — Standalone HTML report
+**Priority:** P0
+
+Proposed modules:
+- `src/framevitals/reporting/html.py`
+- `src/framevitals/reporting/assets.py`
+
+Deliverables:
+- Self-contained HTML report requiring no server.
+- Overview, health, findings, missingness, column profiles, relationships, anomalies, target intelligence, drift when available, and recommendations.
+- Deterministic report generation from an existing result object.
+
+Acceptance criteria:
+- Report works offline in a browser.
+- No user dataset is uploaded anywhere.
+- HTML generation does not re-run analysis.
+
+---
+
+### PR 4 — Configuration and presets
+**Priority:** P0
+
+Proposed modules:
+- `src/framevitals/config.py`
+- `src/framevitals/config_presets.py`
+- `src/framevitals/config_profiles.py`
+
+Deliverables:
+- Typed configuration object.
+- Presets: `quick`, `standard`, `deep`, `exhaustive`, `custom`.
+- Resource policy: workers, memory budget, time budget, sampling policy, GPU preference.
+- Analysis-category toggles.
+- Config precedence rules.
+
+Suggested precedence:
+1. explicit Python/CLI arguments
+2. project config
+3. user config
+4. environment variables
+5. preset defaults
+
+Acceptance criteria:
+- Same config can be used from Python and CLI.
+- Config validation produces useful errors.
+
+---
+
+### PR 5 — AnalysisContext and reusable cache
+**Priority:** P0
+
+Proposed modules:
+- `src/framevitals/execution/context.py`
+- `src/framevitals/execution/cache.py`
+- `src/framevitals/execution/sampling.py`
+
+Deliverables:
+- One context per run containing loaded data/backend, shape, profile, roles, fingerprints, reusable samples, configuration, seed, installed capabilities, and cached intermediates.
+- Modules request reusable facts from the context rather than rescanning the dataset.
+
+Acceptance criteria:
+- Current outputs remain equivalent for deterministic analyses.
+- Benchmarks demonstrate fewer full-data scans on representative datasets.
+
+---
+
+### PR 6 — Planner-controlled execution
+**Priority:** P0
+
+Evolve `analysis_inventory.py` + `analysis_selector.py` into a real execution planner.
+
+Proposed modules:
+- `src/framevitals/planner.py`
+- `src/framevitals/execution/budget.py`
+
+Deliverables:
+- Registry entries declare inputs, applicability, cost, dependencies, outputs, optional pack, and resource class.
+- Planner produces an explainable execution plan.
+- `framevitals plan data.csv --explain` shows what will run and why.
+- Exhaustive mode means every applicable installed analysis that fits the configured policy.
+
+Acceptance criteria:
+- The planner actually controls execution rather than only describing it.
+- Skipped analyses record a reason.
+
+---
+
+### PR 7 — Interactive CLI/TUI foundation
+**Priority:** P1
+
+Proposed modules:
+- `src/framevitals/cli_tui.py`
+- `src/framevitals/doctor.py`
+
+Deliverables:
+- Running `framevitals` without a subcommand opens the interactive interface when the TUI is available.
+- Analyze, Compare, Validate, Reports, Configuration, Add-ons, Models, Doctor.
+- Traditional subcommands remain fully usable for scripts and CI.
+
+Acceptance criteria:
+- No TUI dependency is imported during a normal library import.
+- Non-interactive behavior remains stable.
+
+---
+
+### PR 8 — Add-on manager and capability registry
+**Priority:** P1
+
+Proposed modules:
+- `src/framevitals/addons.py`
+- `src/framevitals/capabilities.py`
+- `src/framevitals/models/registry.py`
+
+Deliverables:
+- Installed/enabled are separate states.
+- Discover whether optional dependencies are installed.
+- TUI install/remove/toggle actions.
+- Scriptable equivalents: `addons list/install/remove/enable/disable`.
+- Exact command displayed before modifying the active environment.
+
+Acceptance criteria:
+- Importing FrameVitals never installs anything.
+- Installation requires explicit confirmation in interactive mode.
+
+---
+
+### PR 9 — Semantic types and deeper profiling
+**Priority:** P1
+
+Proposed modules:
+- `src/framevitals/semantic_types.py`
+- `src/framevitals/quality/keys.py`
+- `src/framevitals/quality/missingness.py`
+
+Deliverables:
+- Stronger semantic types: ID, UUID, email, URL, IP, phone-like, currency, percentage, geospatial-like, date/time, free text, code/category.
+- Candidate primary keys and composite-key hints.
+- Missingness patterns and co-occurrence.
+- Categorical normalization suggestions.
+- Optional small character model can later act as a second opinion when rule confidence is low.
+
+---
+
+### PR 10 — Target intelligence and relationship engine
+**Priority:** P1
+
+Proposed modules:
+- `src/framevitals/target_intelligence.py`
+- `src/framevitals/relationships.py`
+- `src/framevitals/ml/label_quality.py`
+
+Deliverables:
+- Target/task inference.
+- Class imbalance and target quality.
+- Proxy, temporal, and split leakage.
+- Numeric↔numeric, categorical↔categorical, numeric↔categorical relationships.
+- Feature stability/redundancy signals.
+
+Optional model support:
+- nonlinear baseline as a diagnostic, not AutoML.
+
+---
+
+### PR 11 — Drift, snapshots, and monitoring foundation
+**Priority:** P1
+
+Proposed modules:
+- `src/framevitals/drift/`
+- `src/framevitals/monitoring/snapshot.py`
+- `src/framevitals/monitoring/store.py`
+
+Deliverables:
+- Pluggable drift methods and automatic method selection.
+- Drift severity and confidence/evidence.
+- Schema diff.
+- Result snapshots and local history.
+- Compare current result against a baseline snapshot.
+
+---
+
+### PR 12 — Scale/backends and first optional model pack
+**Priority:** P2
+
+Proposed modules:
+- `src/framevitals/backends/`
+- `src/framevitals/io/`
+- `src/framevitals/models/deep_anomaly.py`
+- `src/framevitals/models/runtime.py`
+
+Deliverables:
+- Polars/PyArrow/Narwhals strategy.
+- Parquet and chunked/streaming paths.
+- Large-data sampling/budget behavior.
+- Optional anomaly pack: autoencoder and/or DeepSVDD alongside classical detectors.
+- Optional GPU selection only when useful and installed.
+
+Acceptance criteria:
+- Core install stays usable without deep-learning frameworks.
+- Model pack failure never removes deterministic fallback analysis.
+
+## Parallel workstreams
+
+The public project website can be developed in parallel because it should initially use curated/precomputed demo data rather than depend on the full backend roadmap.
+
+Recommended parallel streams:
+- **Core:** PR 1–6
+- **Experience:** PR 2–3, 7–8
+- **Analysis depth:** PR 9–11
+- **Scale/models:** PR 12+
+- **Website:** independent visual/demo frontend with shared report design language
+
+## Release philosophy
+
+Do not ship a release because the project gained a certain number of algorithms. Ship when a coherent user workflow becomes materially better.
+
+Examples:
+- result/report release
+- configuration/planner release
+- interactive/add-on release
+- monitoring release
+- scale/backend release
diff --git a/docs/architecture-proposal/PROPOSED_MODULE_TREE.md b/docs/architecture-proposal/PROPOSED_MODULE_TREE.md
new file mode 100644
index 0000000..0559b74
--- /dev/null
+++ b/docs/architecture-proposal/PROPOSED_MODULE_TREE.md
@@ -0,0 +1,256 @@
+# Proposed Future Module Tree
+
+This is a destination architecture, **not** a request to rename/move every current module immediately. Existing public imports should remain compatible while internal organization evolves gradually.
+
+```text
+src/framevitals/
+├── __init__.py
+├── api.py
+├── result.py
+├── findings.py
+├── metadata.py
+├── errors.py
+├── config.py
+├── config_presets.py
+├── config_profiles.py
+├── capabilities.py
+├── addons.py
+├── doctor.py
+│
+├── execution/
+│ ├── __init__.py
+│ ├── context.py
+│ ├── planner.py
+│ ├── inventory.py
+│ ├── budget.py
+│ ├── cache.py
+│ ├── sampling.py
+│ └── scheduler.py
+│
+├── io/
+│ ├── __init__.py
+│ ├── loader.py
+│ ├── csv.py
+│ ├── excel.py
+│ ├── json.py
+│ ├── parquet.py
+│ ├── sql.py
+│ └── cloud.py
+│
+├── backends/
+│ ├── __init__.py
+│ ├── base.py
+│ ├── pandas.py
+│ ├── polars.py
+│ ├── arrow.py
+│ └── narwhals_adapter.py
+│
+├── profiling/
+│ ├── __init__.py
+│ ├── structural.py
+│ ├── semantic_types.py
+│ ├── column_roles.py
+│ ├── dataset_signals.py
+│ ├── keys.py
+│ ├── missingness.py
+│ └── freshness.py
+│
+├── quality/
+│ ├── __init__.py
+│ ├── health.py
+│ ├── duplicates.py
+│ ├── consistency.py
+│ ├── ranges.py
+│ ├── categories.py
+│ └── pii.py
+│
+├── contracts/
+│ ├── __init__.py
+│ ├── infer.py
+│ ├── validate.py
+│ ├── rules.py
+│ ├── policy.py
+│ ├── diff.py
+│ └── schema.py
+│
+├── statistics/
+│ ├── __init__.py
+│ ├── descriptive.py
+│ ├── distributions.py
+│ ├── robust.py
+│ ├── testing.py
+│ ├── relationships.py
+│ ├── multicollinearity.py
+│ └── segments.py
+│
+├── anomaly/
+│ ├── __init__.py
+│ ├── ensemble.py
+│ ├── univariate.py
+│ ├── classical.py
+│ └── scoring.py
+│
+├── drift/
+│ ├── __init__.py
+│ ├── compare.py
+│ ├── numeric.py
+│ ├── categorical.py
+│ ├── text.py
+│ ├── schema.py
+│ ├── methods.py
+│ └── selection.py
+│
+├── target/
+│ ├── __init__.py
+│ ├── intelligence.py
+│ ├── task.py
+│ ├── leakage.py
+│ ├── imbalance.py
+│ ├── label_quality.py
+│ └── candidates.py
+│
+├── ml/
+│ ├── __init__.py
+│ ├── readiness.py
+│ ├── preprocessing.py
+│ ├── baseline.py
+│ ├── leaderboard.py
+│ ├── diagnostics.py
+│ ├── feature_importance.py
+│ └── explainability.py
+│
+├── time_series/
+│ ├── __init__.py
+│ ├── detection.py
+│ ├── quality.py
+│ ├── diagnostics.py
+│ ├── seasonality.py
+│ ├── change_points.py
+│ └── anomaly.py
+│
+├── text/
+│ ├── __init__.py
+│ ├── profile.py
+│ ├── quality.py
+│ ├── duplication.py
+│ ├── pii.py
+│ └── drift.py
+│
+├── cleaning/
+│ ├── __init__.py
+│ ├── plan.py
+│ ├── suggestions.py
+│ ├── simulate.py
+│ ├── apply.py
+│ └── audit.py
+│
+├── monitoring/
+│ ├── __init__.py
+│ ├── snapshot.py
+│ ├── store.py
+│ ├── history.py
+│ └── policy.py
+│
+├── models/
+│ ├── __init__.py
+│ ├── registry.py
+│ ├── manifest.py
+│ ├── runtime.py
+│ ├── semantic_types.py
+│ ├── deep_anomaly.py
+│ ├── temporal.py
+│ └── embeddings.py
+│
+├── reporting/
+│ ├── __init__.py
+│ ├── terminal.py
+│ ├── json.py
+│ ├── html.py
+│ ├── pdf.py
+│ ├── notebook.py
+│ ├── charts.py
+│ ├── compare_html.py
+│ └── privacy.py
+│
+├── plugins/
+│ ├── __init__.py
+│ ├── registry.py
+│ ├── checks.py
+│ ├── analyzers.py
+│ └── hooks.py
+│
+├── ai/
+│ ├── __init__.py
+│ ├── insights.py
+│ ├── agent.py
+│ ├── tools.py
+│ └── brief.py
+│
+├── cli.py
+└── cli_tui.py
+```
+
+## Migration philosophy
+
+Do **not** perform a giant move-only refactor immediately. Prefer:
+
+1. introduce a new namespace when implementing a new coherent capability
+2. move an old module only when it is actively being changed or causes architectural friction
+3. keep compatibility shims for public imports where necessary
+4. delete shims only through documented deprecation cycles
+
+## Mapping from important current modules
+
+| Current module | Future home/direction |
+|---|---|
+| `pipeline.py` | gradually replaced/orchestrated by `execution/context.py` + `execution/planner.py` |
+| `analysis_inventory.py` | `execution/inventory.py` |
+| `analysis_selector.py` | `execution/planner.py` |
+| `profiler.py` | `profiling/structural.py` |
+| `column_roles.py` | `profiling/column_roles.py` + semantic typing layer |
+| `dataset_signals.py` | `profiling/dataset_signals.py` |
+| `health_score.py` | `quality/health.py` |
+| `contracts.py` | split gradually into `contracts/` package |
+| `drift_analysis.py` | split gradually into `drift/` package |
+| `anomaly_ensemble.py` | `anomaly/ensemble.py` |
+| `ml_readiness.py` | `ml/readiness.py` |
+| `target_analyzer.py` | `target/intelligence.py` |
+| `target_leakage.py` | `target/leakage.py` |
+| `time_series.py` | split into `time_series/` package |
+| `text_profile.py` | `text/profile.py` |
+| `cleaner.py` | replaced by plan/simulate/apply/audit workflow in `cleaning/` |
+| `visualizer.py` | `reporting/charts.py` |
+| PDF/report modules | `reporting/` |
+| `ai_agent.py`, `ai_insights.py`, `agent_tools.py`, `agent_brief.py` | isolated under optional `ai/` namespace |
+
+## Dependency direction
+
+A useful dependency rule is:
+
+```text
+api/result/reporting
+ ↓
+execution planner/context
+ ↓
+analysis domains
+ ↓
+profiling/backends/config
+```
+
+Analysis-domain modules should **not** import the CLI/TUI or website code. Reporting should consume result structures rather than cause analyses to rerun. Optional AI should consume structured findings/results rather than become a required dependency of deterministic modules.
+
+## Plugin boundary
+
+Long term, third-party analysis plugins should register through a stable plugin API instead of monkey-patching the pipeline.
+
+A plugin should declare:
+- ID/name/version
+- supported FrameVitals plugin API version
+- applicability predicate
+- required inputs/context facts
+- resource class
+- optional dependency requirements
+- output/finding schema
+- whether failure is fatal or optional
+
+This allows FrameVitals to be exhaustive without requiring every niche analysis to live in the core repository forever.
diff --git a/docs/architecture-proposal/README.md b/docs/architecture-proposal/README.md
new file mode 100644
index 0000000..89fb461
--- /dev/null
+++ b/docs/architecture-proposal/README.md
@@ -0,0 +1,24 @@
+# FrameVitals Architecture Proposal Archive
+
+These documents were transferred from the former `docs/architecture-proposal` working branch into `develop/august` so the useful planning work can be preserved before branch cleanup.
+
+They are **planning references, not the current public API or release contract**. Several originally proposed items have already evolved into implemented FrameVitals features, including result objects, source-aware execution, execution budgets, streaming/Arrow support, native Rust kernels, planning APIs, monitoring snapshots, quality gates, and capability-oriented optional dependencies.
+
+For current behavior, prefer the maintained documentation in the parent `docs/` directory and the package code/tests.
+
+## Preserved planning documents
+
+- `IMPLEMENTATION_ROADMAP.md` — original staged implementation sequence and product philosophy.
+- `BENCHMARK_ACCEPTANCE_PLAN.md` — correctness/performance/memory/backend acceptance ideas.
+- `CAPABILITY_PACKS.md` — optional capability-pack and dependency-management design.
+- `CLI_TUI_SPEC.md` — future interactive terminal design ideas.
+- `DECISIONS.md` — accepted long-term product/architecture principles.
+- `PROPOSED_MODULE_TREE.md` — proposed modular organization for future growth.
+
+Generated PDF/LaTeX artifacts and the proposal-only build workflow were intentionally **not** transferred. Source planning content belongs in Git; generated planning artifacts do not need to remain on an active development branch.
+
+## Core rule retained from the proposal
+
+> FrameVitals should be exhaustive in capability, not exhaustive in what it runs by default.
+
+That principle remains compatible with the current direction: the execution engine should choose the cheapest statistically defensible work based on applicability, scale, installed capabilities, backend availability, and requested analysis depth.
diff --git a/docs/execution-provenance.md b/docs/execution-provenance.md
new file mode 100644
index 0000000..609318e
--- /dev/null
+++ b/docs/execution-provenance.md
@@ -0,0 +1,119 @@
+# Execution provenance
+
+FrameVitals results can mix exact metadata, full-stream calculations, bounded row
+samples, estimates, and operations that intentionally materialize a complete pandas
+DataFrame. The `execution` block makes those decisions machine-readable.
+
+## Schema version
+
+The shared execution contract currently uses:
+
+```json
+{
+ "execution_schema_version": "1"
+}
+```
+
+During the `0.x` series, older operation-specific keys remain available while public
+results converge on this common vocabulary.
+
+## Common fields
+
+| Field | Meaning |
+| --- | --- |
+| `execution_schema_version` | Version of the shared execution metadata contract |
+| `method` | Stable description of the execution strategy |
+| `full_materialization` | Whether FrameVitals created a complete pandas representation of a non-pandas source |
+| `source` | Source metadata/capabilities when available |
+| `sampled` | Whether row-level work used fewer than all source rows |
+| `source_rows` | Exact source row count when known |
+| `source_columns` | Exact source column count when known |
+| `sample_rows` | Number of rows used for bounded row-level work |
+| `strategy` | Sampling or source-consumption strategy |
+| `components` | Per-component exactness/approximation information |
+| `reason` | Human-readable explanation for the execution choice |
+| `scope` | Legacy/operation-specific scope retained during the `0.x` migration |
+
+Not every field appears in every result. Missing information is omitted rather than
+represented as ambiguous placeholder values.
+
+## Full materialization semantics
+
+`full_materialization` describes **what the operation did**, not merely where the
+source came from.
+
+A pandas `DataFrame` is already materialized when supplied by the caller, so running
+an exact operation on it does not count as FrameVitals materializing a new source.
+By contrast, converting any of these into a complete pandas DataFrame does count:
+
+- a file-backed source;
+- a PyArrow table;
+- a DuckDB relation;
+- a remote/custom source.
+
+This distinction matters for memory planning and for pipelines that deliberately stay
+on Arrow/relation-backed execution paths.
+
+## Examples
+
+### Bounded statistics
+
+```json
+{
+ "execution_schema_version": "1",
+ "method": "bounded_deep_statistics",
+ "full_materialization": false,
+ "sampled": true,
+ "source_rows": 120000,
+ "source_columns": 18,
+ "sample_rows": 1000,
+ "strategy": "streaming_evenly_spaced_global_rows"
+}
+```
+
+### Exact contract validation
+
+```json
+{
+ "execution_schema_version": "1",
+ "method": "exact_contract_validation",
+ "full_materialization": true,
+ "sampled": false,
+ "source": {
+ "kind": "relation",
+ "format": "duckdb"
+ }
+}
+```
+
+### Quality gate
+
+A gate aggregates the execution blocks of the selected check families:
+
+```python
+result = fv.gate(
+ current,
+ reference=reference,
+ contract=contract,
+ custom_checks=[positive_revenue],
+)
+
+print(result["execution"])
+```
+
+The top-level gate reports `full_materialization=True` when **any** selected check
+family required it, while nested `validation`, `drift`, and `custom` entries preserve
+family-level provenance.
+
+## Exactness is diagnostic-specific
+
+A streaming-capable source does not make every metric approximate. For example:
+
+- source shape and schema can be exact;
+- missingness can be computed over the full stream;
+- duplicate rate may become a bounded estimate on a large source;
+- deep statistics may use a deterministic bounded sample;
+- contract uniqueness checks stay exact and therefore may materialize.
+
+Consumers should prefer the `execution`/`components` fields over assumptions based on
+file format or dataset size.
diff --git a/docs/extending-framevitals.md b/docs/extending-framevitals.md
new file mode 100644
index 0000000..6675782
--- /dev/null
+++ b/docs/extending-framevitals.md
@@ -0,0 +1,163 @@
+# Extending FrameVitals
+
+FrameVitals is designed to be extended at the edges rather than forked in the core.
+There are two intentionally small extension boundaries:
+
+1. **custom data checks** for domain invariants;
+2. **`DatasetSource` implementations** for new storage/query systems.
+
+Third-party check packages can additionally register checks through standard Python
+entry points. Source adapters are normal Python objects implementing the source
+protocol and can be shipped by any package without FrameVitals importing that package.
+
+## Custom checks
+
+Use `@framevitals.check` for application-owned invariants:
+
+```python
+import framevitals as fv
+
+@fv.check(
+ "positive revenue",
+ severity="error",
+ description="Revenue cannot be negative.",
+)
+def positive_revenue(df):
+ minimum = float(df["revenue"].min())
+ return {
+ "passed": minimum >= 0,
+ "message": "Negative revenue found." if minimum < 0 else "Revenue is valid.",
+ "details": {"minimum": minimum},
+ }
+```
+
+A check may return either a boolean-like value or a mapping containing `passed`.
+Mappings can also include `message` and `details`.
+
+Custom checks intentionally run against the complete DataFrame. FrameVitals cannot
+safely infer whether an arbitrary Python invariant is sampleable, so it does not
+silently weaken user-defined rules to bounded samples.
+
+## Publishing a check plugin
+
+A third-party package can expose checks through the `framevitals.checks` entry-point
+group:
+
+```toml
+[project.entry-points."framevitals.checks"]
+positive_revenue = "acme_framevitals_checks:positive_revenue"
+```
+
+The exported object can be a `DataCheck` or a compatible DataFrame callable.
+Applications opt in explicitly:
+
+```python
+checks = fv.discover_checks()
+result = fv.gate(current, custom_checks=checks)
+```
+
+FrameVitals never auto-loads installed check plugins. Loading an entry point executes
+provider code, so discovery is an explicit trust decision.
+
+### Plugin guidance
+
+A good provider package should:
+
+- keep public check names stable;
+- avoid network or filesystem side effects during module import;
+- return JSON-friendly `details` values;
+- document whether a rule is a warning or a hard error;
+- test against the supported FrameVitals version range;
+- avoid mutating the DataFrame passed into a check.
+
+FrameVitals gives every check an isolated DataFrame copy, but provider code should
+still behave as a pure predicate where practical.
+
+## Custom DatasetSource implementations
+
+A storage/query integration does not need to be added to FrameVitals core. The minimal
+protocol is deliberately small:
+
+```python
+from framevitals.sources import DatasetMetadata
+
+class MySource:
+ def inspect(self):
+ return DatasetMetadata(
+ name="warehouse.orders",
+ kind="remote",
+ format="my-engine",
+ rows=1_000_000,
+ columns=12,
+ size_bytes=None,
+ materialized=False,
+ supports_projection=False,
+ supports_streaming=False,
+ )
+
+ def load(self):
+ # Return the complete dataset as pandas only when an operation requires it.
+ return fetch_as_pandas()
+```
+
+Then pass the source directly:
+
+```python
+source = MySource()
+print(fv.inspect_source(source))
+report = fv.analyze(source, mode="quick")
+```
+
+Because the protocol is runtime-checkable, FrameVitals can consume compatible custom
+objects without maintaining a registry of every provider package.
+
+## Streaming source protocol
+
+To opt into source-aware batch execution, additionally implement `iter_batches`:
+
+```python
+class MyStreamingSource(MySource):
+ def iter_batches(self, *, batch_size=65_536, columns=None):
+ ...
+```
+
+A streaming adapter should preserve these invariants:
+
+1. `inspect()` reports the **true source shape**, not the retained sample shape.
+2. `iter_batches()` respects `batch_size` and requested projection where advertised.
+3. yielded batches are compatible with the Arrow-oriented streaming profiler.
+4. `load()` remains available for exact/full-row APIs.
+5. optional provider dependencies are imported lazily.
+6. the adapter does not pretend an exact operation is streaming merely because the
+ source supports batches.
+
+## Prefer standard interoperability over one-off adapters
+
+If a data library exposes the Arrow C Stream / PyCapsule interface, FrameVitals can
+usually consume it through the generic Arrow boundary instead of carrying a dedicated
+library dependency.
+
+That is how FrameVitals can interoperate with modern dataframe producers such as
+Polars while keeping Polars out of the runtime dependency graph.
+
+Use a dedicated adapter when the storage/query system provides capabilities that the
+generic Arrow boundary would lose. The DuckDB adapter is an example: it preserves a
+lazy relation, pushes column projection into DuckDB, obtains exact relation metadata,
+and streams Arrow batches without first constructing a complete in-memory table.
+
+## Execution provenance for extensions
+
+Extension authors should not invent incompatible exactness terminology. FrameVitals'
+shared execution schema uses fields such as:
+
+- `method`
+- `full_materialization`
+- `sampled`
+- `source_rows`
+- `source_columns`
+- `sample_rows`
+- `strategy`
+- `components`
+- `reason`
+
+See [Execution provenance](execution-provenance.md) for the current schema.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..babd9fa
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,99 @@
+# FrameVitals
+
+**FrameVitals is a source-aware data-health and quality-gate engine for tabular pipelines.**
+
+It provides one workflow for inspecting data health, comparing production batches,
+validating contracts, enforcing domain-specific invariants, and retaining compact
+monitoring history.
+
+```python
+import framevitals as fv
+
+source = fv.inspect_source("production.parquet")
+report = fv.analyze("production.parquet", mode="quick")
+drift = fv.compare("training.parquet", "production.parquet")
+validation = fv.validate("production.parquet", contract)
+gate = fv.gate(
+ "production.parquet",
+ reference="training.parquet",
+ contract=contract,
+)
+```
+
+## Core design
+
+FrameVitals is built around four constraints:
+
+1. **Source-aware execution** — supported sources expose metadata, projection, and
+ batches before FrameVitals decides whether pandas materialization is necessary.
+2. **Bounded expensive work** — statistics, anomaly detection, relationship discovery,
+ and drift use explicit execution budgets when full-row computation is not required.
+3. **Exactness where correctness requires it** — contracts and arbitrary custom Python
+ invariants are not silently weakened to sampled checks.
+4. **Execution transparency** — public results disclose whether work was exact,
+ sampled, estimated, streamed, or fully materialized.
+
+## Main workflow
+
+```text
+source
+ │
+ ├─► inspect_source
+ │
+ ├─► analyze ─► snapshot ─► SnapshotHistory
+ │
+ ├─► compare(reference, current)
+ │
+ ├─► validate(current, contract)
+ │
+ └─► gate
+ ├─ contract validation
+ ├─ drift
+ └─ custom checks / plugins
+```
+
+## Supported source families
+
+FrameVitals currently recognizes:
+
+- pandas `DataFrame` inputs;
+- CSV and TSV files, with optional Arrow streaming;
+- Parquet through the Arrow capability;
+- PyArrow `Table` and `RecordBatch` inputs;
+- table producers supporting the Arrow C Stream / PyCapsule interface;
+- lazy DuckDB relations through the optional DuckDB adapter;
+- custom objects implementing the FrameVitals `DatasetSource` protocol.
+
+Use `fv.inspect_source(data)` before analysis when you want to see the source's
+shape metadata and streaming/projection capabilities.
+
+## Installation
+
+```bash
+pip install framevitals
+```
+
+Optional capabilities are deliberately separated:
+
+```bash
+pip install "framevitals[arrow]"
+pip install "framevitals[duckdb]"
+pip install "framevitals[excel]"
+pip install "framevitals[plot]"
+pip install "framevitals[ml]"
+pip install "framevitals[ai]"
+pip install "framevitals[web]"
+```
+
+For documentation development:
+
+```bash
+pip install -e ".[docs]"
+mkdocs serve
+```
+
+## Documentation map
+
+- [Source-aware execution](source-execution.md)
+- [Execution provenance](execution-provenance.md)
+- [Quality gates and custom checks](quality-gates.md)
diff --git a/docs/monitoring.md b/docs/monitoring.md
new file mode 100644
index 0000000..f9f45a7
--- /dev/null
+++ b/docs/monitoring.md
@@ -0,0 +1,120 @@
+# Monitoring with snapshots
+
+FrameVitals snapshots turn a full analysis result into compact, versioned monitoring
+state. They are designed for workflows where you want to detect meaningful changes
+without storing every raw production batch.
+
+## Create a snapshot in Python
+
+```python
+import framevitals as fv
+
+report = fv.analyze("production.parquet", mode="quick")
+snapshot = report.snapshot("production.snapshot.json")
+```
+
+A snapshot retains compact state such as:
+
+- source identity;
+- schema and dtypes;
+- missingness;
+- duplicate rate;
+- health score;
+- ML-readiness score;
+- finding codes;
+- analysis configuration;
+- a deterministic state fingerprint.
+
+It does **not** embed the full raw dataset.
+
+## Snapshot from the CLI
+
+```bash
+framevitals snapshot production.parquet \
+ --mode quick \
+ --output production.snapshot.json
+```
+
+Snapshot generation always disables filesystem analysis artifacts. The requested
+snapshot file is the only explicit monitoring artifact written by the command.
+
+Use JSON on stdout when integrating with another tool:
+
+```bash
+framevitals snapshot production.parquet \
+ --mode quick \
+ --format json \
+ --output production.snapshot.json
+```
+
+## Compare two snapshots
+
+```bash
+framevitals compare-snapshots \
+ baseline.snapshot.json \
+ production.snapshot.json
+```
+
+The comparison reports:
+
+- whether the state fingerprint changed;
+- added or removed columns;
+- dtype changes;
+- per-column missingness changes;
+- health-score delta;
+- ML-readiness delta;
+- new findings;
+- resolved findings.
+
+For machine-readable output:
+
+```bash
+framevitals compare-snapshots \
+ baseline.snapshot.json \
+ production.snapshot.json \
+ --format json \
+ --output snapshot-diff.json
+```
+
+## Fail CI when state changes
+
+Snapshot change is not automatically treated as failure because many changes are
+legitimate. When a pipeline explicitly wants fingerprint stability, opt in:
+
+```bash
+framevitals compare-snapshots \
+ baseline.snapshot.json \
+ production.snapshot.json \
+ --fail-on-change
+```
+
+The command returns exit code `1` when the fingerprints differ and `0` when they are
+equal.
+
+For data-quality enforcement, prefer `framevitals gate` when you want semantic
+contract/drift thresholds rather than a strict "anything changed" rule.
+
+## Persistent history in Python
+
+For repeated local monitoring, `SnapshotHistory` stores compact snapshots under one
+directory:
+
+```python
+history = fv.SnapshotHistory(".framevitals/history")
+history.add(report.snapshot(), label="production")
+
+latest = history.latest()
+previous = history.previous()
+change = history.compare_latest()
+```
+
+The default `.framevitals/` runtime directory is ignored by Git.
+
+## Snapshot compatibility
+
+Snapshots carry their own schema version. `fv.load_snapshot()` validates that version
+before returning an `AnalysisSnapshot`, so incompatible monitoring state fails loudly
+instead of being compared with unknown semantics.
+
+During the `0.x` series, snapshot/result schemas may still evolve. Breaking changes
+must be recorded in the changelog and release notes before promotion to `main`.
diff --git a/docs/quality-gates.md b/docs/quality-gates.md
new file mode 100644
index 0000000..7a778dd
--- /dev/null
+++ b/docs/quality-gates.md
@@ -0,0 +1,164 @@
+# Quality gates and custom checks
+
+FrameVitals quality gates combine the checks a pipeline actually needs into one
+`pass` / `warn` / `fail` verdict.
+
+## Built-in gate families
+
+A gate can run any combination of:
+
+- exact contract validation;
+- reference-vs-current drift;
+- exact user-defined Python checks.
+
+```python
+result = fv.gate(
+ current,
+ reference=reference,
+ contract=contract,
+ custom_checks=[positive_revenue],
+)
+
+print(result.status)
+print(result.passed)
+print(result.reasons)
+```
+
+At least one family must be selected.
+
+## Contracts
+
+Infer a reusable contract from a trusted reference dataset:
+
+```python
+contract = fv.infer_contract(reference)
+validation = fv.validate(current, contract)
+```
+
+Current contracts can encode:
+
+- required and optional columns;
+- broad dtype expectations;
+- nullability and tolerated null rates;
+- numeric bounds with configurable tolerance;
+- low-cardinality allowed values;
+- uniqueness hints.
+
+Contract validation remains exact. If the input is Arrow-, relation-, or file-backed,
+FrameVitals may materialize the complete dataset to pandas rather than weakening the
+contract to a sample.
+
+## Custom Python invariants
+
+```python
+@fv.check(
+ "positive revenue",
+ severity="error",
+ description="Revenue cannot be negative.",
+)
+def positive_revenue(df):
+ minimum = float(df["revenue"].min())
+ return {
+ "passed": minimum >= 0,
+ "message": "Negative revenue found." if minimum < 0 else "Revenue is valid.",
+ "details": {"minimum": minimum},
+ }
+```
+
+Run checks independently:
+
+```python
+result = fv.run_checks(current, [positive_revenue])
+print(result.status)
+print(result.findings)
+```
+
+Or include them in a gate:
+
+```python
+result = fv.gate(current, custom_checks=[positive_revenue])
+```
+
+Every custom check receives an isolated DataFrame copy so one check cannot mutate the
+input seen by later checks. Exceptions raised by provider code become structured error
+results instead of aborting the entire check collection.
+
+### Severity
+
+Use `severity="warning"` for soft expectations and `severity="error"` for hard
+invariants. A warning-only custom-check result has status `warn` but remains passing;
+an error failure produces status `fail`.
+
+## Third-party check plugins
+
+External packages can expose reusable checks with normal Python package entry points:
+
+```toml
+[project.entry-points."framevitals.checks"]
+positive_revenue = "acme_data_checks:positive_revenue"
+```
+
+FrameVitals **does not automatically import installed plugins**. Applications opt in:
+
+```python
+checks = fv.discover_checks()
+result = fv.gate(current, custom_checks=checks)
+```
+
+This is an intentional trust boundary: loading an entry point executes code supplied
+by the provider package.
+
+Discovery rejects duplicate public check names and surfaces broken providers rather
+than silently changing gate behavior.
+
+## Drift thresholds
+
+```python
+result = fv.gate(
+ current,
+ reference=reference,
+ drift_warn_on="moderate",
+ drift_fail_on="severe",
+)
+```
+
+Supported severities are `stable`, `minor`, `moderate`, and `severe`.
+
+## CLI
+
+```bash
+framevitals gate production.parquet \
+ --reference training.parquet \
+ --contract contract.json \
+ --drift-warn-on moderate \
+ --drift-fail-on severe \
+ --output framevitals-gate.json
+```
+
+The CLI exits `0` for pass/warn and `1` for fail.
+
+## GitHub Action
+
+The repository includes a reusable composite action:
+
+```yaml
+- uses: parthdongre/FrameVitals@v0.1.0
+ id: framevitals
+ with:
+ current: production.parquet
+ reference: training.parquet
+ contract: contract.json
+ output: framevitals-gate.json
+```
+
+For real CI pipelines, pin a released tag or commit SHA instead of a moving branch.
+The action exposes `status`, `passed`, and `result-path` outputs.
+
+## Execution transparency
+
+The gate's top-level `execution.full_materialization` becomes true if any selected
+family required full materialization. Family-specific execution blocks remain nested
+under `validation`, `drift`, and `custom` so automation can distinguish why a gate
+materialized or sampled data.
+
+See [Execution provenance](execution-provenance.md) for the shared schema.
diff --git a/docs/result-objects.md b/docs/result-objects.md
new file mode 100644
index 0000000..c23b6f2
--- /dev/null
+++ b/docs/result-objects.md
@@ -0,0 +1,169 @@
+# Result objects
+
+FrameVitals public workflows return **dict-compatible result objects** during the
+`0.x` series. This keeps existing mapping/JSON code working while adding discoverable
+helpers for notebooks, applications, reports, and CI.
+
+## Why dict-compatible objects?
+
+A FrameVitals result should be easy to:
+
+- index like a normal dictionary;
+- serialize to JSON;
+- pass into existing application code;
+- inspect interactively with named properties and helper methods.
+
+The `0.x` series therefore favors additive wrappers over a hard break to dataclasses
+or validation models.
+
+## Full analysis
+
+`fv.analyze(...)` returns `AnalysisResult`.
+
+```python
+report = fv.analyze(data)
+
+report.health
+report.ml_readiness
+report.findings
+report.recommendations
+report.column("age")
+report.summary()
+report.summary_text()
+report.to_json("analysis.json")
+report.to_html("analysis.html")
+report.snapshot("snapshot.json")
+```
+
+`AnalysisResult` remains a `dict`, so existing code still works:
+
+```python
+score = report["health"]["overall_score"]
+```
+
+## Focused diagnostics
+
+Focused APIs return `DiagnosticResult`:
+
+- `fv.profile(...)`
+- `fv.roles(...)`
+- `fv.health(...)`
+- `fv.ml_readiness(...)`
+- `fv.quality(...)`
+- `fv.statistics(...)`
+- `fv.anomalies(...)`
+- `fv.relationships(...)`
+- `fv.target_analysis(...)`
+
+```python
+stats = fv.statistics(data, mode="quick")
+
+stats.diagnostic # "statistics"
+stats.dataset_name
+stats.execution
+stats.source
+stats.available
+stats.summary()
+stats.summary_text()
+stats.to_json("statistics.json")
+```
+
+The diagnostic label is Python-side metadata and is **not injected into the mapping**.
+That means wrapping a focused payload does not mutate its JSON schema:
+
+```python
+assert "diagnostic" not in stats
+assert dict(stats) == stats.to_dict()
+```
+
+`to_dict()` returns a detached deep copy so callers can modify it without mutating the
+live result object.
+
+## Quality operations
+
+### DriftResult
+
+`fv.compare(...)` returns `DriftResult` with conveniences such as:
+
+```python
+drift.severity
+drift.status
+drift.columns
+drift.summary_text()
+drift.to_json()
+```
+
+### ValidationResult
+
+`fv.validate(...)` returns `ValidationResult`:
+
+```python
+validation.valid
+validation.status
+validation.findings
+validation.summary_text()
+```
+
+### CheckResult
+
+`fv.run_checks(...)` returns `CheckResult`:
+
+```python
+checks.status
+checks.passed
+checks.results
+checks.findings
+checks.summary_text()
+```
+
+### GateResult
+
+`fv.gate(...)` returns `GateResult`:
+
+```python
+gate.status
+gate.passed
+gate.checks_run
+gate.reasons
+gate.summary_text()
+```
+
+All four quality result types remain mapping-compatible and expose `to_dict()` /
+`to_json()` through the shared quality-result base.
+
+## Monitoring state
+
+`AnalysisSnapshot` is a compact dict-compatible monitoring record derived from an
+`AnalysisResult`. `SnapshotHistory` manages persisted snapshots without storing raw
+input datasets.
+
+```python
+history = fv.SnapshotHistory(".framevitals/history")
+history.add(report.snapshot(), label="production")
+change = history.compare_latest()
+```
+
+## Execution metadata
+
+Focused and quality results increasingly expose the shared execution-provenance
+contract under `result.execution` or `result["execution"]`.
+
+```python
+execution = stats.execution
+print(execution["execution_schema_version"])
+print(execution["method"])
+print(execution["sampled"])
+print(execution["full_materialization"])
+```
+
+See [Execution provenance](execution-provenance.md) for the schema and exactness rules.
+
+## Compatibility policy during 0.x
+
+FrameVitals aims to evolve result ergonomics without needless churn:
+
+1. result objects remain subclasses of `dict`;
+2. helper properties should not silently change serialized payloads;
+3. new shared metadata is added deliberately and versioned where appropriate;
+4. legacy operation-specific fields remain available during migration;
+5. breaking result-schema changes must be documented before release.
diff --git a/docs/source-execution.md b/docs/source-execution.md
new file mode 100644
index 0000000..ec11600
--- /dev/null
+++ b/docs/source-execution.md
@@ -0,0 +1,154 @@
+# Source-aware execution
+
+FrameVitals separates **what the data is stored in** from **what a diagnostic needs to
+compute**. Public APIs resolve inputs into a `DatasetSource` before choosing between
+batch streaming and pandas materialization.
+
+## Inspect a source first
+
+```python
+import framevitals as fv
+
+info = fv.inspect_source(data)
+print(info)
+```
+
+Typical fields include:
+
+```json
+{
+ "name": "production.parquet",
+ "kind": "file",
+ "format": "parquet",
+ "rows": 1200000,
+ "columns": 42,
+ "size_bytes": 84599210,
+ "materialized": false,
+ "supports_projection": true,
+ "supports_streaming": true
+}
+```
+
+`inspect_source()` does not run health/statistical diagnostics. Some adapters may need
+to scan lightweight metadata (for example exact relation row counts), but they do not
+materialize the full dataset into pandas simply to describe source capabilities.
+
+## DatasetSource protocol
+
+A minimal source provides:
+
+```python
+class DatasetSource(Protocol):
+ def inspect(self) -> DatasetMetadata: ...
+ def load(self) -> pandas.DataFrame: ...
+```
+
+A streaming source additionally provides:
+
+```python
+class StreamingDatasetSource(DatasetSource, Protocol):
+ def iter_batches(
+ self,
+ *,
+ batch_size: int = 65_536,
+ columns: Sequence[str] | None = None,
+ ) -> Iterator[Any]: ...
+```
+
+This means new storage systems can integrate with FrameVitals without changing every
+diagnostic implementation.
+
+## Current adapters
+
+### pandas
+
+A pandas `DataFrame` is already materialized. It remains the reference in-memory
+representation for exact/full-row operations.
+
+### Parquet
+
+With `framevitals[arrow]`, Parquet supports:
+
+- exact row/column metadata from file metadata;
+- column projection;
+- Arrow record batches;
+- bounded global row sampling without `ParquetSource.load()`.
+
+### CSV / TSV
+
+With Arrow installed, compatible delimited files use incremental Arrow CSV reading.
+If Arrow cannot interpret a file, FrameVitals preserves compatibility by falling back
+to the existing pandas loader rather than silently returning a different parse.
+
+### PyArrow Table and RecordBatch
+
+PyArrow in-memory objects enter the batch path directly. FrameVitals does not first
+convert the complete object to pandas for streaming-safe diagnostics.
+
+### Arrow C Stream / PyCapsule producers
+
+Table-like objects implementing `__arrow_c_stream__` can be normalized through
+PyArrow. This gives interoperable support to producers such as modern dataframe
+libraries without hard-coding each library into FrameVitals.
+
+A raw `RecordBatchReader` is currently rejected because it does not expose the cheap
+exact row count required by FrameVitals' current planning/profiling contract.
+
+### DuckDB relations
+
+With `framevitals[duckdb]`, a lazy `DuckDBPyRelation` supports:
+
+- exact row count via a cached aggregate;
+- schema inspection;
+- projection pushed into the DuckDB relation;
+- Arrow `RecordBatchReader` transport;
+- pandas materialization only when an exact/full-row API explicitly requires it.
+
+```python
+import duckdb
+import framevitals as fv
+
+con = duckdb.connect()
+relation = con.sql("SELECT * FROM read_parquet('events/*.parquet')")
+
+print(fv.inspect_source(relation))
+report = fv.analyze(relation, mode="quick")
+```
+
+## Source-aware public APIs
+
+The source abstraction is shared by:
+
+- `inspect_source()`
+- `profile()`
+- `roles()`
+- `health()`
+- `ml_readiness()`
+- `quality()`
+- `statistics()`
+- `anomalies()`
+- `relationships()`
+- `target_analysis()`
+- `analyze()`
+- `plan()`
+- `compare()`
+- `validate()`
+- `run_checks()`
+- `gate()`
+
+The operation still decides whether streaming is semantically valid. For example,
+contract validation and arbitrary Python checks deliberately remain exact.
+
+## Adding a source adapter
+
+Contributors should preserve these invariants:
+
+1. `inspect()` should be cheaper/safer than full pandas materialization.
+2. `iter_batches()` must respect requested projection where supported.
+3. source metadata must report the **true** source shape, not sample shape.
+4. bounded algorithms must disclose sampling strategy and row counts.
+5. exact operations must not claim streaming merely because the source supports it.
+6. optional storage dependencies must remain lazily imported.
+
+See [Execution provenance](execution-provenance.md) for the metadata contract used to
+report these choices.
diff --git a/docs/stability.md b/docs/stability.md
new file mode 100644
index 0000000..f0b9149
--- /dev/null
+++ b/docs/stability.md
@@ -0,0 +1,138 @@
+# Stability and compatibility policy
+
+FrameVitals follows semantic versioning, but the project is currently in the `0.x`
+series. The purpose of this policy is to make that maturity level explicit without
+using "alpha" as an excuse for arbitrary breakage.
+
+## Python versions
+
+The current package metadata supports:
+
+- Python 3.11
+- Python 3.12
+- Python 3.13
+
+The main test matrix exercises all three. Separate smoke workflows exercise public
+package/CLI behavior on Windows and macOS in addition to the primary Linux CI.
+
+## Dependency lower bounds
+
+Minimum core dependency versions declared in `pyproject.toml` are tested in a dedicated
+Python 3.11 lower-bound workflow. If FrameVitals begins relying on behavior unavailable
+at an advertised lower bound, the project must either restore compatibility or raise
+the declared minimum deliberately.
+
+Optional capability groups (`arrow`, `duckdb`, `excel`, `plot`, `ml`, `ai`, `web`,
+`docs`) remain independently installable. Core import/CLI behavior should not require
+an optional dependency merely because the corresponding module exists in the source
+tree.
+
+## Public Python surface
+
+The package-root workflow APIs are the primary public surface:
+
+- `inspect_source`
+- `analyze` / `plan`
+- focused diagnostics such as `profile`, `health`, `statistics`, and `relationships`
+- `compare`
+- `infer_contract` / `validate`
+- `check` / `run_checks` / `discover_checks`
+- `gate`
+- snapshot/history helpers
+
+A regression test locks the exported package surface so accidental symbol removal is
+caught in CI.
+
+Modules and helpers not exported at package root may still be intentionally reusable,
+but should not be assumed to have the same compatibility promise unless documented.
+
+## Dict-compatible result objects
+
+During `0.x`, public result objects remain subclasses of `dict` to preserve existing
+mapping and JSON-oriented callers while adding helper methods.
+
+FrameVitals avoids injecting Python-only wrapper metadata into serialized payloads.
+For example, `DiagnosticResult.diagnostic` is object metadata and does not become a
+new JSON key merely because a focused result is wrapped.
+
+## Versioned machine-readable schemas
+
+Several persisted/automation-facing structures carry explicit schema versions:
+
+- analysis results: `result_schema_version`
+- monitoring snapshots: `snapshot_schema_version`
+- execution provenance: `execution_schema_version`
+- data contracts: contract `version`
+
+A schema-version bump should accompany an intentionally incompatible machine-readable
+change. New additive fields do not necessarily require a bump when older consumers
+can ignore them safely.
+
+## Execution semantics
+
+Source-aware execution is part of the public behavior, not an implementation detail.
+When a result exposes execution provenance, FrameVitals should not silently relabel:
+
+- sampled work as exact;
+- a sample shape as source shape;
+- full pandas materialization as streaming;
+- approximate cardinality/duplicate estimates as exact values.
+
+Performance optimizations, including the optional native Rust core, must preserve
+public result semantics. Acceleration is not allowed to create a separate correctness
+contract.
+
+## Extension compatibility
+
+Third-party check plugins use the `framevitals.checks` Python entry-point group.
+Plugin discovery is explicit and never automatic because loading an entry point
+executes provider code.
+
+Custom data sources integrate through the `DatasetSource` / `StreamingDatasetSource`
+protocols. Prefer standard Arrow interoperability over a library-specific dependency
+when the protocol preserves the required metadata and execution semantics.
+
+## What may change during 0.x
+
+Before `1.0`, minor releases may still revise:
+
+- scoring thresholds;
+- finding codes or wording;
+- result structure not protected by an explicit schema contract;
+- source-adapter implementation details;
+- experimental analysis modules;
+- extension points that are explicitly marked provisional.
+
+Breaking changes should be documented in `CHANGELOG.md` and release notes before they
+reach `main`. Avoid breaking package-root workflow signatures unless the improvement
+is substantial enough to justify migration cost.
+
+## Deprecation direction
+
+During `0.x`, FrameVitals may occasionally make direct breaking changes when maintaining
+two behaviors would create more ambiguity than value. Where practical, prefer a
+warning/migration period.
+
+After `1.0`, the intended policy is stricter: public package-root APIs and versioned
+schemas should use documented deprecation periods before removal, except when a change
+is required to address a security or correctness issue.
+
+## CI as the compatibility contract
+
+The repository intentionally separates compatibility concerns into dedicated lanes:
+
+- Python 3.11/3.12/3.13 core tests;
+- declared minimum dependency tests;
+- Arrow streaming tests;
+- DuckDB interoperability tests;
+- Polars-through-Arrow protocol tests;
+- Windows/macOS public smoke tests;
+- native Rust bridge checks;
+- plugin-provider install/discovery tests;
+- package/wheel validation;
+- strict documentation build;
+- performance guardrails;
+- CodeQL security analysis.
+
+A documented compatibility claim should ideally have a corresponding automated lane
+before it is treated as a project promise.
diff --git a/examples/check_plugin/README.md b/examples/check_plugin/README.md
new file mode 100644
index 0000000..d4f4888
--- /dev/null
+++ b/examples/check_plugin/README.md
@@ -0,0 +1,49 @@
+# FrameVitals check plugin example
+
+This directory is a minimal installable third-party package that contributes custom
+checks to FrameVitals through standard Python entry points.
+
+It is intentionally separate from the main `framevitals` package so the example tests
+the same packaging/discovery boundary that an external project would use.
+
+## Install beside a FrameVitals checkout
+
+From the repository root:
+
+```bash
+python -m pip install -e .
+python -m pip install -e examples/check_plugin
+```
+
+Then discover the provider checks explicitly:
+
+```python
+import framevitals as fv
+
+checks = fv.discover_checks()
+for check in checks:
+ print(check.name, check.severity)
+```
+
+Use them directly or inside the quality gate:
+
+```python
+result = fv.gate(dataframe, custom_checks=checks)
+print(result.summary_text())
+```
+
+## Entry-point contract
+
+The example declares:
+
+```toml
+[project.entry-points."framevitals.checks"]
+positive_revenue = "framevitals_example_checks:positive_revenue"
+preferred_plan = "framevitals_example_checks:preferred_plan"
+```
+
+Provider packages may export `DataCheck` objects or compatible DataFrame callables.
+FrameVitals does **not** automatically import installed providers; applications opt in
+with `fv.discover_checks()` because loading entry points executes provider code.
+
+See `docs/extending-framevitals.md` for the extension-author guide.
diff --git a/examples/check_plugin/pyproject.toml b/examples/check_plugin/pyproject.toml
new file mode 100644
index 0000000..a560913
--- /dev/null
+++ b/examples/check_plugin/pyproject.toml
@@ -0,0 +1,22 @@
+[build-system]
+requires = ["setuptools>=77"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "framevitals-example-checks"
+version = "0.1.0"
+description = "Minimal third-party FrameVitals check plugin example."
+requires-python = ">=3.11"
+dependencies = [
+ "framevitals>=0.1,<1",
+]
+
+[project.entry-points."framevitals.checks"]
+positive_revenue = "framevitals_example_checks:positive_revenue"
+preferred_plan = "framevitals_example_checks:preferred_plan"
+
+[tool.setuptools]
+package-dir = {"" = "src"}
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/examples/check_plugin/src/framevitals_example_checks/__init__.py b/examples/check_plugin/src/framevitals_example_checks/__init__.py
new file mode 100644
index 0000000..e9928e0
--- /dev/null
+++ b/examples/check_plugin/src/framevitals_example_checks/__init__.py
@@ -0,0 +1,57 @@
+"""Example third-party checks discovered through FrameVitals entry points."""
+
+from __future__ import annotations
+
+import framevitals as fv
+
+
+@fv.check(
+ "positive revenue",
+ severity="error",
+ description="Revenue cannot be negative.",
+)
+def positive_revenue(dataframe):
+ if "revenue" not in dataframe.columns:
+ return {
+ "passed": False,
+ "message": "Required revenue column is missing.",
+ "details": {"required_column": "revenue"},
+ }
+
+ minimum = float(dataframe["revenue"].min())
+ return {
+ "passed": minimum >= 0,
+ "message": (
+ "Revenue is non-negative."
+ if minimum >= 0
+ else "Negative revenue records were found."
+ ),
+ "details": {"minimum": minimum},
+ }
+
+
+@fv.check(
+ "preferred plan domain",
+ severity="warning",
+ description="Plans should use the preferred product vocabulary.",
+)
+def preferred_plan(dataframe):
+ if "plan" not in dataframe.columns:
+ return {
+ "passed": False,
+ "message": "Plan column is missing.",
+ "details": {"required_column": "plan"},
+ }
+
+ allowed = {"basic", "pro", "enterprise"}
+ observed = set(dataframe["plan"].dropna().astype(str))
+ unexpected = sorted(observed - allowed)
+ return {
+ "passed": not unexpected,
+ "message": (
+ "Plan values use the preferred domain."
+ if not unexpected
+ else "Unexpected plan labels were found."
+ ),
+ "details": {"unexpected": unexpected},
+ }
diff --git a/examples/custom_quality_gate.py b/examples/custom_quality_gate.py
new file mode 100644
index 0000000..e5ac1c2
--- /dev/null
+++ b/examples/custom_quality_gate.py
@@ -0,0 +1,60 @@
+"""Example: combine FrameVitals contracts, drift, and domain-specific checks."""
+
+from __future__ import annotations
+
+import pandas as pd
+
+import framevitals as fv
+
+
+@fv.check(
+ "positive revenue",
+ severity="error",
+ description="Accounting exports must never contain negative recognized revenue.",
+)
+def positive_revenue(df: pd.DataFrame):
+ invalid = int((df["revenue"] < 0).sum())
+ return {
+ "passed": invalid == 0,
+ "message": (
+ "Revenue values are non-negative."
+ if invalid == 0
+ else f"Found {invalid} negative revenue record(s)."
+ ),
+ "details": {"negative_rows": invalid},
+ }
+
+
+@fv.check("preferred latency budget", severity="warning")
+def latency_budget(df: pd.DataFrame):
+ p95 = float(df["latency_ms"].quantile(0.95))
+ return {
+ "passed": p95 <= 250.0,
+ "message": f"p95 latency is {p95:.1f} ms; preferred maximum is 250 ms.",
+ "details": {"p95_latency_ms": p95},
+ }
+
+
+def main() -> None:
+ reference = pd.read_parquet("data/training.parquet")
+ current = pd.read_parquet("data/production.parquet")
+
+ contract = fv.infer_contract(reference)
+ result = fv.gate(
+ current,
+ reference=reference,
+ contract=contract,
+ custom_checks=[positive_revenue, latency_budget],
+ drift_warn_on="moderate",
+ drift_fail_on="severe",
+ )
+
+ print(result.summary_text())
+ result.to_json("framevitals-gate.json")
+
+ if not result.passed:
+ raise SystemExit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/snapshot_history.py b/examples/snapshot_history.py
new file mode 100644
index 0000000..564fefb
--- /dev/null
+++ b/examples/snapshot_history.py
@@ -0,0 +1,34 @@
+"""Example: persist compact FrameVitals monitoring state across recurring runs."""
+
+from __future__ import annotations
+
+import framevitals as fv
+
+
+def main() -> None:
+ report = fv.analyze("data/production.parquet", mode="quick", artifacts=False)
+
+ history = fv.SnapshotHistory(".framevitals/history")
+ path = history.add(report, label="nightly")
+ print(f"Stored snapshot: {path}")
+
+ latest = history.latest()
+ if latest is not None:
+ print(f"Latest fingerprint: {latest['fingerprint']}")
+
+ if len(history) >= 2:
+ change = history.compare_latest()
+ print(f"Health delta: {change['health_delta']}")
+ print(f"New findings: {change['findings']['new']}")
+
+ for point in history.timeline()[-5:]:
+ print(
+ point["created_at"],
+ point["health_score"],
+ point["ml_readiness_score"],
+ point["finding_count"],
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mkdocs.yml b/mkdocs.yml
new file mode 100644
index 0000000..7c247ea
--- /dev/null
+++ b/mkdocs.yml
@@ -0,0 +1,39 @@
+site_name: FrameVitals
+site_description: Source-aware data health, drift, contracts, and quality gates for tabular pipelines.
+repo_name: parthdongre/FrameVitals
+repo_url: https://github.com/parthdongre/FrameVitals
+edit_uri: edit/dev/docs/
+
+strict: true
+
+theme:
+ name: material
+ features:
+ - navigation.sections
+ - navigation.top
+ - content.code.copy
+ - search.suggest
+
+nav:
+ - Home: index.md
+ - Source-aware execution: source-execution.md
+ - Execution provenance: execution-provenance.md
+ - Result objects: result-objects.md
+ - Quality gates and custom checks: quality-gates.md
+ - Monitoring snapshots: monitoring.md
+ - Extending FrameVitals: extending-framevitals.md
+ - Stability and compatibility: stability.md
+
+markdown_extensions:
+ - admonition
+ - attr_list
+ - tables
+ - toc:
+ permalink: true
+ - pymdownx.highlight:
+ anchor_linenums: true
+ - pymdownx.inlinehilite
+ - pymdownx.superfences
+
+plugins:
+ - search
diff --git a/pyproject.toml b/pyproject.toml
index 5e35283..f661a15 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "framevitals"
-version = "0.1.0"
+version = "0.2.0"
description = "Data quality, drift detection, anomaly analysis, and ML-readiness diagnostics for pandas and tabular data."
readme = "README.md"
requires-python = ">=3.11"
@@ -31,21 +31,25 @@ classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Information Analysis",
+ "Typing :: Typed",
]
dependencies = [
"pandas>=2.2",
"numpy>=1.26",
- "matplotlib>=3.8",
- "seaborn>=0.13",
- "openpyxl>=3.1",
- "xlrd>=2.0",
"scipy>=1.13",
"statsmodels>=0.14",
"scikit-learn>=1.5",
- "pydantic>=2.7",
]
[project.optional-dependencies]
+excel = [
+ "openpyxl>=3.1",
+ "xlrd>=2.0",
+]
+plot = [
+ "matplotlib>=3.8",
+ "seaborn>=0.13",
+]
ml = [
"xgboost>=2.0",
"lightgbm>=4.3",
@@ -54,21 +58,40 @@ ml = [
]
ai = [
"ollama>=0.3",
+ "pydantic>=2.7",
]
web = [
"flask>=3.0",
"gunicorn>=22.0",
"werkzeug>=3.0",
]
+arrow = [
+ "pyarrow>=25.0,<26",
+]
+duckdb = [
+ "duckdb>=1.4",
+ "pyarrow>=25.0,<26",
+]
+docs = [
+ "mkdocs",
+ "mkdocs-material",
+]
all = [
+ "openpyxl>=3.1",
+ "xlrd>=2.0",
+ "matplotlib>=3.8",
+ "seaborn>=0.13",
"xgboost>=2.0",
"lightgbm>=4.3",
"pyod>=2.0",
"shap>=0.45",
"ollama>=0.3",
+ "pydantic>=2.7",
"flask>=3.0",
"gunicorn>=22.0",
"werkzeug>=3.0",
+ "pyarrow>=25.0,<26",
+ "duckdb>=1.4",
]
dev = [
"pytest>=8.2",
@@ -77,13 +100,19 @@ dev = [
"build>=1.2",
"twine>=5.1",
"ruff>=0.6",
+ "pre-commit>=4.0",
+ "openpyxl>=3.1",
+ "xlrd>=2.0",
+ "matplotlib>=3.8",
+ "seaborn>=0.13",
+ "pydantic>=2.7",
]
[project.scripts]
framevitals = "framevitals.cli:main"
[project.urls]
-Documentation = "https://github.com/parthdongre/FrameVitals#readme"
+Documentation = "https://github.com/parthdongre/FrameVitals/tree/main/docs"
Repository = "https://github.com/parthdongre/FrameVitals"
Issues = "https://github.com/parthdongre/FrameVitals/issues"
Changelog = "https://github.com/parthdongre/FrameVitals/blob/main/CHANGELOG.md"
@@ -95,6 +124,13 @@ package-dir = {"" = "src"}
where = ["src"]
include = ["framevitals*"]
+[tool.setuptools.package-data]
+framevitals = ["py.typed"]
+
+[tool.maturin]
+python-source = "src"
+module-name = "framevitals._native"
+
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
new file mode 100644
index 0000000..acd3556
--- /dev/null
+++ b/rust/Cargo.lock
@@ -0,0 +1,957 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "const-random",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "arrow-array"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc9a4a4b2b5ecd0e04df03471661cb61f28bed3c7fd50994715129b01b2edb97"
+dependencies = [
+ "ahash",
+ "arrow-buffer",
+ "arrow-data",
+ "arrow-schema",
+ "chrono",
+ "chrono-tz",
+ "half",
+ "hashbrown",
+ "libc",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "arrow-buffer"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c12b576ef18c1deb80925a248b25ad84f419198d791b8e293fc6aaa60441fe90"
+dependencies = [
+ "bytes",
+ "half",
+ "num-bigint",
+ "num-traits",
+]
+
+[[package]]
+name = "arrow-cast"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68338a9096a5dc9bc11927c58c43a8526d96bf6abd2012ef6c0c9f505991cc79"
+dependencies = [
+ "arrow-array",
+ "arrow-buffer",
+ "arrow-data",
+ "arrow-ord",
+ "arrow-schema",
+ "arrow-select",
+ "atoi",
+ "base64",
+ "chrono",
+ "comfy-table",
+ "half",
+ "lexical-core",
+ "num-traits",
+ "ryu",
+]
+
+[[package]]
+name = "arrow-data"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "723fe4aeed7604e00b9883a465af4ff0a0e6c44c03e41a68c3d1cbc403e0e44d"
+dependencies = [
+ "arrow-buffer",
+ "arrow-schema",
+ "half",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "arrow-ord"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c08dff0686cf23ca4f562803f191ccbeb726dbae6309cd4b4aaf65e0f2c979"
+dependencies = [
+ "arrow-array",
+ "arrow-buffer",
+ "arrow-data",
+ "arrow-schema",
+ "arrow-select",
+]
+
+[[package]]
+name = "arrow-schema"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6fed2ca0d1eade57e811cbe73b98ad50cc08a1183e13b2d2aa43a7df593f40e"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "arrow-select"
+version = "59.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "466b19cf75130b891dc1b23a84b343c714c62c64c9c62e365c76aa0ff90a53fb"
+dependencies = [
+ "ahash",
+ "arrow-array",
+ "arrow-buffer",
+ "arrow-data",
+ "arrow-schema",
+ "num-traits",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "chrono-tz"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
+dependencies = [
+ "chrono",
+ "phf",
+]
+
+[[package]]
+name = "comfy-table"
+version = "7.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
+dependencies = [
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.17",
+ "once_cell",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "framevitals-core"
+version = "0.2.0"
+dependencies = [
+ "arrow-array",
+ "arrow-schema",
+ "rustc-hash",
+]
+
+[[package]]
+name = "framevitals-py"
+version = "0.2.0"
+dependencies = [
+ "framevitals-core",
+ "pyo3",
+ "pyo3-arrow",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "num-traits",
+ "zerocopy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lexical-core"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594"
+dependencies = [
+ "lexical-parse-float",
+ "lexical-parse-integer",
+ "lexical-util",
+ "lexical-write-float",
+ "lexical-write-integer",
+]
+
+[[package]]
+name = "lexical-parse-float"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56"
+dependencies = [
+ "lexical-parse-integer",
+ "lexical-util",
+]
+
+[[package]]
+name = "lexical-parse-integer"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34"
+dependencies = [
+ "lexical-util",
+]
+
+[[package]]
+name = "lexical-util"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17"
+
+[[package]]
+name = "lexical-write-float"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361"
+dependencies = [
+ "lexical-util",
+ "lexical-write-integer",
+]
+
+[[package]]
+name = "lexical-write-integer"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df"
+dependencies = [
+ "lexical-util",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matrixmultiply"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
+dependencies = [
+ "autocfg",
+ "rawpointer",
+]
+
+[[package]]
+name = "ndarray"
+version = "0.17.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
+dependencies = [
+ "matrixmultiply",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "portable-atomic",
+ "portable-atomic-util",
+ "rawpointer",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "numpy"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a5b15d63a5ff39e378daed0e1340d3a5964703ea9712eb09a0dc66fade996f4"
+dependencies = [
+ "half",
+ "libc",
+ "ndarray",
+ "num-complex",
+ "num-integer",
+ "num-traits",
+ "pyo3",
+ "pyo3-build-config",
+ "rustc-hash",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "phf"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
+dependencies = [
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pyo3"
+version = "0.29.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
+dependencies = [
+ "chrono",
+ "chrono-tz",
+ "indexmap",
+ "libc",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+]
+
+[[package]]
+name = "pyo3-arrow"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d5ddf226a2dbf7607570d0657c2bf6fbe299208368b2914f3dd7e7ba0b57688"
+dependencies = [
+ "arrow-array",
+ "arrow-buffer",
+ "arrow-cast",
+ "arrow-data",
+ "arrow-schema",
+ "arrow-select",
+ "chrono",
+ "chrono-tz",
+ "half",
+ "indexmap",
+ "numpy",
+ "pyo3",
+ "thiserror",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.29.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
+dependencies = [
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.29.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.29.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.29.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rawpointer"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.56"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 0000000..77fdd81
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,3 @@
+[workspace]
+members = ["framevitals-core", "framevitals-py"]
+resolver = "2"
diff --git a/rust/framevitals-core/Cargo.toml b/rust/framevitals-core/Cargo.toml
new file mode 100644
index 0000000..3a04db4
--- /dev/null
+++ b/rust/framevitals-core/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "framevitals-core"
+version = "0.2.0"
+edition = "2021"
+description = "Native streaming kernels for FrameVitals"
+license = "MIT"
+publish = false
+
+[lib]
+name = "framevitals_core"
+path = "src/lib.rs"
+
+[features]
+default = []
+arrow = ["dep:arrow-array", "dep:arrow-schema"]
+
+[dependencies]
+arrow-array = { version = "59.1", optional = true }
+arrow-schema = { version = "59.1", optional = true }
+rustc-hash = "2.1"
diff --git a/rust/framevitals-core/src/arrow_scan.rs b/rust/framevitals-core/src/arrow_scan.rs
new file mode 100644
index 0000000..2460068
--- /dev/null
+++ b/rust/framevitals-core/src/arrow_scan.rs
@@ -0,0 +1,184 @@
+//! Arrow-native batch scanning for FrameVitals.
+//!
+//! The scanner consumes Arrow `RecordBatch` values incrementally and produces
+//! mergeable numeric states. It never materializes a Python object per cell and
+//! does not require a complete dataset to reside in memory.
+
+use std::collections::{BTreeMap, BTreeSet};
+
+use arrow_array::{
+ Array, Float32Array, Float64Array, Int32Array, Int64Array, RecordBatch, UInt32Array,
+ UInt64Array,
+};
+use arrow_schema::DataType;
+
+use crate::NumericState;
+
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct BatchNumericStates {
+ pub rows: u64,
+ pub states: BTreeMap,
+ pub skipped_columns: BTreeSet,
+}
+
+impl BatchNumericStates {
+ /// Merge states from another Arrow batch/partition without retaining either
+ /// batch's raw observations.
+ #[must_use]
+ pub fn merge(mut self, other: Self) -> Self {
+ self.rows += other.rows;
+ self.skipped_columns.extend(other.skipped_columns);
+
+ for (name, state) in other.states {
+ self.states
+ .entry(name)
+ .and_modify(|current| *current = current.merge(state))
+ .or_insert(state);
+ }
+ self
+ }
+}
+
+macro_rules! scan_primitive {
+ ($array:expr) => {{
+ let array = $array;
+ let mut state = NumericState::default();
+ for index in 0..array.len() {
+ if array.is_null(index) {
+ state.observe(None);
+ } else {
+ state.observe(Some(array.value(index) as f64));
+ }
+ }
+ state
+ }};
+}
+
+/// Scan the supported primitive numeric columns of an Arrow `RecordBatch`.
+///
+/// Unsupported types are reported in `skipped_columns` rather than failing the
+/// complete batch, allowing future semantic/text/sketch scanners to process
+/// those columns independently.
+pub fn scan_record_batch(batch: &RecordBatch) -> BatchNumericStates {
+ let mut output = BatchNumericStates {
+ rows: batch.num_rows() as u64,
+ ..BatchNumericStates::default()
+ };
+
+ for (index, field) in batch.schema().fields().iter().enumerate() {
+ let name = field.name().clone();
+ let array = batch.column(index);
+
+ let state = match field.data_type() {
+ DataType::Float64 => array
+ .as_any()
+ .downcast_ref::()
+ .map(|values| scan_primitive!(values)),
+ DataType::Float32 => array
+ .as_any()
+ .downcast_ref::()
+ .map(|values| scan_primitive!(values)),
+ DataType::Int64 => array
+ .as_any()
+ .downcast_ref::()
+ .map(|values| scan_primitive!(values)),
+ DataType::Int32 => array
+ .as_any()
+ .downcast_ref::()
+ .map(|values| scan_primitive!(values)),
+ DataType::UInt64 => array
+ .as_any()
+ .downcast_ref::()
+ .map(|values| scan_primitive!(values)),
+ DataType::UInt32 => array
+ .as_any()
+ .downcast_ref::()
+ .map(|values| scan_primitive!(values)),
+ _ => None,
+ };
+
+ match state {
+ Some(state) => {
+ output.states.insert(name, state);
+ }
+ None => {
+ output.skipped_columns.insert(name);
+ }
+ }
+ }
+
+ output
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::Arc;
+
+ use arrow_array::{ArrayRef, Float64Array, Int64Array, RecordBatch, StringArray};
+
+ use super::scan_record_batch;
+
+ fn batch(values: Vec