diff --git a/CHANGELOG.md b/CHANGELOG.md index d5fc975..dc851ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,63 @@ FrameVitals follows semantic versioning while the public API matures. The 0.x se ## Unreleased -No user-facing changes are currently queued beyond 0.2.0. +No user-facing changes are currently queued beyond 0.3.0. + +## 0.3.0 - 2026-09-05 + +FrameVitals 0.3.0 focuses on predictable execution, reusable planning state, monitoring integrity, and safer web/API behavior while retaining the source-aware Arrow/Rust execution architecture introduced in 0.2.0. + +### Highlights + +- Added enforceable per-run resource caps for sampled rows, relationship-pair work, memory-heavy parallelism, and ultra-wide streaming profile width. +- Added a versioned, dependency-aware execution planner with explicit module decisions, resource classes, dependency blocking, runnable order, and topological execution stages. +- Added a reusable per-run `AnalysisContext` for exactly-once planning intermediates, facts, bounded samples, deterministic seeds, and metadata-only provenance. +- Added deterministic `FRAMEVITALS_*` environment overrides with documented precedence and explicit Python API overrides. +- Added snapshot integrity verification so monitoring comparisons reject tampered or internally inconsistent snapshots instead of treating modified metadata as trustworthy state. +- Hardened the Flask/web layer against path traversal, user-derived filesystem paths, exception-detail disclosure, and sensitive path logging. + +### Configuration and execution policy + +- Added `max_sample_rows`, `max_relationship_pairs`, `max_memory_heavy_parallelism`, and `max_streaming_profile_columns` to `AnalysisConfig` and the public `analyze()`/`plan()` Python APIs. +- Resource caps are hard upper bounds: they can only tighten adaptive work and never silently expand work above mode defaults. +- Added deterministic environment overrides for preset, mode, target, artifacts, workers, disabled modules, and all four resource caps. +- Configuration precedence is now deterministic: defaults < preset < environment < config mapping/TOML/`AnalysisConfig` < explicit Python arguments. +- Added the `exhaustive` preset as the forward-looking alias for the deepest built-in policy while retaining `research` compatibility throughout the 0.x series. +- Added per-run `ExecutionPolicy` scoping using context-local state so concurrent analyses can apply different limits without mutating process-global policy. +- Fixed low sample caps so values below ten remain valid hard limits; diagnostics that need more observations now skip individually rather than rejecting the configured budget. + +### Planning and reusable execution state + +- Added planner schema version `1` and structured per-module decisions including status, reason, resource class, dependencies, and blocking information. +- Centralized built-in mode-to-module policy in the planner and made public analysis configuration consume the same source of truth. +- Added dependency propagation so disabling or invalidating an upstream module marks dependent work non-applicable instead of falsely advertising it as runnable. +- Added topologically ordered execution stages plus a flattened runnable-module order for scheduler integration. +- Added `AnalysisContext`, a per-run thread-safe container for resolved config, execution policy, source metadata, authoritative facts, exactly-once cached intermediates, reusable samples, deterministic seed, and provenance metadata. +- Updated `framevitals.plan()` to reuse one context for column roles, dataset signals, execution-budget derivation, and execution-plan construction. +- Preserved compatibility aliases for pre-0.3 internal analysis-mode policy imports while keeping the planner as the authoritative implementation. + +### Monitoring and snapshot integrity + +- Snapshot loading/comparison now validates integrity instead of accepting modified fingerprints or inconsistent serialized state. +- CLI monitoring tests now compare two independently generated valid snapshots rather than mutating snapshot internals. +- Snapshot integrity failures are surfaced as validation errors before drift/monitoring logic runs. + +### Web and API hardening + +- Uploaded filenames no longer determine server filesystem paths; validated extensions are mapped through server-owned suffixes and generated dataset identifiers. +- Upload paths are retained in bounded server-side state instead of being stored in client-side Flask session data. +- Report, cleaned-dataset, and temporary-upload paths are resolved and constrained to managed directories before filesystem operations. +- Server-rendered and JSON endpoints now keep exception details in server logs and return stable generic error messages externally. +- Removed logging of managed upload paths after CodeQL identified them as potentially sensitive data. +- PDF and AI fallback failures no longer expose raw exception text to clients. +- `/api/health` now reports the installed FrameVitals package version instead of a hard-coded web API version string. +- Web analysis uses the same canonical mode policy as the Python API. + +### Compatibility and release quality + +- Python 3.11, 3.12, and 3.13 core lanes, lower-bound dependencies, optional features, Arrow fallback, Arrow/DuckDB and Polars interoperability, native Rust/Python bridge checks, frontend builds, package quality, and CodeQL were exercised during the release-candidate gate. +- The release keeps `research` mode accepted while exposing `exhaustive` as an alias; no public 0.2 API was intentionally removed. +- The package remains an alpha (`0.x`) release: planner stages are now an explicit scheduling contract, while deeper planner control of every materialized/streaming runtime scheduling branch can continue incrementally without changing the 0.3 public planner schema. ## 0.2.0 - 2026-08-17 diff --git a/README.md b/README.md index 92f969f..7f44b66 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ fv.analyze(data, mode="deep") fv.analyze(data, mode="research") ``` -Use `quick` for fast checks and the deeper modes when you want broader statistical or modelling diagnostics. +Use `quick` for fast checks and the deeper modes when you want broader statistical or modelling diagnostics. For preset-driven configuration, `exhaustive` is available as an alias for the deepest built-in preset while `research` remains supported. ## Source-Aware Execution @@ -255,7 +255,7 @@ Training / Analytics / Production The repository includes a reusable GitHub Action: ```yaml -- uses: parthdongre/FrameVitals@v0.2.0 +- uses: parthdongre/FrameVitals@v0.3.0 id: framevitals with: current: data/production.parquet diff --git a/app.py b/app.py index f202231..1fd34e2 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,17 @@ -""" -FrameVitals Flask API and report server. +"""FrameVitals Flask API and local report server. -Provides JSON endpoints for the React dashboard while keeping the existing -server-rendered report routes available for local use. +The web layer intentionally stays thin: it reuses the same mode policy as the +public Python API, bounds in-process cache state, and keeps filesystem/network +side effects inside explicit request handlers. """ +from __future__ import annotations + import math import os +import re +import secrets +from collections import OrderedDict from copy import deepcopy from pathlib import Path from threading import Lock, Thread @@ -15,83 +20,176 @@ from flask import Flask, jsonify, redirect, render_template, request, send_file, session, url_for from werkzeug.exceptions import ClientDisconnected +from framevitals import __version__ as FRAMEVITALS_VERSION 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.loader import UPLOAD_DIR, load_dataset, save_uploaded_file from framevitals.pipeline import run_full_analysis +from framevitals.planner import effective_disabled_modules + + +_VALID_ANALYSIS_MODES = {"quick", "standard", "deep", "research"} +_DATASET_ID_PATTERN = re.compile(r"^[0-9a-f]{12}$") +CLEANED_DIR = Path("cleaned") +REPORT_DIR = Path("reports") + + +def _bounded_positive_env(name: str, default: int, maximum: int) -> int: + raw = os.environ.get(name) + if raw is None: + return default + try: + value = int(raw) + except ValueError: + return default + if value < 1: + return default + return min(value, maximum) + +_WEB_CACHE_LIMIT = _bounded_positive_env("FRAMEVITALS_WEB_CACHE_LIMIT", 16, 128) +_REPORT_JOB_LIMIT = max(_WEB_CACHE_LIMIT, 32) -# --------------------------------------------------------------------------- -# JSON sanitizer -# --------------------------------------------------------------------------- -# Flask's jsonify emits the JavaScript-only tokens NaN, Infinity, -Infinity by -# default. Browsers reject these in strict-parse mode (response.json() and -# 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) + +def _is_nonfinite(value) -> bool: + return isinstance(value, float) and not math.isfinite(value) def _json_safe(value): + """Recursively convert values that strict JSON cannot represent.""" if _is_nonfinite(value): return None if isinstance(value, dict): - return {k: _json_safe(v) for k, v in value.items()} + return {key: _json_safe(item) for key, item in value.items()} if isinstance(value, (list, tuple)): - return [_json_safe(v) for v in value] + return [_json_safe(item) for item in value] if isinstance(value, set): - return [_json_safe(v) for v in value] + return [_json_safe(item) for item in value] return value def safe_jsonify(payload): - """Drop-in replacement for jsonify that is strict-JSON safe.""" + """Return an RFC-8259-safe Flask JSON response.""" return jsonify(_json_safe(payload)) app = Flask(__name__) -app.secret_key = os.environ.get( - "FRAMEVITALS_SECRET_KEY", - "development-only-secret", -) +app.secret_key = os.environ.get("FRAMEVITALS_SECRET_KEY") or secrets.token_urlsafe(32) app.config["MAX_CONTENT_LENGTH"] = 50 * 1024 * 1024 -UPLOAD_DIR = Path("uploads") -UPLOAD_DIR.mkdir(exist_ok=True) - -REPORT_DIR = Path("reports") -REPORT_DIR.mkdir(exist_ok=True) - -ANALYSIS_CACHE = {} -REPORT_JOBS = {} +ANALYSIS_CACHE: OrderedDict[str, dict] = OrderedDict() +UPLOAD_PATHS: OrderedDict[str, str] = OrderedDict() +REPORT_JOBS: OrderedDict[str, dict] = OrderedDict() REPORT_LOCK = Lock() class DotDict(dict): - """Allow dict.key access for Jinja templates.""" + """Allow ``dict.key`` access for legacy Jinja templates.""" __getattr__ = dict.get __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ @staticmethod - def from_dict(d): - if isinstance(d, dict): - return DotDict({k: DotDict.from_dict(v) for k, v in d.items()}) - if isinstance(d, list): - return [DotDict.from_dict(i) for i in d] - return d + def from_dict(value): + if isinstance(value, dict): + return DotDict({key: DotDict.from_dict(item) for key, item in value.items()}) + if isinstance(value, list): + return [DotDict.from_dict(item) for item in value] + return value + + +def _normalize_analysis_mode(value: str | None) -> str: + mode = (value or "standard").strip().lower() + return mode if mode in _VALID_ANALYSIS_MODES else "standard" + + +def _validate_dataset_id(dataset_id: str | None) -> str: + if not isinstance(dataset_id, str) or not _DATASET_ID_PATTERN.fullmatch(dataset_id): + raise ValueError("Invalid dataset identifier.") + return dataset_id + + +def _trusted_generated_path(value: str | Path | None, root: Path) -> Path | None: + """Resolve a server-generated artifact path and keep it inside ``root``.""" + if value is None: + return None + try: + root_path = root.resolve() + candidate = Path(value).resolve() + except (OSError, RuntimeError, TypeError, ValueError): + return None + if candidate.parent != root_path: + return None + return candidate + + +def _is_nonempty_file(path: Path | None) -> bool: + if path is None: + return False + try: + return path.is_file() and path.stat().st_size > 0 + except OSError: + return False + + +def _cache_upload_path(dataset_id: str, file_path: Path) -> None: + dataset_id = _validate_dataset_id(dataset_id) + trusted = _trusted_generated_path(file_path, UPLOAD_DIR) + if trusted is None: + raise ValueError("Upload path escaped the managed upload directory.") + with REPORT_LOCK: + UPLOAD_PATHS[dataset_id] = str(trusted) + UPLOAD_PATHS.move_to_end(dataset_id) + while len(UPLOAD_PATHS) > _WEB_CACHE_LIMIT: + UPLOAD_PATHS.popitem(last=False) + + +def _get_upload_path(dataset_id: str | None) -> Path | None: + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return None + with REPORT_LOCK: + value = UPLOAD_PATHS.get(dataset_id) + if value is not None: + UPLOAD_PATHS.move_to_end(dataset_id) + return _trusted_generated_path(value, UPLOAD_DIR) -def _report_path(dataset_id: str) -> Path: - return REPORT_DIR / f"{dataset_id}_report.pdf" +def _cache_analysis(dataset_id: str, result: dict) -> None: + """Store a defensive result copy while bounding process memory growth.""" + dataset_id = _validate_dataset_id(dataset_id) + with REPORT_LOCK: + ANALYSIS_CACHE[dataset_id] = deepcopy(result) + ANALYSIS_CACHE.move_to_end(dataset_id) + while len(ANALYSIS_CACHE) > _WEB_CACHE_LIMIT: + evicted_id, _ = ANALYSIS_CACHE.popitem(last=False) + REPORT_JOBS.pop(evicted_id, None) + UPLOAD_PATHS.pop(evicted_id, None) + + +def _get_cached_analysis(dataset_id: str | None) -> dict | None: + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return None + with REPORT_LOCK: + result = ANALYSIS_CACHE.get(dataset_id) + if result is None: + return None + ANALYSIS_CACHE.move_to_end(dataset_id) + return deepcopy(result) def _get_report_job(dataset_id: str) -> dict: + dataset_id = _validate_dataset_id(dataset_id) with REPORT_LOCK: - return dict(REPORT_JOBS.get(dataset_id, {})) + job = REPORT_JOBS.get(dataset_id, {}) + if job: + REPORT_JOBS.move_to_end(dataset_id) + return dict(job) def _set_report_job( @@ -100,64 +198,92 @@ def _set_report_job( pdf_path: Path | None = None, error: str | None = None, ) -> dict: + dataset_id = _validate_dataset_id(dataset_id) + trusted_pdf = _trusted_generated_path(pdf_path, REPORT_DIR) if pdf_path else None + if pdf_path is not None and trusted_pdf is None: + raise ValueError("Report path escaped the managed report directory.") job = { "status": status, - "pdf_path": str(pdf_path) if pdf_path else None, + "pdf_path": str(trusted_pdf) if trusted_pdf else None, "error": error, } with REPORT_LOCK: REPORT_JOBS[dataset_id] = job + REPORT_JOBS.move_to_end(dataset_id) + while len(REPORT_JOBS) > _REPORT_JOB_LIMIT: + REPORT_JOBS.popitem(last=False) return dict(job) -def _queue_pdf_generation(dataset_id: str, result: dict | None = None) -> dict: - if result is None: - with REPORT_LOCK: - cached_result = ANALYSIS_CACHE.get(dataset_id) - else: - cached_result = result +def _run_web_analysis( + *, + dataset_id: str, + original_filename: str, + analysis_mode: str, + target_column: str | None, + file_path: Path | None = None, + dataframe=None, + skip_ai: bool = False, +) -> dict: + """Run the materialized web pipeline with the canonical mode policy.""" + mode = _normalize_analysis_mode(analysis_mode) + return run_full_analysis( + dataset_id=_validate_dataset_id(dataset_id), + file_path=file_path, + original_filename=original_filename, + analysis_mode=mode, + target_column=target_column, + dataframe=dataframe, + skip_ai=skip_ai, + disabled_modules=effective_disabled_modules(mode, ()), + ) + +def _queue_pdf_generation(dataset_id: str, result: dict | None = None) -> dict: + dataset_id = _validate_dataset_id(dataset_id) + cached_result = deepcopy(result) if result is not None else _get_cached_analysis(dataset_id) if cached_result is None: - return _set_report_job(dataset_id, "missing", error="Analysis result not available.") + return _set_report_job( + dataset_id, + "missing", + error="Analysis result not available.", + ) current_job = _get_report_job(dataset_id) - if current_job.get("status") in {"queued", "running", "ready"}: + if current_job.get("status") in {"queued", "running"}: return current_job + if current_job.get("status") == "ready": + report_path = _trusted_generated_path(current_job.get("pdf_path"), REPORT_DIR) + if _is_nonempty_file(report_path): + return current_job _set_report_job(dataset_id, "queued") - def worker(): + def worker() -> None: _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)) + pdf_path = generate_pdf_report(cached_result) _set_report_job(dataset_id, "ready", pdf_path=pdf_path) - except Exception as exc: - import traceback - - traceback.print_exc() - _set_report_job(dataset_id, "failed", error=str(exc)) + except Exception: # optional report generation must fail soft + app.logger.exception("PDF generation failed for dataset %s", dataset_id) + _set_report_job( + dataset_id, + "failed", + error="PDF report generation failed. Check server logs for details.", + ) Thread(target=worker, daemon=True).start() return _get_report_job(dataset_id) def _report_status_payload(dataset_id: str) -> dict: + dataset_id = _validate_dataset_id(dataset_id) job = _get_report_job(dataset_id) - report_path = _report_path(dataset_id) - - if job.get("status") == "ready" and report_path.exists() and report_path.stat().st_size > 0: - return { - "status": "ready", - "ready": True, - "pdf_path": str(report_path), - "error": None, - } + report_path = _trusted_generated_path(job.get("pdf_path"), REPORT_DIR) - if not job and report_path.exists() and report_path.stat().st_size > 0: + if job.get("status") == "ready" and _is_nonempty_file(report_path): return { "status": "ready", "ready": True, @@ -166,14 +292,44 @@ def _report_status_payload(dataset_id: str) -> dict: } status = job.get("status") or "pending" + if status == "ready": + status = "missing" return { "status": status, - "ready": status == "ready", - "pdf_path": job.get("pdf_path"), - "error": job.get("error"), + "ready": False, + "pdf_path": None, + "error": job.get("error") if status == "failed" else None, } +def _store_session( + *, + dataset_id: str, + original_filename: str, + analysis_mode: str, + target_column: str | None, +) -> None: + # Keep paths out of client-side session state. The upload path is held in a + # bounded server-side map keyed by the generated dataset identifier. + session["dataset_id"] = _validate_dataset_id(dataset_id) + session["original_filename"] = original_filename + session["analysis_mode"] = _normalize_analysis_mode(analysis_mode) + session["target_column"] = target_column + + +def _unlink_quietly(path: Path | None) -> None: + if path is None: + return + trusted = _trusted_generated_path(path, UPLOAD_DIR) + if trusted is None: + app.logger.warning("Refusing to remove path outside the managed upload directory") + return + try: + trusted.unlink(missing_ok=True) + except OSError: + app.logger.warning("Could not remove temporary upload") + + @app.route("/") def index(): return render_template("index.html") @@ -184,7 +340,7 @@ def analyze(): try: try: uploaded_file = request.files.get("dataset") - analysis_mode = request.form.get("analysis_mode", "standard") + analysis_mode = _normalize_analysis_mode(request.form.get("analysis_mode")) target_column = request.form.get("target_column") or None except ClientDisconnected: return render_template( @@ -199,38 +355,33 @@ def analyze(): return render_template( "error.html", message="Please upload a valid dataset file.", - ) + ), 400 dataset_id, file_path, original_filename = save_uploaded_file(uploaded_file) - - result = run_full_analysis( + _cache_upload_path(dataset_id, file_path) + result = _run_web_analysis( dataset_id=dataset_id, file_path=file_path, original_filename=original_filename, analysis_mode=analysis_mode, target_column=target_column, ) - - with REPORT_LOCK: - ANALYSIS_CACHE[dataset_id] = deepcopy(result) - + _cache_analysis(dataset_id, result) _queue_pdf_generation(dataset_id, result) result["report_status"] = _report_status_payload(dataset_id) - - session["dataset_id"] = dataset_id - session["file_path"] = str(file_path) - session["original_filename"] = original_filename - session["analysis_mode"] = analysis_mode - session["target_column"] = target_column - - 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)) + _store_session( + dataset_id=dataset_id, + original_filename=original_filename, + analysis_mode=analysis_mode, + target_column=target_column, + ) + return render_template("report.html", result=DotDict.from_dict(result)) + except Exception: + app.logger.exception("Server-rendered analysis failed") + return render_template( + "error.html", + message="Dataset analysis failed. Check the server logs for details.", + ), 500 @app.route("/api/analyze", methods=["POST"]) @@ -239,7 +390,7 @@ def api_analyze(): start = perf_counter() try: uploaded_file = request.files.get("dataset") - analysis_mode = request.form.get("analysis_mode", "standard") + analysis_mode = _normalize_analysis_mode(request.form.get("analysis_mode")) target_column = request.form.get("target_column") or None except ClientDisconnected: return jsonify({ @@ -249,25 +400,20 @@ def api_analyze(): ) }), 400 - if analysis_mode not in {"quick", "standard", "deep", "research"}: - analysis_mode = "standard" - if not uploaded_file or uploaded_file.filename == "": return jsonify({"error": "Please upload a valid dataset file."}), 400 dataset_id, file_path, original_filename = save_uploaded_file(uploaded_file) - - result = run_full_analysis( + _cache_upload_path(dataset_id, file_path) + df = load_dataset(file_path) + result = _run_web_analysis( dataset_id=dataset_id, - file_path=file_path, + dataframe=df, original_filename=original_filename, analysis_mode=analysis_mode, target_column=target_column, ) - - df = load_dataset(file_path) elapsed_ms = (perf_counter() - start) * 1000 - payload = build_dashboard_payload( result=result, df=df, @@ -277,66 +423,62 @@ def api_analyze(): target_column=target_column, ) - with REPORT_LOCK: - ANALYSIS_CACHE[dataset_id] = deepcopy(result) - + _cache_analysis(dataset_id, result) _queue_pdf_generation(dataset_id, result) report_status = _report_status_payload(dataset_id) payload["reportStatus"] = report_status payload.setdefault("downloadLinks", {})["reportReady"] = report_status["ready"] payload["downloadLinks"]["reportStatus"] = report_status["status"] - - session["dataset_id"] = dataset_id - session["file_path"] = str(file_path) - session["original_filename"] = original_filename - session["analysis_mode"] = analysis_mode - session["target_column"] = target_column - + _store_session( + dataset_id=dataset_id, + original_filename=original_filename, + analysis_mode=analysis_mode, + target_column=target_column, + ) return safe_jsonify(payload) - - except Exception as exc: - import traceback - - traceback.print_exc() - return jsonify({"error": str(exc)}), 500 + except Exception: + app.logger.exception("API analysis failed") + return jsonify({"error": "Dataset analysis failed."}), 500 @app.route("/ask", methods=["POST"]) def ask(): try: dataset_id = session.get("dataset_id") - file_path = session.get("file_path") original_filename = session.get("original_filename", "dataset") - analysis_mode = session.get("analysis_mode", "standard") + analysis_mode = _normalize_analysis_mode(session.get("analysis_mode")) target_column = session.get("target_column") question = request.form.get("question", "") - if not dataset_id or not file_path: + if not dataset_id: + return redirect(url_for("index")) + dataset_id = _validate_dataset_id(dataset_id) + file_path = _get_upload_path(dataset_id) + if file_path is None: return redirect(url_for("index")) - result = run_full_analysis( - dataset_id=dataset_id, - file_path=Path(file_path), - original_filename=original_filename, - analysis_mode=analysis_mode, - target_column=target_column, - skip_ai=True, - ) - - with REPORT_LOCK: - ANALYSIS_CACHE[dataset_id] = deepcopy(result) + # Reuse the analysis produced by the upload route. The old handler + # reran the entire pipeline for every question, which was needlessly + # expensive and could produce a different result under changed env state. + result = _get_cached_analysis(dataset_id) + if result is None: + result = _run_web_analysis( + dataset_id=dataset_id, + file_path=file_path, + original_filename=original_filename, + analysis_mode=analysis_mode, + target_column=target_column, + skip_ai=True, + ) + _cache_analysis(dataset_id, result) - _queue_pdf_generation(dataset_id, result) result["report_status"] = _report_status_payload(dataset_id) - - # 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)), + df=load_dataset(file_path), analysis_result=result, ) answer = { @@ -356,39 +498,46 @@ def ask(): result["chat_answer"] = answer result["chat_question"] = question - - result_dot = DotDict.from_dict(result) - return render_template("report.html", result=result_dot) - - except Exception as exc: - return render_template("error.html", message=str(exc)) + return render_template("report.html", result=DotDict.from_dict(result)) + except Exception: + app.logger.exception("Server-rendered question answering failed") + return render_template( + "error.html", + message="Question answering failed. Check the server logs for details.", + ), 500 @app.route("/api/ask", methods=["POST"]) def api_ask(): - """JSON endpoint for the agentic Q&A loop. Used by the React frontend.""" + """JSON endpoint for the optional agentic Q&A loop.""" try: body = request.get_json(silent=True) or {} question = (body.get("question") or "").strip() - dataset_id = body.get("dataset_id") or session.get("dataset_id") + session_dataset_id = session.get("dataset_id") + dataset_id = body.get("dataset_id") or session_dataset_id if not question: return jsonify({"error": "Missing 'question' in request body."}), 400 + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return jsonify({"error": "Invalid dataset identifier."}), 400 + if session_dataset_id and dataset_id != session_dataset_id: + return jsonify({"error": "Dataset does not belong to this session."}), 403 - with REPORT_LOCK: - cached_result = ANALYSIS_CACHE.get(dataset_id) - + cached_result = _get_cached_analysis(dataset_id) if cached_result is None: return jsonify({ "error": "No cached analysis was found for this dataset. Run /api/analyze first.", }), 404 - file_path = session.get("file_path") + file_path = _get_upload_path(dataset_id) df = None - if file_path: + if file_path is not None: try: - df = load_dataset(Path(file_path)) + df = load_dataset(file_path) except Exception: + app.logger.warning("Could not reload cached upload for agent analysis") df = None try: @@ -401,8 +550,8 @@ def api_ask(): analysis_result=cached_result, fast=(mode != "full"), ) - except Exception as exc: - response = answer_dataset_question( + except Exception: + fallback = answer_dataset_question( question=question, profile=cached_result["profile"], health=cached_result["health"], @@ -411,8 +560,8 @@ def api_ask(): advanced=cached_result.get("advanced"), ) response = { - "source": response.get("source", "fallback"), - "answer": response.get("answer", str(exc)), + "source": fallback.get("source", "fallback"), + "answer": fallback.get("answer") or "Question answering is unavailable.", "trace": {}, } @@ -423,34 +572,23 @@ def api_ask(): "answer": response.get("answer"), "trace": response.get("trace", {}), }) - - except Exception as exc: - import traceback - - traceback.print_exc() - return jsonify({"error": str(exc)}), 500 + except Exception: + app.logger.exception("API question answering failed") + return jsonify({"error": "Question answering failed."}), 500 @app.route("/api/ai-report", methods=["POST"]) def api_ai_report(): - """ - On-demand AI report generation. The pipeline skips this phase by default - (set FRAMEVITALS_ANALYZE_AI=1 to run it during /api/analyze). The frontend - calls this endpoint when the user clicks "Generate AI report" on the - AI Report tab. - - Body: {"dataset_id": "..."} - """ + """Generate the optional AI narrative for an existing cached analysis.""" try: body = request.get_json(silent=True) or {} dataset_id = body.get("dataset_id") or session.get("dataset_id") + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return jsonify({"error": "Invalid dataset identifier."}), 400 - if not dataset_id: - return jsonify({"error": "Missing dataset_id."}), 400 - - with REPORT_LOCK: - cached = ANALYSIS_CACHE.get(dataset_id) - + cached = _get_cached_analysis(dataset_id) if cached is None: return jsonify({ "error": "No cached analysis for this dataset. Re-run /api/analyze." @@ -468,64 +606,52 @@ def api_ai_report(): column_roles_summary=cached.get("roles_summary") or {}, dataset_signals=cached.get("dataset_signals") or {}, ) - except Exception as exc: - ai_report = {"source": f"error: {exc}", "text": str(exc)} - - with REPORT_LOCK: - cached["ai_report"] = ai_report - ANALYSIS_CACHE[dataset_id] = cached + except Exception: + app.logger.exception("AI report model generation failed") + ai_report = { + "source": "error", + "text": "AI report generation failed. Check server logs for details.", + } + cached["ai_report"] = ai_report + _cache_analysis(dataset_id, cached) return safe_jsonify(ai_report) - - except Exception as exc: - import traceback - - traceback.print_exc() - return jsonify({"error": str(exc)}), 500 + except Exception: + app.logger.exception("AI report generation failed") + return jsonify({"error": "AI report generation failed."}), 500 @app.route("/api/health") def api_health(): - """ - Backend status check used by the live console to populate the status row. - - Reports: - - Flask backend reachable (always true if this returns) - - Ollama reachable (best-effort socket probe) - - OpenRouter API key present - - Cached analyses count - - Pipeline modules loaded - """ import socket def _probe(host: str, port: int, timeout: float = 0.4) -> bool: try: with socket.create_connection((host, port), timeout=timeout): return True - except Exception: + except OSError: return False - ollama_reachable = _probe("127.0.0.1", 11434) - openrouter_configured = bool(os.environ.get("OPENROUTER_API_KEY", "").strip()) + with REPORT_LOCK: + cached_count = len(ANALYSIS_CACHE) + job_count = len(REPORT_JOBS) return jsonify({ "flask": True, - "ollama_reachable": ollama_reachable, - "openrouter_configured": openrouter_configured, - "cached_analyses": len(ANALYSIS_CACHE), - "pdf_jobs": len(REPORT_JOBS), - "version": "v3", + "ollama_reachable": _probe("127.0.0.1", 11434), + "openrouter_configured": bool( + os.environ.get("OPENROUTER_API_KEY", "").strip() + ), + "cached_analyses": cached_count, + "pdf_jobs": job_count, + "version": FRAMEVITALS_VERSION, }) @app.route("/api/compare", methods=["POST"]) def api_compare(): - """ - Compare two uploaded datasets. Multipart form fields: - reference: file (older / training) - current: file (newer / production) - columns: optional comma-separated list to restrict comparison - """ + ref_path = None + cur_path = None try: try: ref_file = request.files.get("reference") @@ -538,12 +664,11 @@ def api_compare(): if not cur_file or not cur_file.filename: return jsonify({"error": "Missing 'current' file."}), 400 - _, ref_path, _ = save_uploaded_file(ref_file) - _, cur_path, _ = save_uploaded_file(cur_file) - + _, ref_path, ref_name = save_uploaded_file(ref_file) + _, cur_path, cur_name = save_uploaded_file(cur_file) columns_param = request.form.get("columns", "").strip() columns = ( - [c.strip() for c in columns_param.split(",") if c.strip()] + [column.strip() for column in columns_param.split(",") if column.strip()] if columns_param else None ) @@ -551,26 +676,20 @@ def api_compare(): 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 + report["reference_filename"] = ref_name + report["current_filename"] = cur_name return safe_jsonify(report) - - except Exception as exc: - import traceback - - traceback.print_exc() - return jsonify({"error": str(exc)}), 500 + except Exception: + app.logger.exception("Dataset comparison failed") + return jsonify({"error": "Dataset comparison failed."}), 500 + finally: + _unlink_quietly(ref_path) + _unlink_quietly(cur_path) @app.route("/api/compare-self", methods=["POST"]) def api_compare_self(): - """ - Compare a single dataset against itself by splitting on a date column. - Multipart fields: - dataset: file - date_column: str (column to split on) - ratio: float in (0, 1), default 0.5 - """ + ds_path = None try: try: ds_file = request.files.get("dataset") @@ -592,7 +711,6 @@ def api_compare_self(): _, ds_path, ds_name = save_uploaded_file(ds_file) df = load_dataset(ds_path) - try: df_ref, df_cur = split_by_date(df, date_column, ratio=ratio) except ValueError as exc: @@ -606,24 +724,35 @@ def api_compare_self(): report["split_by"] = date_column report["split_ratio"] = ratio return safe_jsonify(report) - - except Exception as exc: - import traceback - - traceback.print_exc() - return jsonify({"error": str(exc)}), 500 + except Exception: + app.logger.exception("Self-comparison failed") + return jsonify({"error": "Dataset self-comparison failed."}), 500 + finally: + _unlink_quietly(ds_path) @app.route("/download-cleaned/") def download_cleaned(dataset_id): - path = Path("cleaned") / f"{dataset_id}_cleaned.csv" - if path.exists(): + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return render_template("error.html", message="Invalid dataset identifier."), 400 + + result = _get_cached_analysis(dataset_id) + cleaning = result.get("cleaning", {}) if isinstance(result, dict) else {} + output_value = cleaning.get("output_path") if isinstance(cleaning, dict) else None + path = _trusted_generated_path(output_value, CLEANED_DIR) + if _is_nonempty_file(path): return send_file(path, as_attachment=True) - return render_template("error.html", message="Cleaned dataset not found.") + return render_template("error.html", message="Cleaned dataset not found."), 404 @app.route("/api/report-status/") def api_report_status(dataset_id): + try: + dataset_id = _validate_dataset_id(dataset_id) + except ValueError: + return jsonify({"error": "Invalid dataset identifier."}), 400 status = _report_status_payload(dataset_id) status["downloadUrl"] = f"/download-report/{dataset_id}" status["dataset_id"] = dataset_id @@ -633,38 +762,49 @@ def api_report_status(dataset_id): @app.route("/download-report/") def download_report(dataset_id): try: - pdf_path = _report_path(dataset_id) + dataset_id = _validate_dataset_id(dataset_id) report_status = _report_status_payload(dataset_id) + pdf_path = _trusted_generated_path(report_status.get("pdf_path"), REPORT_DIR) - if report_status["ready"] and pdf_path.exists() and pdf_path.stat().st_size > 0: + if report_status["ready"] and _is_nonempty_file(pdf_path): return send_file(pdf_path, as_attachment=True) - result = ANALYSIS_CACHE.get(dataset_id) + result = _get_cached_analysis(dataset_id) if result is not None: _queue_pdf_generation(dataset_id, result) report_status = _report_status_payload(dataset_id) - - if report_status["ready"] and pdf_path.exists() and pdf_path.stat().st_size > 0: + pdf_path = _trusted_generated_path(report_status.get("pdf_path"), REPORT_DIR) + if report_status["ready"] and _is_nonempty_file(pdf_path): return send_file(pdf_path, as_attachment=True) message = ( "The PDF report is generating in the background. " - "Please try again in a few seconds." + "Please try again shortly." ) if report_status["status"] == "failed": - message = f"PDF generation failed: {report_status.get('error', 'Unknown error')}" + message = "PDF report generation failed. Check the server logs for details." elif result is None: message = ( "No cached analysis was found for this dataset. " "Please run analysis again first." ) - return render_template("error.html", message=message), 202 - - except Exception as exc: - return render_template("error.html", message=str(exc)) + except ValueError: + return render_template("error.html", message="Invalid dataset identifier."), 400 + except Exception: + app.logger.exception("PDF download failed") + return render_template( + "error.html", + message="PDF download failed. Check the server logs for details.", + ), 500 if __name__ == "__main__": - app.run(debug=True, host="127.0.0.1", port=5055) + debug = os.environ.get("FRAMEVITALS_DEBUG", "0").strip().lower() in { + "1", + "true", + "yes", + "on", + } + app.run(debug=debug, host="127.0.0.1", port=5055) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..0494fed --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,67 @@ +# Runtime configuration + +FrameVitals can be configured from Python mappings or a TOML file passed with +`config=` / `--config`. Version 0.3 adds enforceable resource caps on top of the +existing adaptive execution modes. + +```toml +[analysis] +preset = "exhaustive" +target = "churn" +artifacts = false + +[resources] +workers = 4 +max_sample_rows = 5000 +max_relationship_pairs = 20 +max_memory_heavy_parallelism = 1 +max_streaming_profile_columns = 64 + +[modules] +modeling = false +ai = false +``` + +## Resource caps + +The `max_*` settings are hard upper bounds. They only reduce work selected by a +mode; they never increase a mode's built-in sampling, relationship, streaming, +or parallelism budgets. + +- `max_sample_rows` caps bounded row samples used by expensive diagnostics. +- `max_relationship_pairs` caps pairwise relationship/statistical work. +- `max_memory_heavy_parallelism` caps concurrent memory-heavy analysis tasks. +- `max_streaming_profile_columns` caps full-stream profiling width for streaming + sources and can force deterministic schema projection before scanning. + +`framevitals plan data.csv --config framevitals.toml` shows the resolved resource +policy and the effective execution budget before heavy analysis starts. + +## Presets + +The built-in presets are `quick`, `standard`, `deep`, `research`, `exhaustive`, +and `ci`. `exhaustive` currently maps to the established `research` execution +mode while the 0.x public mode names remain backward compatible. + +## Environment overrides + +The runtime layer recognizes `FRAMEVITALS_PRESET`, `FRAMEVITALS_MODE`, +`FRAMEVITALS_TARGET`, `FRAMEVITALS_ARTIFACTS`, `FRAMEVITALS_WORKERS`, +`FRAMEVITALS_DISABLED_MODULES`, and environment forms of every resource cap +(for example `FRAMEVITALS_MAX_SAMPLE_ROWS`). Disabled modules are a +comma-separated list. Boolean values accept `true/false`, `1/0`, `yes/no`, and +`on/off`. + +## Precedence + +Configuration currently resolves in this order, from lowest to highest: + +1. FrameVitals defaults +2. preset defaults +3. `FRAMEVITALS_*` environment overrides +4. config mapping/TOML values +5. explicit Python or CLI runtime arguments + +Module toggles can be set under `[modules]`. Resource caps live under +`[resources]`; they are intentionally shared by `analyze()` and `plan()` so the +previewed budget matches execution. diff --git a/docs/execution-context.md b/docs/execution-context.md new file mode 100644 index 0000000..ea762dc --- /dev/null +++ b/docs/execution-context.md @@ -0,0 +1,44 @@ +# Execution context + +FrameVitals 0.3 introduces a per-run `AnalysisContext` as the shared state holder for +planning and, progressively, analysis execution. The goal is to stop independent +modules from rediscovering the same profile, roles, signals, samples, and planning +facts through repeated scans. + +The context contains: + +- resolved runtime configuration and enforceable resource policy; +- source identity and true dataset shape; +- authoritative facts such as profile, roles, signals, budget, and execution plan; +- a thread-safe reusable intermediate cache; +- named bounded samples that can be shared by downstream modules; +- a deterministic run seed. + +## Cache semantics + +`AnalysisContext.get_or_compute(key, factory)` computes a cache entry at most once +per context, even when scheduler threads request it concurrently. Cache state is +strictly per run; no process-global analysis cache is introduced. + +Known intermediates can also be inserted with `cache_value()`. Authoritative facts +use `set_fact()` / `require_fact()`, which reject accidental replacement unless the +caller explicitly opts into `overwrite=True`. + +## Sample reuse + +`store_sample()` retains a named sample object for downstream modules. Context +metadata records only sample shape/provenance; it never serializes the raw sample. +That keeps `plan()` and future result provenance safe to inspect while allowing the +runtime to reuse the actual in-memory object. + +## Current integration + +`framevitals.plan()` now constructs one context and uses its cache for role +inference, dataset signals, execution-budget derivation, and planner construction. +The returned plan includes `execution_context` metadata with schema version, fact +names, cache statistics, and planning-sample provenance. + +The next execution integration is to create the same context inside full materialized +and streaming analyses, populate it from their already-computed structural facts, +and make module schedulers request cached facts/samples from it instead of owning +parallel state. diff --git a/docs/planner-scheduling.md b/docs/planner-scheduling.md new file mode 100644 index 0000000..238feaf --- /dev/null +++ b/docs/planner-scheduling.md @@ -0,0 +1,31 @@ +# Planner scheduling contract + +FrameVitals 0.3 planner output is structured so it can be consumed by a runtime +scheduler rather than only displayed to users. + +## Dependency blocking + +Runtime modules declare `depends_on`. After applicability and configuration rules +are evaluated, the planner propagates blocked dependencies downstream. A dependent +module is changed to `not_applicable` and receives a `blocked_by` list plus a reason. + +For example, explicitly disabling `target_intelligence` in a research run also blocks +`modeling`, which then blocks `explainability`. The planner will not advertise work +that cannot satisfy its declared prerequisites. + +## Execution stages + +`selection.execution_modules.execution_stages` contains topologically ordered stages. +Each stage contains: + +- `stage`: zero-based stage index; +- `modules`: modules whose dependencies are satisfied by earlier stages; +- `resource_classes`: coarse resource classes represented by those modules. + +`runnable_modules` is the flattened stage order and includes both `run` and +`conditional` decisions. Conditional modules still require their runtime condition +to become true—for example, explainability requires modeling to produce a suitable +winner. + +This is the scheduling interface that the materialized and streaming executors can +adopt incrementally while preserving the existing result schema. diff --git a/docs/planning.md b/docs/planning.md new file mode 100644 index 0000000..adfed72 --- /dev/null +++ b/docs/planning.md @@ -0,0 +1,43 @@ +# Execution planning + +FrameVitals 0.3 introduces a versioned planner contract behind `framevitals.plan()`. +The planner separates three questions that were previously mixed together: + +1. which analyses are applicable to the observed dataset signals; +2. which runtime modules are disabled by mode or explicit configuration; +3. which modules are expected to run, are not applicable, or remain conditional. + +A plan now includes `selection.planner_schema_version` and structured execution-module +decisions under `selection.execution_modules.decisions`. + +```python +plan = framevitals.plan(df, mode="standard", target="churn") + +print(plan.planner_schema_version) +print(plan.module_decisions["anomaly_detection"]) +``` + +Each module decision contains: + +- `status`: `run`, `conditional`, `not_applicable`, `disabled_by_mode`, or + `disabled_by_config`; +- `reason`: a human-readable explanation; +- `resource_class`: a stable coarse cost category such as `bounded_cpu` or + `memory_heavy`; +- `depends_on`: runtime module dependencies. + +The compatibility fields `execution_modules.disabled` and +`execution_modules.enabled` remain available. `effective_disabled` additionally +shows the union of explicit configuration and built-in mode policy. + +## Planner ownership in 0.3 + +The built-in mode-to-module policy is now centralized in `framevitals.planner` and +is also used by the public `analyze()` dispatcher. This removes one source of drift +between planning and execution while keeping current pipeline behavior stable. + +Signal-based `not_applicable` decisions are currently explanatory: the next planner +integration step is for both the materialized and streaming schedulers to consume +those decisions directly after their shared structural facts are available. This is +intentional so FrameVitals does not add a second pre-analysis scan just to make the +planner authoritative. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c18d192..7faf511 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,7 +21,7 @@ "tailwind-merge": "^2.5.5" }, "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.13.4", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", @@ -1236,49 +1236,49 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1293,9 +1293,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1310,9 +1310,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1327,9 +1327,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1344,9 +1344,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1361,9 +1361,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], @@ -1381,9 +1381,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], @@ -1401,9 +1401,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], @@ -1421,9 +1421,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], @@ -1441,9 +1441,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1459,21 +1459,87 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1488,9 +1554,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1505,15 +1571,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -1824,9 +1890,9 @@ "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.21.2", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", - "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2570,9 +2636,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, diff --git a/frontend/package.json b/frontend/package.json index 158eed5..7c94634 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,7 +22,7 @@ "tailwind-merge": "^2.5.5" }, "devDependencies": { - "@tailwindcss/vite": "^4.0.0", + "@tailwindcss/vite": "^4.3.3", "@types/node": "^22.13.4", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 41597cc..c034fc4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,6 @@ /** * Single-source HTTP helpers. Everything that calls the Flask backend goes - * through these so error semantics (status, JSON body parsing, abort signals) + * through these so status handling, JSON parsing, abort signals, and timeouts * stay consistent across hooks and pages. */ @@ -27,75 +27,82 @@ export class ApiError extends Error { interface RequestOptions { signal?: AbortSignal; - /** - * Optional headers to merge with the defaults. `Content-Type` is set - * automatically for JSON bodies. - */ + /** Optional headers to merge with the request defaults. */ headers?: Record; - /** - * Hard timeout in milliseconds. Defaults to no timeout (the browser's own - * default, typically 5 minutes). Use a large value for analyze, smaller - * for ask / health. - */ + /** Hard timeout in milliseconds. Omit to use the browser/network default. */ timeoutMs?: number; } async function parseBody(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; - // Prefer the streaming-friendly response.json() when we know it's JSON; - // fall back to text() (then JSON.parse) for anything else so we can still - // surface a backend error page that came back as text/html. - if (contentType.includes("application/json")) { - // Read the body as text first so we can both parse JSON *and* preserve - // the raw payload for error diagnostics. Calling response.json() and - // then trying to read text() afterwards (even via clone) is fragile — - // an empty body makes json() throw and our recovery path was returning - // an empty {} that callers were treating as success. - const raw = await response.text(); - if (!raw) { - throw new ApiError( - response.status || 0, - { error: "Empty response body" }, - "Empty response body", - ); - } - try { - return JSON.parse(raw) as T; - } catch (err) { - throw new ApiError( - response.status || 0, - { error: raw }, - `Failed to parse JSON: ${(err as Error).message}`, - ); - } + const raw = await response.text(); + + if (!contentType.includes("application/json")) { + const message = raw || "Expected a JSON response from the FrameVitals API."; + throw new ApiError( + response.status || 0, + { error: message }, + `Unexpected response content type: ${contentType || "unknown"}`, + ); } - const raw = await response.text(); - if (!raw) return {} as T; - return { error: raw } as unknown as T; + if (!raw) { + throw new ApiError( + response.status || 0, + { error: "Empty response body" }, + "Empty response body", + ); + } + + try { + return JSON.parse(raw) as T; + } catch (err) { + throw new ApiError( + response.status || 0, + { error: raw }, + `Failed to parse JSON: ${(err as Error).message}`, + ); + } } async function request(url: string, init: RequestInit, opts: RequestOptions = {}): Promise { - // Compose the caller's signal with our optional timeout-driven abort. let timeoutHandle: ReturnType | null = null; + let removeAbortListener: (() => void) | null = null; let signal: AbortSignal | undefined = opts.signal; + if (opts.timeoutMs && opts.timeoutMs > 0) { - const ctrl = new AbortController(); - timeoutHandle = setTimeout(() => ctrl.abort(), opts.timeoutMs); + const controller = new AbortController(); + timeoutHandle = setTimeout(() => controller.abort(), opts.timeoutMs); + if (opts.signal) { - // If the caller already provided a signal, hook into it too. - const onAbort = () => ctrl.abort(); - if (opts.signal.aborted) ctrl.abort(); - else opts.signal.addEventListener("abort", onAbort, { once: true }); + const callerSignal = opts.signal; + const onAbort = () => controller.abort(); + if (callerSignal.aborted) { + controller.abort(); + } else { + callerSignal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => callerSignal.removeEventListener("abort", onAbort); + } } - signal = ctrl.signal; + signal = controller.signal; } + const cleanup = () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + if (removeAbortListener) { + removeAbortListener(); + removeAbortListener = null; + } + }; + let response: Response; try { response = await fetch(url, { ...init, signal }); } catch (err) { - if (timeoutHandle) clearTimeout(timeoutHandle); + cleanup(); if (err instanceof DOMException && err.name === "AbortError") { throw new ApiError( 0, @@ -105,8 +112,7 @@ async function request(url: string, init: RequestInit, opts: RequestOptions = } throw err; } - - if (timeoutHandle) clearTimeout(timeoutHandle); + cleanup(); const body = await parseBody(response); if (!response.ok) { @@ -147,8 +153,7 @@ export function postFormData(url: string, fd: FormData, opts: RequestOptions url, { method: "POST", - // NOTE: do NOT set Content-Type; the browser fills in the multipart - // boundary automatically when the body is a FormData instance. + // Do not set Content-Type: the browser supplies the multipart boundary. headers: { Accept: "application/json", ...(opts.headers ?? {}) }, body: fd, }, diff --git a/frontend/src/pages/report/_placeholder.tsx b/frontend/src/pages/report/_placeholder.tsx deleted file mode 100644 index 0659d73..0000000 --- a/frontend/src/pages/report/_placeholder.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ComponentType, ReactNode } from "react"; -import { EmptyState } from "@/components/ui/EmptyState"; -import type { TabComponentProps } from "./tabRegistry"; - -interface PlaceholderProps { - title?: string; - hint?: ReactNode; -} - -/** - * Tiny shared component used by the tab placeholders during phases 7-10. - * Each phase replaces the relevant tab module with a real implementation; - * having a single shared placeholder keeps the build green at every step. - */ -export function TabPlaceholder({ title, hint }: PlaceholderProps) { - return ( - - ); -} - -/** - * Wraps a placeholder into a default-exported tab module so the lazy loader - * in `tabRegistry.ts` can pull it in without ceremony. - */ -export function makePlaceholderTab( - title: string, - hint?: ReactNode, -): ComponentType { - return function PlaceholderTab(_props: TabComponentProps) { - return ; - }; -} diff --git a/frontend/vercel.json b/frontend/vercel.json index 46c5224..d381c01 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -2,8 +2,8 @@ "rewrites": [ { "source": "/api/(.*)", "destination": "https://datalens-ai-backend.onrender.com/api/$1" }, { "source": "/static/(.*)", "destination": "https://datalens-ai-backend.onrender.com/static/$1" }, - { "source": "/download-cleaned", "destination": "https://datalens-ai-backend.onrender.com/download-cleaned" }, - { "source": "/download-report", "destination": "https://datalens-ai-backend.onrender.com/download-report" }, + { "source": "/download-cleaned/(.*)", "destination": "https://datalens-ai-backend.onrender.com/download-cleaned/$1" }, + { "source": "/download-report/(.*)", "destination": "https://datalens-ai-backend.onrender.com/download-report/$1" }, { "source": "/(.*)", "destination": "/index.html" } ] } diff --git a/mkdocs.yml b/mkdocs.yml index 7c247ea..1838dca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,10 @@ theme: nav: - Home: index.md + - Configuration: configuration.md + - Planning: planning.md + - Planner scheduling: planner-scheduling.md + - Execution context: execution-context.md - Source-aware execution: source-execution.md - Execution provenance: execution-provenance.md - Result objects: result-objects.md diff --git a/pyproject.toml b/pyproject.toml index f661a15..3d34c54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "framevitals" -version = "0.2.0" +version = "0.3.0" description = "Data quality, drift detection, anomaly analysis, and ML-readiness diagnostics for pandas and tabular data." readme = "README.md" requires-python = ">=3.11" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index acd3556..40bb324 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -262,7 +262,7 @@ checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "framevitals-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "arrow-array", "arrow-schema", @@ -271,7 +271,7 @@ dependencies = [ [[package]] name = "framevitals-py" -version = "0.2.0" +version = "0.3.0" dependencies = [ "framevitals-core", "pyo3", diff --git a/rust/framevitals-core/Cargo.toml b/rust/framevitals-core/Cargo.toml index 3a04db4..14e67a3 100644 --- a/rust/framevitals-core/Cargo.toml +++ b/rust/framevitals-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "framevitals-core" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "Native streaming kernels for FrameVitals" license = "MIT" diff --git a/rust/framevitals-py/Cargo.toml b/rust/framevitals-py/Cargo.toml index 3c5325a..c35c473 100644 --- a/rust/framevitals-py/Cargo.toml +++ b/rust/framevitals-py/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "framevitals-py" -version = "0.2.0" +version = "0.3.0" edition = "2021" description = "PyO3 bridge for FrameVitals native kernels" license = "MIT" diff --git a/src/framevitals/__init__.py b/src/framevitals/__init__.py index 4039a1a..58b7b31 100644 --- a/src/framevitals/__init__.py +++ b/src/framevitals/__init__.py @@ -25,7 +25,7 @@ from framevitals.checks import DataCheck from framevitals.cleaning_plan import CleaningPlan -__version__ = "0.2.0" +__version__ = "0.3.0" def __getattr__(name: str): @@ -174,6 +174,10 @@ def analyze( preset: str | None = None, config: Any = None, disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, ) -> AnalysisResult: """Analyze a supported tabular source through the canonical dispatcher.""" from framevitals.analysis_api import analyze as _analyze @@ -187,6 +191,10 @@ def analyze( preset=preset, config=config, disabled_modules=disabled_modules, + max_sample_rows=max_sample_rows, + max_relationship_pairs=max_relationship_pairs, + max_memory_heavy_parallelism=max_memory_heavy_parallelism, + max_streaming_profile_columns=max_streaming_profile_columns, ) @@ -199,6 +207,10 @@ def plan( preset: str | None = None, config: Any = None, disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, ) -> AnalysisPlan: """Preview analyses, scale policy, and execution constraints without running them.""" from framevitals.planning_api import plan as _plan @@ -211,6 +223,10 @@ def plan( preset=preset, config=config, disabled_modules=disabled_modules, + max_sample_rows=max_sample_rows, + max_relationship_pairs=max_relationship_pairs, + max_memory_heavy_parallelism=max_memory_heavy_parallelism, + max_streaming_profile_columns=max_streaming_profile_columns, ) diff --git a/src/framevitals/ai_insights.py b/src/framevitals/ai_insights.py index 186a13d..c30ae39 100644 --- a/src/framevitals/ai_insights.py +++ b/src/framevitals/ai_insights.py @@ -1,8 +1,8 @@ -""" -AI Insights -=========== -OpenRouter-first AI report generation with Ollama fallback and a deterministic -rule-based fallback when no model is reachable. +"""Optional AI interpretation with deterministic local fallbacks. + +OpenRouter is attempted first, then Ollama, then a statistics-only fallback. +Environment-backed endpoint metadata is read at call time so test/deployment +configuration is not frozen when the module is imported. """ from __future__ import annotations @@ -12,17 +12,34 @@ import urllib.error import urllib.request + OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" -DEFAULT_OPENROUTER_MODEL = os.environ.get( - "OPENROUTER_MODEL", - "meta-llama/llama-3.1-8b-instruct:free", -) -DEFAULT_OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2") -OPENROUTER_SITE_URL = os.environ.get("OPENROUTER_SITE_URL", "http://127.0.0.1:5055") -OPENROUTER_APP_NAME = os.environ.get("OPENROUTER_APP_NAME", "FrameVitals") +_DEFAULT_OPENROUTER_MODEL = "meta-llama/llama-3.1-8b-instruct:free" +_DEFAULT_OLLAMA_MODEL = "llama3.2" + + +def _environment_text(name: str, default: str) -> str: + value = os.environ.get(name, "").strip() + return value or default + + +def _openrouter_model() -> str: + return _environment_text("OPENROUTER_MODEL", _DEFAULT_OPENROUTER_MODEL) + +def _ollama_model() -> str: + return _environment_text("OLLAMA_MODEL", _DEFAULT_OLLAMA_MODEL) -def compact_context(profile, health, signals, ml_readiness, advanced=None, column_roles_summary=None, dataset_signals=None): + +def compact_context( + profile, + health, + signals, + ml_readiness, + advanced=None, + column_roles_summary=None, + dataset_signals=None, +): context = { "profile": { "shape": profile["shape"], @@ -47,13 +64,10 @@ def compact_context(profile, health, signals, ml_readiness, advanced=None, colum "leakage": advanced.get("leakage", {}), "top_column_utility": advanced.get("column_utility", [])[:8], } - if column_roles_summary: context["column_roles_summary"] = column_roles_summary - if dataset_signals: context["dataset_signals"] = dataset_signals - return context @@ -61,8 +75,11 @@ def _openrouter_headers(): return { "Authorization": f"Bearer {os.environ.get('OPENROUTER_API_KEY', '').strip()}", "Content-Type": "application/json", - "HTTP-Referer": OPENROUTER_SITE_URL, - "X-Title": OPENROUTER_APP_NAME, + "HTTP-Referer": _environment_text( + "OPENROUTER_SITE_URL", + "http://127.0.0.1:5055", + ), + "X-Title": _environment_text("OPENROUTER_APP_NAME", "FrameVitals"), } @@ -72,12 +89,11 @@ def _call_openrouter(messages, model=None): raise RuntimeError("OPENROUTER_API_KEY is not set") payload = { - "model": model or DEFAULT_OPENROUTER_MODEL, + "model": model or _openrouter_model(), "messages": messages, "temperature": 0.2, "max_tokens": 1400, } - request = urllib.request.Request( OPENROUTER_URL, data=json.dumps(payload).encode("utf-8"), @@ -90,14 +106,19 @@ def _call_openrouter(messages, model=None): data = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: error_payload = exc.read().decode("utf-8", errors="ignore") - raise RuntimeError(f"OpenRouter request failed: {error_payload or exc.reason}") from exc - except Exception as exc: + raise RuntimeError( + f"OpenRouter request failed: {error_payload or exc.reason}" + ) from exc + except (OSError, TimeoutError, urllib.error.URLError) as exc: raise RuntimeError(f"OpenRouter request failed: {exc}") from exc try: - return data["choices"][0]["message"]["content"].strip() - except Exception as exc: - raise RuntimeError(f"OpenRouter returned an unexpected payload: {data}") from exc + content = data["choices"][0]["message"]["content"] + if not isinstance(content, str) or not content.strip(): + raise ValueError("empty completion content") + return content.strip() + except (KeyError, IndexError, TypeError, ValueError) as exc: + raise RuntimeError("OpenRouter returned an unexpected payload shape.") from exc def _call_ollama(messages, model=None): @@ -107,10 +128,16 @@ def _call_ollama(messages, model=None): raise RuntimeError(f"Ollama is unavailable: {exc}") from exc response = ollama.chat( - model=model or DEFAULT_OLLAMA_MODEL, + model=model or _ollama_model(), messages=messages, ) - return response["message"]["content"].strip() + try: + content = response["message"]["content"] + if not isinstance(content, str) or not content.strip(): + raise ValueError("empty completion content") + return content.strip() + except (KeyError, TypeError, ValueError) as exc: + raise RuntimeError("Ollama returned an unexpected payload shape.") from exc def _build_report_prompt(context): @@ -152,111 +179,178 @@ def _build_question_prompt(question, context): ## Limitation""" -def _fallback_ai_report(profile, health, ml_readiness, advanced, roles_summary=None, ds_signals=None, error=None): +def _fallback_ai_report( + profile, + health, + ml_readiness, + advanced, + roles_summary=None, + ds_signals=None, + error=None, +): + advanced = advanced or {} details = health.get("details", {}) - lines = [] - - lines.append("## Executive Summary") - lines.append( - f"The dataset contains {profile['shape']['rows']:,} rows and {profile['shape']['columns']} columns. " - f"The overall health score is {health['overall_score']}/100 ({health.get('label', 'Unknown')}). " - f"ML readiness is {ml_readiness['score']}/100 ({ml_readiness.get('label', 'Unknown')})." - ) - lines.append("") + lines = [ + "## Executive Summary", + ( + f"The dataset contains {profile['shape']['rows']:,} rows and " + f"{profile['shape']['columns']} columns. The overall health score is " + f"{health['overall_score']}/100 ({health.get('label', 'Unknown')}). " + f"ML readiness is {ml_readiness['score']}/100 " + f"({ml_readiness.get('label', 'Unknown')})." + ), + "", + "## Data Quality Risks", + ] - lines.append("## Data Quality Risks") - if details.get("missing_percent", 0) > 0: - sev = "Critical" if details.get("missing_percent", 0) >= 30 else "High" if details.get("missing_percent", 0) >= 10 else "Medium" - lines.append(f"- **{sev}**: {details.get('missing_percent', 0)}% of cells are missing") + missing_percent = details.get("missing_percent", 0) + if missing_percent > 0: + severity = "Critical" if missing_percent >= 30 else "High" if missing_percent >= 10 else "Medium" + lines.append(f"- **{severity}**: {missing_percent}% of cells are missing") if details.get("duplicate_percent", 0) > 0: - lines.append(f"- **Medium**: {details.get('duplicate_percent', 0)}% duplicate rows detected") + lines.append( + f"- **Medium**: {details.get('duplicate_percent', 0)}% duplicate rows detected" + ) if details.get("outlier_percent", 0) > 0: - lines.append(f"- **Medium**: {details.get('outlier_percent', 0)}% of numeric cells are outliers") + lines.append( + f"- **Medium**: {details.get('outlier_percent', 0)}% of numeric cells are outliers" + ) if details.get("constant_columns"): - lines.append(f"- **Low**: {len(details['constant_columns'])} constant column(s): {', '.join(details['constant_columns'])}") + lines.append( + f"- **Low**: {len(details['constant_columns'])} constant column(s): " + f"{', '.join(details['constant_columns'])}" + ) if details.get("high_cardinality_columns"): - lines.append(f"- **Medium**: {len(details['high_cardinality_columns'])} high-cardinality column(s) may be identifiers") + lines.append( + f"- **Medium**: {len(details['high_cardinality_columns'])} " + "high-cardinality column(s) may be identifiers" + ) if roles_summary and roles_summary.get("id_like"): - lines.append(f"- **High**: Detected ID-like columns: {', '.join(roles_summary['id_like'])}") + lines.append( + f"- **High**: Detected ID-like columns: {', '.join(roles_summary['id_like'])}" + ) if ds_signals and ds_signals.get("has_potential_leakage"): lines.append("- **Critical**: Potential data leakage detected") - lines.append("") + lines.extend(["", "## Key Insights"]) - lines.append("## Key Insights") if roles_summary and roles_summary.get("target_candidates"): - lines.append(f"- Potential target columns: {', '.join(roles_summary['target_candidates'][:5])}") + lines.append( + "- Potential target columns: " + + ", ".join(roles_summary["target_candidates"][:5]) + ) if roles_summary and roles_summary.get("sensitive"): - lines.append(f"- Sensitive columns detected: {', '.join(roles_summary['sensitive'])}") + lines.append( + f"- Sensitive columns detected: {', '.join(roles_summary['sensitive'])}" + ) anomalies = advanced.get("anomalies", {}) if anomalies.get("anomalous_rows", 0) > 0: - lines.append(f"- {anomalies['anomalous_rows']} anomalous rows detected (max score: {anomalies.get('highest_score', 'N/A')})") - lines.append("") - - lines.append("## Cleaning Recommendations") - lines.append("1. Handle missing values before modelling") + lines.append( + f"- {anomalies['anomalous_rows']} anomalous rows detected " + f"(max score: {anomalies.get('highest_score', 'N/A')})" + ) + + lines.extend([ + "", + "## Cleaning Recommendations", + "1. Handle missing values before modelling", + ]) if details.get("duplicate_percent", 0) > 0: lines.append("2. Review and remove duplicate rows") lines.append("3. Inspect and handle outliers in numeric columns") if roles_summary and roles_summary.get("id_like"): - lines.append(f"4. Remove ID columns before ML: {', '.join(roles_summary['id_like'])}") - lines.append("") - - lines.append("## ML Readiness Assessment") - lines.append(f"Score: {ml_readiness['score']}/100 ({ml_readiness.get('label', 'Unknown')})") + lines.append( + f"4. Remove ID columns before ML: {', '.join(roles_summary['id_like'])}" + ) + + lines.extend([ + "", + "## ML Readiness Assessment", + f"Score: {ml_readiness['score']}/100 ({ml_readiness.get('label', 'Unknown')})", + ]) for recommendation in ml_readiness.get("recommendations", []): lines.append(f"- {recommendation}") - lines.append("") - - lines.append("## Warnings") - lines.append(f"- Fairness review: {advanced.get('fairness', {}).get('message', 'No fairness summary available')}") - lines.append(f"- Leakage status: {advanced.get('leakage', {}).get('status', 'No leakage summary available')}") - lines.append("") - - lines.append("## Next Steps") - lines.append("1. Address critical data quality issues first") - lines.append("2. Select a target column for supervised learning") - lines.append("3. Run deep analysis mode for statistical tests") - lines.append("") - lines.append( - "*Note: This report was generated without a reachable model endpoint. It is based on computed statistics and heuristics.*" - ) - source = "fallback" - if error: - source = f"fallback: {error}" + lines.extend([ + "", + "## Warnings", + ( + "- Fairness review: " + + advanced.get("fairness", {}).get( + "message", + "No fairness summary available", + ) + ), + ( + "- Leakage status: " + + advanced.get("leakage", {}).get( + "status", + "No leakage summary available", + ) + ), + "", + "## Next Steps", + "1. Address critical data quality issues first", + "2. Select a target column for supervised learning", + "3. Run deep analysis mode for statistical tests", + "", + ( + "*Note: This report was generated without a reachable model endpoint. " + "It is based on computed statistics and heuristics.*" + ), + ]) + source = "fallback" if not error else f"fallback: {error}" return {"source": source, "text": "\n".join(lines)} def _fallback_answer(profile, health, error=None): - source = "fallback" - if error: - source = f"fallback: {error}" - + source = "fallback" if not error else f"fallback: {error}" return { "source": source, "answer": ( "## Answer\n" "A model endpoint is not currently reachable.\n\n" "## Evidence\n" - f"The dataset has {profile['shape']['rows']:,} rows, {profile['shape']['columns']} columns, and a health score of {health['overall_score']}/100.\n\n" + f"The dataset has {profile['shape']['rows']:,} rows, " + f"{profile['shape']['columns']} columns, and a health score of " + f"{health['overall_score']}/100.\n\n" "## Recommendation\n" - "Start by reviewing missing values, duplicates, outliers, and ML-readiness indicators.\n\n" + "Start by reviewing missing values, duplicates, outliers, and " + "ML-readiness indicators.\n\n" "## Limitation\n" "This answer is generated without a reachable model endpoint." ), } -def generate_ai_report(profile, health, signals, ml_readiness, advanced, column_roles_summary=None, dataset_signals=None, model=None): - context = compact_context(profile, health, signals, ml_readiness, advanced, column_roles_summary, dataset_signals) - prompt = _build_report_prompt(context) +def generate_ai_report( + profile, + health, + signals, + ml_readiness, + advanced, + column_roles_summary=None, + dataset_signals=None, + model=None, +): + context = compact_context( + profile, + health, + signals, + ml_readiness, + advanced, + column_roles_summary, + dataset_signals, + ) messages = [ { "role": "system", - "content": "You are an expert data scientist writing a professional dataset analysis report. Be evidence-based, concise, and actionable.", + "content": ( + "You are an expert data scientist writing a professional dataset " + "analysis report. Be evidence-based, concise, and actionable." + ), }, - {"role": "user", "content": prompt}, + {"role": "user", "content": _build_report_prompt(context)}, ] try: @@ -264,24 +358,39 @@ def generate_ai_report(profile, health, signals, ml_readiness, advanced, column_ return {"source": "openrouter", "text": text} except Exception as openrouter_error: try: - text = _call_ollama(messages, model=model or DEFAULT_OLLAMA_MODEL) + text = _call_ollama(messages, model=model or _ollama_model()) return {"source": "ollama", "text": text} except Exception as ollama_error: - return { - "source": f"fallback: {openrouter_error}; {ollama_error}", - **_fallback_ai_report(profile, health, ml_readiness, advanced, column_roles_summary, dataset_signals, None), - } - - -def answer_dataset_question(question, profile, health, signals, ml_readiness, advanced=None, model=None): + return _fallback_ai_report( + profile, + health, + ml_readiness, + advanced, + column_roles_summary, + dataset_signals, + error=f"{openrouter_error}; {ollama_error}", + ) + + +def answer_dataset_question( + question, + profile, + health, + signals, + ml_readiness, + advanced=None, + model=None, +): context = compact_context(profile, health, signals, ml_readiness, advanced) - prompt = _build_question_prompt(question, context) messages = [ { "role": "system", - "content": "Answer only from the provided dataset context. Be precise and evidence-based.", + "content": ( + "Answer only from the provided dataset context. " + "Be precise and evidence-based." + ), }, - {"role": "user", "content": prompt}, + {"role": "user", "content": _build_question_prompt(question, context)}, ] try: @@ -289,10 +398,11 @@ def answer_dataset_question(question, profile, health, signals, ml_readiness, ad return {"source": "openrouter", "answer": text} except Exception as openrouter_error: try: - text = _call_ollama(messages, model=model or DEFAULT_OLLAMA_MODEL) + text = _call_ollama(messages, model=model or _ollama_model()) return {"source": "ollama", "answer": text} except Exception as ollama_error: - return { - "source": f"fallback: {openrouter_error}; {ollama_error}", - **_fallback_answer(profile, health, None), - } + return _fallback_answer( + profile, + health, + error=f"{openrouter_error}; {ollama_error}", + ) diff --git a/src/framevitals/analysis_api.py b/src/framevitals/analysis_api.py index 9e0b5e7..81aed43 100644 --- a/src/framevitals/analysis_api.py +++ b/src/framevitals/analysis_api.py @@ -15,52 +15,20 @@ import pandas as pd from framevitals.config import ConfigInput, resolve_config +from framevitals.execution import ExecutionPolicy, use_execution_policy from framevitals.pipeline import run_full_analysis +from framevitals.planner import MODE_DISABLED_MODULES, effective_disabled_modules from framevitals.result import AnalysisResult from framevitals.sources import StreamingDatasetSource, resolve_source DataInput = Any - -_MODE_DISABLED_MODULES: dict[str, frozenset[str]] = { - # Quick is intentionally an overview. Keep explicit target intelligence and - # artifact cleaning available for backwards compatibility, while omitting - # the heavier anomaly/time-series/research/modeling layers. - "quick": frozenset({ - "deep_statistics", - "anomaly_detection", - "time_series", - "text_profile", - "modeling", - "explainability", - }), - # Standard is the operational default. It keeps practical anomaly, - # time-series and target diagnostics, but leaves research-grade statistics, - # free-text profiling and model training/explainability to deeper tiers. - "standard": frozenset({ - "deep_statistics", - "text_profile", - "modeling", - "explainability", - }), - # Deep is the advanced diagnostic tier: research-grade statistics and text - # profiling are enabled, while repeated model CV/refitting is reserved for - # research mode where the extra runtime is an explicit user choice. - "deep": frozenset({"modeling", "explainability"}), - "research": frozenset(), -} - - -def _effective_disabled_modules( - mode: str, - user_disabled: tuple[str, ...], -) -> tuple[str, ...]: - """Merge explicit disables with the stable module policy for a mode.""" - implicit = _MODE_DISABLED_MODULES.get(mode) - if implicit is None: - raise ValueError(f"Unknown analysis mode: {mode}") - return tuple(sorted(set(user_disabled) | set(implicit))) +# Compatibility aliases retained for internal callers/tests that imported the +# pre-0.3 private policy names. The planner remains the single source of truth; +# these aliases deliberately do not duplicate policy data or behavior. +_MODE_DISABLED_MODULES = MODE_DISABLED_MODULES +_effective_disabled_modules = effective_disabled_modules def analyze( @@ -73,6 +41,10 @@ def analyze( preset: str | None = None, config: ConfigInput = None, disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, ) -> AnalysisResult: """Analyze a tabular dataset through the appropriate execution source.""" resolved = resolve_config( @@ -83,84 +55,95 @@ def analyze( artifacts=artifacts, workers=workers, disabled_modules=disabled_modules, + max_sample_rows=max_sample_rows, + max_relationship_pairs=max_relationship_pairs, + max_memory_heavy_parallelism=max_memory_heavy_parallelism, + max_streaming_profile_columns=max_streaming_profile_columns, ) - effective_disabled = _effective_disabled_modules( + effective_disabled = effective_disabled_modules( resolved.mode, resolved.disabled_modules, ) dataset_id = f"fv_{uuid4().hex[:12]}" - - # Preserve the direct DataFrame path so callers do not pay for a defensive - # source-layer copy before the established materialized pipeline begins. - if isinstance(data, pd.DataFrame): - if data.empty: - raise ValueError("Dataset DataFrame is empty.") - payload = run_full_analysis( - dataset_id=dataset_id, - original_filename="", - analysis_mode=resolved.mode, - target_column=resolved.target, - parallel_workers=resolved.workers, - skip_ai=True, - dataframe=data, - write_artifacts=resolved.artifacts, - disabled_modules=effective_disabled, - ) - else: - source = resolve_source(data) - metadata = source.inspect() - if metadata.rows == 0: - raise ValueError(f"Dataset is empty: {metadata.name}") - - if ( - metadata.supports_streaming - and isinstance(source, StreamingDatasetSource) - and not resolved.artifacts - ): - from framevitals.streaming_exact_reuse import reuse_streaming_exact_statistics - from framevitals.streaming_pipeline import run_streaming_analysis - - payload = run_streaming_analysis( - source=source, - dataset_id=dataset_id, - original_filename=metadata.name, - analysis_mode=resolved.mode, - target_column=resolved.target, - parallel_workers=resolved.workers, - skip_ai=True, - disabled_modules=effective_disabled, - ) - payload = reuse_streaming_exact_statistics(payload) - elif isinstance(data, (str, Path)): - path = Path(data) - if not path.exists(): - raise FileNotFoundError(f"Dataset not found: {path}") - if not path.is_file(): - raise ValueError(f"Expected a file for dataset, got: {path}") + execution_policy = ExecutionPolicy(**resolved.execution_policy()) + + # ExecutionPolicy is carried through a context variable so the existing + # materialized and streaming pipelines derive the same capped budgets without + # process-global mutation or a parallel configuration API. + with use_execution_policy(execution_policy): + # Preserve the direct DataFrame path so callers do not pay for a defensive + # source-layer copy before the established materialized pipeline begins. + if isinstance(data, pd.DataFrame): + if data.empty: + raise ValueError("Dataset DataFrame is empty.") payload = run_full_analysis( dataset_id=dataset_id, - file_path=path, - original_filename=metadata.name, + original_filename="", analysis_mode=resolved.mode, target_column=resolved.target, parallel_workers=resolved.workers, skip_ai=True, + dataframe=data, write_artifacts=resolved.artifacts, disabled_modules=effective_disabled, ) else: - dataframe = source.load() - payload = run_full_analysis( - dataset_id=dataset_id, - original_filename=metadata.name, - analysis_mode=resolved.mode, - target_column=resolved.target, - parallel_workers=resolved.workers, - skip_ai=True, - dataframe=dataframe, - write_artifacts=resolved.artifacts, - disabled_modules=effective_disabled, - ) + source = resolve_source(data) + metadata = source.inspect() + if metadata.rows == 0: + raise ValueError(f"Dataset is empty: {metadata.name}") + + if ( + metadata.supports_streaming + and isinstance(source, StreamingDatasetSource) + and not resolved.artifacts + ): + from framevitals.streaming_exact_reuse import ( + reuse_streaming_exact_statistics, + ) + from framevitals.streaming_pipeline import run_streaming_analysis + + payload = run_streaming_analysis( + source=source, + dataset_id=dataset_id, + original_filename=metadata.name, + analysis_mode=resolved.mode, + target_column=resolved.target, + parallel_workers=resolved.workers, + skip_ai=True, + disabled_modules=effective_disabled, + ) + payload = reuse_streaming_exact_statistics(payload) + elif isinstance(data, (str, Path)): + path = Path(data) + if not path.exists(): + raise FileNotFoundError(f"Dataset not found: {path}") + if not path.is_file(): + raise ValueError(f"Expected a file for dataset, got: {path}") + payload = run_full_analysis( + dataset_id=dataset_id, + file_path=path, + original_filename=metadata.name, + analysis_mode=resolved.mode, + target_column=resolved.target, + parallel_workers=resolved.workers, + skip_ai=True, + write_artifacts=resolved.artifacts, + disabled_modules=effective_disabled, + ) + else: + dataframe = source.load() + payload = run_full_analysis( + dataset_id=dataset_id, + original_filename=metadata.name, + analysis_mode=resolved.mode, + target_column=resolved.target, + parallel_workers=resolved.workers, + skip_ai=True, + dataframe=dataframe, + write_artifacts=resolved.artifacts, + disabled_modules=effective_disabled, + ) # Keep public configuration/provenance compatible: ``disabled_modules`` # describes only explicit caller configuration. Mode policy is observable @@ -170,4 +153,6 @@ def analyze( execution = payload.get("execution") if isinstance(execution, dict): execution["disabled_modules"] = sorted(resolved.disabled_modules) + execution["resource_policy"] = execution_policy.to_dict() + execution["effective_disabled_modules"] = list(effective_disabled) return AnalysisResult(payload) diff --git a/src/framevitals/api.py b/src/framevitals/api.py index da8bb0f..f7cd645 100644 --- a/src/framevitals/api.py +++ b/src/framevitals/api.py @@ -155,6 +155,10 @@ def analyze( preset: str | None = None, config: Any = None, disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, ) -> AnalysisResult: """Analyze a dataset through the canonical source-aware dispatcher.""" from framevitals.analysis_api import analyze as _analyze @@ -168,6 +172,10 @@ def analyze( preset=preset, config=config, disabled_modules=disabled_modules, + max_sample_rows=max_sample_rows, + max_relationship_pairs=max_relationship_pairs, + max_memory_heavy_parallelism=max_memory_heavy_parallelism, + max_streaming_profile_columns=max_streaming_profile_columns, ) @@ -180,6 +188,10 @@ def plan( preset: str | None = None, config: Any = None, disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, ) -> AnalysisPlan: """Preview analysis execution through the canonical planning API.""" from framevitals.planning_api import plan as _plan @@ -192,6 +204,10 @@ def plan( preset=preset, config=config, disabled_modules=disabled_modules, + max_sample_rows=max_sample_rows, + max_relationship_pairs=max_relationship_pairs, + max_memory_heavy_parallelism=max_memory_heavy_parallelism, + max_streaming_profile_columns=max_streaming_profile_columns, ) diff --git a/src/framevitals/config.py b/src/framevitals/config.py index 1f4c10a..b6a1fa5 100644 --- a/src/framevitals/config.py +++ b/src/framevitals/config.py @@ -2,16 +2,18 @@ The configuration layer controls both analysis depth/resources and optional pipeline modules. Defaults preserve historical behaviour; users can explicitly -disable expensive or irrelevant modules without changing the stable result -shape or maintaining a second configuration system. +disable expensive or irrelevant modules and cap expensive execution work without +maintaining a second configuration system. """ from __future__ import annotations from dataclasses import asdict, dataclass, replace +from numbers import Integral +import os from pathlib import Path -from typing import Any, Mapping import tomllib +from typing import Any, Mapping VALID_MODES = {"quick", "standard", "deep", "research"} @@ -34,6 +36,9 @@ "standard": {"mode": "standard", "workers": 4, "artifacts": False}, "deep": {"mode": "deep", "workers": 4, "artifacts": False}, "research": {"mode": "research", "workers": 4, "artifacts": False}, + # Exhaustive is the forward-looking name for the deepest built-in policy. + # Keep ``research`` as a compatibility preset/mode throughout the 0.x series. + "exhaustive": {"mode": "research", "workers": 4, "artifacts": False}, "ci": { "mode": "standard", "workers": 2, @@ -42,27 +47,91 @@ }, } +_RESOURCE_KEYS = ( + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", +) + + +def _coerce_disabled_modules(value: Any) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str) or not isinstance(value, (list, tuple, set)): + raise ValueError("disabled_modules must be a list/tuple of module names.") + modules = tuple(value) + if any(not isinstance(item, str) or not item for item in modules): + raise ValueError("disabled_modules must contain non-empty module name strings.") + return modules + + +def _coerce_positive_int(name: str, value: Any) -> int: + if isinstance(value, bool): + raise ValueError(f"{name} must be an integer.") + if isinstance(value, Integral): + converted = int(value) + elif isinstance(value, str): + try: + converted = int(value.strip()) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be an integer.") from exc + else: + raise ValueError(f"{name} must be an integer.") + if converted < 1: + raise ValueError(f"{name} must be at least 1.") + return converted + + +def _coerce_optional_positive_int(name: str, value: Any) -> int | None: + if value is None: + return None + return _coerce_positive_int(name, value) + @dataclass(frozen=True, slots=True) class AnalysisConfig: - """Resolved configuration consumed by the analysis pipeline.""" + """Resolved configuration consumed by the analysis pipeline. + + Resource fields are hard upper bounds. They may reduce a mode's adaptive + execution budget but never force FrameVitals to perform more work than the + mode would normally allow. + """ mode: str = "standard" target: str | None = None artifacts: bool = False workers: int = 4 disabled_modules: tuple[str, ...] = () + max_sample_rows: int | None = None + max_relationship_pairs: int | None = None + max_memory_heavy_parallelism: int | None = None + max_streaming_profile_columns: int | None = None def __post_init__(self) -> None: - if self.mode not in VALID_MODES: + if not isinstance(self.mode, str) or self.mode not in VALID_MODES: raise ValueError( f"Invalid analysis mode '{self.mode}'. " f"Choose from: {', '.join(sorted(VALID_MODES))}" ) - if self.workers < 1: - raise ValueError("workers must be at least 1.") + if self.target is not None and not isinstance(self.target, str): + raise ValueError("target must be a column name string or null.") + if not isinstance(self.artifacts, bool): + raise ValueError("artifacts must be true or false.") + + object.__setattr__( + self, + "workers", + _coerce_positive_int("workers", self.workers), + ) + for name in _RESOURCE_KEYS: + object.__setattr__( + self, + name, + _coerce_optional_positive_int(name, getattr(self, name)), + ) - modules = tuple(dict.fromkeys(self.disabled_modules)) + modules = tuple(dict.fromkeys(_coerce_disabled_modules(self.disabled_modules))) unknown = sorted(set(modules) - VALID_MODULES) if unknown: raise ValueError( @@ -72,17 +141,40 @@ def __post_init__(self) -> None: object.__setattr__(self, "disabled_modules", modules) def to_dict(self) -> dict[str, Any]: - return asdict(self) + payload = asdict(self) + # Preserve the 0.2.x serialized config shape unless a 0.3 resource cap + # is explicitly configured. This keeps existing integrations stable. + for name in _RESOURCE_KEYS: + if payload.get(name) is None: + payload.pop(name, None) + return payload def module_enabled(self, name: str) -> bool: if name not in VALID_MODULES: raise ValueError(f"Unknown FrameVitals module: {name}") return name not in self.disabled_modules + def execution_policy(self) -> dict[str, int | None]: + """Return the resource caps understood by the adaptive execution layer.""" + return {name: getattr(self, name) for name in _RESOURCE_KEYS} + ConfigInput = AnalysisConfig | Mapping[str, Any] | str | Path | None +_ENV_VALUE_KEYS = { + "FRAMEVITALS_MODE": "mode", + "FRAMEVITALS_TARGET": "target", + "FRAMEVITALS_ARTIFACTS": "artifacts", + "FRAMEVITALS_WORKERS": "workers", + "FRAMEVITALS_DISABLED_MODULES": "disabled_modules", + "FRAMEVITALS_MAX_SAMPLE_ROWS": "max_sample_rows", + "FRAMEVITALS_MAX_RELATIONSHIP_PAIRS": "max_relationship_pairs", + "FRAMEVITALS_MAX_MEMORY_HEAVY_PARALLELISM": "max_memory_heavy_parallelism", + "FRAMEVITALS_MAX_STREAMING_PROFILE_COLUMNS": "max_streaming_profile_columns", +} + + def available_presets() -> tuple[str, ...]: """Return built-in preset names in deterministic order.""" return tuple(PRESETS) @@ -111,14 +203,6 @@ def _read_toml(path: str | Path) -> dict[str, Any]: return payload -def _coerce_disabled_modules(value: Any) -> tuple[str, ...]: - if value is None: - return () - if isinstance(value, str) or not isinstance(value, (list, tuple, set)): - raise ValueError("disabled_modules must be a list/tuple of module names.") - return tuple(str(item) for item in value) - - def _extract_values( mapping: Mapping[str, Any], ) -> tuple[str | None, dict[str, Any], dict[str, bool]]: @@ -143,10 +227,11 @@ def _extract_values( elif key in mapping: values[key] = mapping[key] - if "workers" in resources: - values["workers"] = resources["workers"] - elif "workers" in mapping: - values["workers"] = mapping["workers"] + for key in ("workers", *_RESOURCE_KEYS): + if key in resources: + values[key] = resources[key] + elif key in mapping: + values[key] = mapping[key] if "disabled_modules" in analysis: values["disabled_modules"] = _coerce_disabled_modules( @@ -168,6 +253,44 @@ def _extract_values( return str(preset) if preset is not None else None, values, module_overrides +def _coerce_environment_bool(name: str, value: str) -> bool: + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError( + f"{name} must be one of: true, false, 1, 0, yes, no, on, off." + ) + + +def _environment_values( + environ: Mapping[str, str] | None = None, +) -> tuple[str | None, dict[str, Any]]: + """Read deterministic FrameVitals runtime overrides from the environment.""" + source = os.environ if environ is None else environ + preset = source.get("FRAMEVITALS_PRESET") + if preset is not None: + preset = preset.strip() or None + + values: dict[str, Any] = {} + for env_name, config_name in _ENV_VALUE_KEYS.items(): + if env_name not in source: + continue + raw = str(source[env_name]) + if config_name == "artifacts": + values[config_name] = _coerce_environment_bool(env_name, raw) + elif config_name == "disabled_modules": + values[config_name] = tuple( + item.strip() for item in raw.split(",") if item.strip() + ) + elif config_name == "target": + values[config_name] = raw.strip() or None + else: + values[config_name] = raw.strip() + return preset, values + + def _preset_values(name: str | None) -> dict[str, Any]: if name is None: return {} @@ -201,21 +324,37 @@ def resolve_config( artifacts: bool | None = None, workers: int | None = None, disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, ) -> AnalysisConfig: - """Resolve defaults, preset, config file/object, then explicit overrides. + """Resolve defaults, preset, environment, config, then explicit overrides. Precedence, from lowest to highest, is: 1. FrameVitals defaults - 2. explicit ``preset=`` argument - 3. configuration file/mapping/object (including ``[modules]`` booleans) - 4. explicit function/CLI arguments + 2. preset defaults + 3. ``FRAMEVITALS_*`` environment overrides + 4. configuration file/mapping/object (including ``[modules]`` booleans) + 5. explicit function/CLI arguments + + Resource caps are strict upper bounds, not requests to increase work above + the selected mode's adaptive defaults. """ values: dict[str, Any] = AnalysisConfig().to_dict() values.update(_preset_values(preset)) + environment_preset, environment_values = _environment_values() + if environment_preset is not None: + values.update(_preset_values(environment_preset)) + values.update(environment_values) + if isinstance(config, AnalysisConfig): - values.update(config.to_dict()) + # A resolved config object is authoritative, including explicit None + # resource caps that clear lower-precedence environment overrides. Keep + # public to_dict() compatibility separate from internal resolution. + values.update(asdict(config)) elif isinstance(config, (str, Path)): config_preset, config_values, module_overrides = _extract_values( _read_toml(config) @@ -243,14 +382,14 @@ def resolve_config( "disabled_modules": ( tuple(disabled_modules) if disabled_modules is not None else None ), + "max_sample_rows": max_sample_rows, + "max_relationship_pairs": max_relationship_pairs, + "max_memory_heavy_parallelism": max_memory_heavy_parallelism, + "max_streaming_profile_columns": max_streaming_profile_columns, } values.update({key: value for key, value in explicit.items() if value is not None}) - try: - values["workers"] = int(values["workers"]) - except (TypeError, ValueError) as exc: - raise ValueError("workers must be an integer.") from exc - + values["workers"] = _coerce_positive_int("workers", values["workers"]) if not isinstance(values["artifacts"], bool): raise ValueError("artifacts must be true or false.") if values["target"] is not None and not isinstance(values["target"], str): @@ -259,6 +398,9 @@ def resolve_config( values["disabled_modules"] = _coerce_disabled_modules( values.get("disabled_modules", ()) ) + for name in _RESOURCE_KEYS: + values[name] = _coerce_optional_positive_int(name, values.get(name)) + return AnalysisConfig(**values) diff --git a/src/framevitals/execution.py b/src/framevitals/execution.py index 1c951fa..0f5f25e 100644 --- a/src/framevitals/execution.py +++ b/src/framevitals/execution.py @@ -11,8 +11,11 @@ from __future__ import annotations +from contextlib import contextmanager +from contextvars import ContextVar from dataclasses import asdict, dataclass -from typing import Any +from numbers import Integral +from typing import Any, Iterator import numpy as np import pandas as pd @@ -21,10 +24,6 @@ _VALID_MODES = {"quick", "standard", "deep", "research"} _SAMPLE_SEED = 0x9E3779B97F4A7C15 -# Full-stream profiling is valuable, but on ultra-wide sources scanning every -# cell defeats the purpose of streaming. These budgets cap the number of source -# cells inspected by the reusable profile pass while preserving the true source -# shape in execution metadata. _STREAMING_PROFILE_CELL_BUDGETS = { "quick": 64_000_000, "standard": 96_000_000, @@ -39,6 +38,84 @@ } +def _require_non_negative_int(name: str, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, Integral): + raise ValueError(f"{name} must be an integer.") + converted = int(value) + if converted < 0: + raise ValueError(f"{name} must be non-negative.") + return converted + + +def _require_positive_int(name: str, value: Any) -> int: + converted = _require_non_negative_int(name, value) + if converted < 1: + raise ValueError(f"{name} must be at least 1.") + return converted + + +def _optional_positive_int(name: str, value: Any) -> int | None: + if value is None: + return None + return _require_positive_int(name, value) + + +@dataclass(frozen=True, slots=True) +class ExecutionPolicy: + """Per-call hard caps layered on top of adaptive mode defaults. + + Policy values can only reduce work. They never increase a mode's built-in + sampling or parallelism limits. A context variable carries the policy across + the existing pipeline without mutable process-wide globals, so concurrent + analyses can safely use different limits. + """ + + max_sample_rows: int | None = None + max_relationship_pairs: int | None = None + max_memory_heavy_parallelism: int | None = None + max_streaming_profile_columns: int | None = None + + def __post_init__(self) -> None: + for name in ( + "max_sample_rows", + "max_relationship_pairs", + "max_memory_heavy_parallelism", + "max_streaming_profile_columns", + ): + object.__setattr__( + self, + name, + _optional_positive_int(name, getattr(self, name)), + ) + + def to_dict(self) -> dict[str, int | None]: + return asdict(self) + + +_DEFAULT_EXECUTION_POLICY = ExecutionPolicy() +_EXECUTION_POLICY: ContextVar[ExecutionPolicy] = ContextVar( + "framevitals_execution_policy", + default=_DEFAULT_EXECUTION_POLICY, +) + + +def current_execution_policy() -> ExecutionPolicy: + """Return the resource caps active for the current analysis context.""" + return _EXECUTION_POLICY.get() + + +@contextmanager +def use_execution_policy(policy: ExecutionPolicy) -> Iterator[None]: + """Apply ``policy`` to nested budget derivation for one logical run.""" + if not isinstance(policy, ExecutionPolicy): + raise TypeError("policy must be an ExecutionPolicy.") + token = _EXECUTION_POLICY.set(policy) + try: + yield + finally: + _EXECUTION_POLICY.reset(token) + + @dataclass(frozen=True, slots=True) class ExecutionBudget: """Resolved resource policy for one analysis run. @@ -76,21 +153,22 @@ def _bounded(requested: int, rows: int) -> int: return min(int(requested), int(rows)) +def _policy_row_cap(requested: int, rows: int, policy: ExecutionPolicy) -> int: + bounded = _bounded(requested, rows) + if policy.max_sample_rows is None: + return bounded + return min(bounded, policy.max_sample_rows) + + def _deterministic_stratified_positions( rows: int, target_rows: int, *, seed: int = _SAMPLE_SEED, ) -> np.ndarray: - """Choose one deterministic pseudo-random row from each equal-width stratum. - - Fixed evenly spaced samples can lock onto periodic structure. Stratified jitter - retains deterministic whole-dataset coverage while breaking that phase locking. - Positions are returned sorted, so temporal order remains available to callers - that need it, without allocating a permutation proportional to the source size. - """ - rows = int(rows) - target_rows = int(target_rows) + """Choose one deterministic pseudo-random row from each equal-width stratum.""" + rows = _require_non_negative_int("rows", rows) + target_rows = _require_non_negative_int("target_rows", target_rows) count = min(rows, target_rows) if count <= 0: return np.empty(0, dtype=np.int64) @@ -107,7 +185,6 @@ def _deterministic_stratified_positions( widths = (edges[1:] - edges[:-1]).astype(np.uint64) indices = np.arange(count, dtype=np.uint64) - # SplitMix64-style deterministic mixing. uint64 overflow is intentional. with np.errstate(over="ignore"): mixed = indices + np.uint64(seed) mixed = (mixed ^ (mixed >> np.uint64(30))) * np.uint64(0xBF58476D1CE4E5B9) @@ -116,9 +193,6 @@ def _deterministic_stratified_positions( offsets = (mixed % widths).astype(np.int64) positions = edges[:-1] + offsets - - # Keep full-range coverage as an explicit invariant while jittering the - # interior strata. This is useful for ordered/time-series diagnostics too. positions[0] = 0 positions[-1] = rows - 1 return positions @@ -130,36 +204,38 @@ def derive_streaming_profile_column_limit( *, mode: str = "standard", ) -> int: - """Return the deterministic full-stream column budget for a source shape. - - Ordinary datasets keep every column. Ultra-wide/high-cell-count sources are - projected before the full streaming profile pass so total scanned cells stay - bounded. The projection itself is selected by the caller from the source - schema; this function only resolves the allowed width. - """ + """Return the deterministic full-stream column budget for a source shape.""" if mode not in _VALID_MODES: raise ValueError(f"Unknown analysis mode: {mode}") - if rows < 0 or columns < 0: - raise ValueError("rows and columns must be non-negative.") + rows = _require_non_negative_int("rows", rows) + columns = _require_non_negative_int("columns", columns) if columns == 0: return 0 + + policy = current_execution_policy() + explicit_cap = policy.max_streaming_profile_columns + if rows == 0: - return int(columns) - - cells = int(rows) * int(columns) - cell_budget = int(_STREAMING_PROFILE_CELL_BUDGETS[mode]) - if cells <= cell_budget and columns < 10_000: - return int(columns) - - by_cells = max(1, cell_budget // max(int(rows), 1)) - return max( - 1, - min( - int(columns), - int(by_cells), - int(_STREAMING_PROFILE_COLUMN_CAPS[mode]), - ), - ) + derived = columns + else: + cells = rows * columns + cell_budget = int(_STREAMING_PROFILE_CELL_BUDGETS[mode]) + if cells <= cell_budget and columns < 10_000: + derived = columns + else: + by_cells = max(1, cell_budget // max(rows, 1)) + derived = max( + 1, + min( + columns, + int(by_cells), + int(_STREAMING_PROFILE_COLUMN_CAPS[mode]), + ), + ) + + if explicit_cap is not None: + derived = min(derived, explicit_cap) + return max(1, int(derived)) def derive_execution_budget( @@ -168,19 +244,14 @@ def derive_execution_budget( *, mode: str = "standard", ) -> ExecutionBudget: - """Derive a conservative execution policy from shape and analysis mode. - - The thresholds are deliberately simple and deterministic for now. They are - a compatibility layer for the future cost-based planner, where RAM, storage - metadata, native throughput, GPU availability, and user accuracy budgets can - refine the same object without changing analysis APIs. - """ + """Derive a conservative execution policy from shape and analysis mode.""" if mode not in _VALID_MODES: raise ValueError(f"Unknown analysis mode: {mode}") - if rows < 0 or columns < 0: - raise ValueError("rows and columns must be non-negative.") + rows = _require_non_negative_int("rows", rows) + columns = _require_non_negative_int("columns", columns) - cells = int(rows) * int(columns) + policy = current_execution_policy() + cells = rows * columns large_dataset = rows >= 100_000 or cells >= 10_000_000 wide_dataset = columns >= 1_000 ultra_wide_dataset = columns >= 10_000 @@ -238,36 +309,42 @@ def derive_execution_budget( } selected = presets[mode] - # Ultra-wide data must spend relationship budget more carefully. The future - # sparse feature-graph engine will replace this fixed cap with candidate - # generation rather than dense pair enumeration. relationship_budget = int(selected["relationships"]) if ultra_wide_dataset: relationship_budget = min(relationship_budget, 10) elif wide_dataset: relationship_budget = min(relationship_budget, 20) + if policy.max_relationship_pairs is not None: + relationship_budget = min( + relationship_budget, + policy.max_relationship_pairs, + ) - # Memory-heavy modules should not be launched four-at-a-time simply because - # a machine exposes four Python workers. Large inputs default to sequential - # heavy execution until the scheduler gains RAM-aware token accounting. heavy_parallelism = 1 if large_dataset or wide_dataset else 2 + if policy.max_memory_heavy_parallelism is not None: + heavy_parallelism = min( + heavy_parallelism, + policy.max_memory_heavy_parallelism, + ) return ExecutionBudget( mode=mode, - rows=int(rows), - columns=int(columns), + rows=rows, + columns=columns, cells=cells, scale_class=scale_class, large_dataset=large_dataset, wide_dataset=wide_dataset, ultra_wide_dataset=ultra_wide_dataset, - quality_sample_rows=_bounded(selected["quality"], rows), - deep_statistics_sample_rows=_bounded(selected["deep"], rows), - bootstrap_sample_rows=_bounded(selected["bootstrap"], rows), - distribution_sample_rows=_bounded(selected["distribution"], rows), - pair_sample_rows=_bounded(selected["pair"], rows), - anomaly_sample_rows=_bounded(selected["anomaly"], rows), - time_series_sample_rows=_bounded(selected["time_series"], rows), + quality_sample_rows=_policy_row_cap(selected["quality"], rows, policy), + deep_statistics_sample_rows=_policy_row_cap(selected["deep"], rows, policy), + bootstrap_sample_rows=_policy_row_cap(selected["bootstrap"], rows, policy), + distribution_sample_rows=_policy_row_cap( + selected["distribution"], rows, policy + ), + pair_sample_rows=_policy_row_cap(selected["pair"], rows, policy), + anomaly_sample_rows=_policy_row_cap(selected["anomaly"], rows, policy), + time_series_sample_rows=_policy_row_cap(selected["time_series"], rows, policy), relationship_pair_budget=relationship_budget, max_memory_heavy_parallelism=heavy_parallelism, ) @@ -280,9 +357,10 @@ def deterministic_sample_frame( preserve_order: bool = False, ) -> tuple[pd.DataFrame, dict[str, Any]]: """Return a deterministic bounded view and transparent sampling metadata.""" + if not isinstance(dataframe, pd.DataFrame): + raise TypeError("dataframe must be a pandas DataFrame.") + max_rows = _require_positive_int("max_rows", max_rows) source_rows = int(len(dataframe)) - if max_rows < 1: - raise ValueError("max_rows must be at least 1.") if source_rows <= max_rows: return dataframe, { @@ -295,8 +373,6 @@ def deterministic_sample_frame( positions = _deterministic_stratified_positions(source_rows, max_rows) sampled = dataframe.iloc[positions] if not preserve_order: - # Positions stay sorted so time-aware callers can preserve ordering, while - # statistical callers receive a copy that is safe to mutate downstream. sampled = sampled.copy() return sampled, { diff --git a/src/framevitals/execution_context.py b/src/framevitals/execution_context.py new file mode 100644 index 0000000..949cb2b --- /dev/null +++ b/src/framevitals/execution_context.py @@ -0,0 +1,198 @@ +"""Per-run reusable execution context for FrameVitals analyses. + +The context is intentionally backend-agnostic and stores references only for the +lifetime of one analysis run. It provides a thread-safe cache for intermediates, +named reusable samples, and structured metadata that can be surfaced without +serializing raw dataset values. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from threading import RLock +from typing import Any, Callable, Mapping + +from framevitals.config import AnalysisConfig +from framevitals.execution import ExecutionPolicy + + +CONTEXT_SCHEMA_VERSION = "1" +DEFAULT_CONTEXT_SEED = 0x9E3779B97F4A7C15 +_MISSING = object() + + +@dataclass(slots=True) +class AnalysisContext: + """Mutable per-run state shared by planning and execution stages. + + The object itself is not part of the public result schema. ``metadata()`` is + safe to expose because it reports names/counts/provenance only and never raw + cached values or sample contents. + """ + + dataset_name: str + source: Mapping[str, Any] + config: AnalysisConfig + execution_policy: ExecutionPolicy + rows: int + columns: int + seed: int = DEFAULT_CONTEXT_SEED + _facts: dict[str, Any] = field(default_factory=dict, init=False, repr=False) + _cache: dict[str, Any] = field(default_factory=dict, init=False, repr=False) + _samples: dict[str, Any] = field(default_factory=dict, init=False, repr=False) + _sample_metadata: dict[str, dict[str, Any]] = field( + default_factory=dict, + init=False, + repr=False, + ) + _cache_hits: int = field(default=0, init=False, repr=False) + _cache_misses: int = field(default=0, init=False, repr=False) + _lock: RLock = field(default_factory=RLock, init=False, repr=False) + + def __post_init__(self) -> None: + self.dataset_name = str(self.dataset_name or "") + self.source = dict(self.source) + self.rows = int(self.rows) + self.columns = int(self.columns) + self.seed = int(self.seed) + if self.rows < 0 or self.columns < 0: + raise ValueError("AnalysisContext rows and columns must be non-negative.") + + @property + def shape(self) -> tuple[int, int]: + return self.rows, self.columns + + @property + def fact_names(self) -> tuple[str, ...]: + with self._lock: + return tuple(sorted(self._facts)) + + @property + def sample_names(self) -> tuple[str, ...]: + with self._lock: + return tuple(sorted(self._samples)) + + def set_fact(self, name: str, value: Any, *, overwrite: bool = False) -> Any: + """Store an authoritative run fact and return ``value`` for fluent use.""" + key = str(name).strip() + if not key: + raise ValueError("fact name must not be empty") + with self._lock: + if key in self._facts and not overwrite: + raise KeyError(f"AnalysisContext fact already exists: {key}") + self._facts[key] = value + return value + + def fact(self, name: str, default: Any = None) -> Any: + """Return a stored fact without copying its potentially large value.""" + with self._lock: + return self._facts.get(name, default) + + def require_fact(self, name: str) -> Any: + """Return a stored fact or fail with a descriptive error.""" + with self._lock: + value = self._facts.get(name, _MISSING) + if value is _MISSING: + raise KeyError(f"AnalysisContext fact is not available: {name}") + return value + + def get_or_compute(self, key: str, factory: Callable[[], Any]) -> Any: + """Return a cached intermediate, computing it at most once per context. + + The factory executes while the re-entrant context lock is held. That makes + duplicate work impossible when independent scheduler threads request the + same reusable intermediate concurrently. Context factories should remain + local computations and should not wait on other analysis threads. + """ + cache_key = str(key).strip() + if not cache_key: + raise ValueError("cache key must not be empty") + if not callable(factory): + raise TypeError("factory must be callable") + + with self._lock: + if cache_key in self._cache: + self._cache_hits += 1 + return self._cache[cache_key] + value = factory() + self._cache[cache_key] = value + self._cache_misses += 1 + return value + + def cache_value(self, key: str, value: Any, *, overwrite: bool = False) -> Any: + """Insert a known intermediate without invoking a factory.""" + cache_key = str(key).strip() + if not cache_key: + raise ValueError("cache key must not be empty") + with self._lock: + if cache_key in self._cache and not overwrite: + raise KeyError(f"AnalysisContext cache entry already exists: {cache_key}") + self._cache[cache_key] = value + return value + + def store_sample( + self, + name: str, + sample: Any, + *, + metadata: Mapping[str, Any] | None = None, + overwrite: bool = False, + ) -> Any: + """Retain a named bounded sample for reuse without serializing its values.""" + key = str(name).strip() + if not key: + raise ValueError("sample name must not be empty") + + sample_metadata = dict(metadata or {}) + if "rows" not in sample_metadata: + try: + sample_metadata["rows"] = int(len(sample)) + except (TypeError, AttributeError): + pass + if "columns" not in sample_metadata: + columns = getattr(sample, "columns", None) + if columns is not None: + try: + sample_metadata["columns"] = int(len(columns)) + except TypeError: + pass + + with self._lock: + if key in self._samples and not overwrite: + raise KeyError(f"AnalysisContext sample already exists: {key}") + self._samples[key] = sample + self._sample_metadata[key] = sample_metadata + return sample + + def sample(self, name: str, default: Any = None) -> Any: + """Return a retained sample by name.""" + with self._lock: + return self._samples.get(name, default) + + def metadata(self) -> dict[str, Any]: + """Return JSON-safe-ish context provenance without raw cached/sample data.""" + with self._lock: + samples = deepcopy(self._sample_metadata) + facts = sorted(self._facts) + cache_entries = sorted(self._cache) + hits = int(self._cache_hits) + misses = int(self._cache_misses) + + return { + "context_schema_version": CONTEXT_SCHEMA_VERSION, + "dataset_name": self.dataset_name, + "shape": {"rows": self.rows, "columns": self.columns}, + "source": deepcopy(dict(self.source)), + "config": self.config.to_dict(), + "resource_policy": self.execution_policy.to_dict(), + "seed": self.seed, + "facts": facts, + "cache": { + "entries": cache_entries, + "entry_count": len(cache_entries), + "hits": hits, + "misses": misses, + }, + "samples": samples, + } diff --git a/src/framevitals/loader.py b/src/framevitals/loader.py index 9e55b7f..209532b 100644 --- a/src/framevitals/loader.py +++ b/src/framevitals/loader.py @@ -10,12 +10,23 @@ UPLOAD_DIR = Path("uploads") +_SAFE_UPLOAD_SUFFIXES = { + ".csv": ".csv", + ".tsv": ".tsv", + ".xlsx": ".xlsx", + ".xls": ".xls", + ".json": ".json", +} def save_uploaded_file(uploaded_file): """ Save a web-uploaded dataset and return its generated dataset ID, saved path, and sanitized original filename. + + The storage path is composed only from server-generated data and a suffix + selected from a fixed allowlist. The client filename is retained only as + sanitized display metadata and never becomes a filesystem path component. """ UPLOAD_DIR.mkdir( @@ -29,9 +40,15 @@ def save_uploaded_file(uploaded_file): uploaded_file.filename ) - suffix = Path( + requested_suffix = Path( original_filename ).suffix.lower() + try: + suffix = _SAFE_UPLOAD_SUFFIXES[requested_suffix] + except KeyError as exc: + # ``validate_file`` should make this unreachable, but keep the storage + # boundary independently safe if the validation contract ever changes. + raise ValueError("Unsupported upload format.") from exc dataset_id = uuid4().hex[:12] diff --git a/src/framevitals/pipeline.py b/src/framevitals/pipeline.py index 0486af0..024f686 100644 --- a/src/framevitals/pipeline.py +++ b/src/framevitals/pipeline.py @@ -176,7 +176,7 @@ def module_enabled(name: str) -> bool: df, profile=profile, column_roles=column_roles, - max_sample_rows=max(execution_budget.quality_sample_rows, 10), + max_sample_rows=execution_budget.quality_sample_rows, ), ) timings_ms["quality_diagnostics"] = quality_elapsed diff --git a/src/framevitals/planner.py b/src/framevitals/planner.py new file mode 100644 index 0000000..ae23eef --- /dev/null +++ b/src/framevitals/planner.py @@ -0,0 +1,338 @@ +"""Declarative execution planning for FrameVitals. + +The planner combines signal-driven analysis selection with stable runtime module +policy so ``plan()`` can explain both *what* analyses are applicable and *which* +execution modules are expected to run, be skipped, or remain conditional. + +Version 0.3 centralizes mode policy here first. Materialized and streaming +execution can progressively consume the same planner contract without changing +the public result shape or duplicating policy tables. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Iterable, Mapping + +from framevitals.analysis_selector import select_analyses +from framevitals.config import VALID_MODULES + + +PLANNER_SCHEMA_VERSION = "1" +_ALL_MODES = ("quick", "standard", "deep", "research") +_RUNNABLE_STATUSES = frozenset({"run", "conditional"}) + + +MODE_DISABLED_MODULES: dict[str, frozenset[str]] = { + "quick": frozenset({ + "deep_statistics", + "anomaly_detection", + "time_series", + "text_profile", + "modeling", + "explainability", + }), + "standard": frozenset({ + "deep_statistics", + "text_profile", + "modeling", + "explainability", + }), + "deep": frozenset({"modeling", "explainability"}), + "research": frozenset(), +} + + +@dataclass(frozen=True, slots=True) +class ModuleRule: + """Declarative runtime rule for one execution module.""" + + resource_class: str + modes: tuple[str, ...] = _ALL_MODES + depends_on: tuple[str, ...] = () + requires_target: bool = False + requires_all_signals: tuple[str, ...] = () + requires_any_signals: tuple[str, ...] = () + artifacts_required: bool = False + conditional_reason: str | None = None + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +MODULE_RULES: dict[str, ModuleRule] = { + "quality_diagnostics": ModuleRule(resource_class="bounded_cpu"), + "deep_statistics": ModuleRule( + resource_class="memory_heavy", + requires_all_signals=("has_numeric_columns",), + ), + "anomaly_detection": ModuleRule( + resource_class="memory_heavy", + requires_all_signals=("has_numeric_columns",), + ), + "time_series": ModuleRule( + resource_class="memory_heavy", + requires_any_signals=("has_datetime_columns", "has_time_series_structure"), + ), + "text_profile": ModuleRule( + resource_class="bounded_cpu", + requires_all_signals=("has_long_text_columns",), + ), + "target_intelligence": ModuleRule( + resource_class="bounded_cpu", + requires_target=True, + ), + "modeling": ModuleRule( + resource_class="memory_heavy", + requires_target=True, + depends_on=("target_intelligence",), + ), + "explainability": ModuleRule( + resource_class="memory_heavy", + requires_target=True, + depends_on=("modeling",), + conditional_reason="Runs only when modeling produces an explainable winner.", + ), + "cleaning": ModuleRule(resource_class="bounded_cpu"), + "charts": ModuleRule( + resource_class="artifact_io", + modes=("standard", "deep", "research"), + artifacts_required=True, + ), + "ai": ModuleRule( + resource_class="optional_external", + conditional_reason="Requires explicit runtime AI opt-in and an available provider.", + ), +} + +if set(MODULE_RULES) != VALID_MODULES: + missing = sorted(VALID_MODULES - set(MODULE_RULES)) + extra = sorted(set(MODULE_RULES) - VALID_MODULES) + raise RuntimeError( + "Planner module rules are out of sync with VALID_MODULES " + f"(missing={missing}, extra={extra})." + ) + +for _module_name, _module_rule in MODULE_RULES.items(): + unknown_dependencies = sorted(set(_module_rule.depends_on) - VALID_MODULES) + if unknown_dependencies: + raise RuntimeError( + f"Planner module {_module_name} has unknown dependencies: " + + ", ".join(unknown_dependencies) + ) + + +def effective_disabled_modules( + mode: str, + user_disabled: Iterable[str] = (), +) -> tuple[str, ...]: + """Merge explicit disables with the stable built-in policy for ``mode``.""" + implicit = MODE_DISABLED_MODULES.get(mode) + if implicit is None: + raise ValueError(f"Unknown analysis mode: {mode}") + explicit = {str(name) for name in user_disabled} + unknown = sorted(explicit - VALID_MODULES) + if unknown: + raise ValueError("Unknown disabled module(s): " + ", ".join(unknown)) + return tuple(sorted(explicit | set(implicit))) + + +def _missing_signal_reason( + rule: ModuleRule, + signals: Mapping[str, Any], +) -> str | None: + missing = [name for name in rule.requires_all_signals if not signals.get(name)] + if missing: + return "Requires signal(s): " + ", ".join(missing) + "." + + if rule.requires_any_signals and not any( + signals.get(name) for name in rule.requires_any_signals + ): + return "Requires at least one signal: " + ", ".join(rule.requires_any_signals) + "." + return None + + +def _apply_dependency_constraints( + decisions: dict[str, dict[str, Any]], +) -> None: + """Block modules whose declared upstream dependencies cannot run.""" + changed = True + while changed: + changed = False + for module in sorted(decisions): + decision = decisions[module] + if decision["status"] not in _RUNNABLE_STATUSES: + continue + + blockers = [ + dependency + for dependency in MODULE_RULES[module].depends_on + if decisions[dependency]["status"] not in _RUNNABLE_STATUSES + ] + if not blockers: + continue + + decision["status"] = "not_applicable" + decision["blocked_by"] = blockers + rendered = ", ".join( + f"{dependency} ({decisions[dependency]['status']})" + for dependency in blockers + ) + decision["reason"] = f"Blocked by dependency: {rendered}." + changed = True + + +def _execution_stages( + decisions: Mapping[str, Mapping[str, Any]], +) -> list[dict[str, Any]]: + """Topologically group runnable modules into dependency-safe stages.""" + pending = { + module + for module, decision in decisions.items() + if decision.get("status") in _RUNNABLE_STATUSES + } + scheduled: set[str] = set() + stages: list[dict[str, Any]] = [] + + while pending: + ready = sorted( + module + for module in pending + if all( + dependency in scheduled or dependency not in pending + for dependency in MODULE_RULES[module].depends_on + ) + ) + if not ready: + cycle = ", ".join(sorted(pending)) + raise RuntimeError(f"Planner dependency cycle detected among: {cycle}") + + stages.append({ + "stage": len(stages), + "modules": ready, + "resource_classes": sorted({ + str(decisions[module]["resource_class"]) + for module in ready + }), + }) + scheduled.update(ready) + pending.difference_update(ready) + + return stages + + +def plan_execution_modules( + *, + signals: Mapping[str, Any], + analysis_mode: str, + target_column: str | None, + disabled_modules: Iterable[str] = (), + artifacts: bool = False, +) -> dict[str, Any]: + """Return explainable runtime-module decisions for one planned analysis.""" + if analysis_mode not in MODE_DISABLED_MODULES: + raise ValueError(f"Unknown analysis mode: {analysis_mode}") + + explicit_disabled = {str(name) for name in disabled_modules} + effective_disabled = set( + effective_disabled_modules(analysis_mode, explicit_disabled) + ) + implicit_disabled = effective_disabled - explicit_disabled + + decisions: dict[str, dict[str, Any]] = {} + + for module in sorted(VALID_MODULES): + rule = MODULE_RULES[module] + status: str + reason: str + + if module in explicit_disabled: + status = "disabled_by_config" + reason = "Disabled by explicit configuration." + elif module in implicit_disabled: + status = "disabled_by_mode" + reason = f"Disabled by the {analysis_mode} mode policy." + elif analysis_mode not in rule.modes: + status = "not_applicable" + reason = f"Not applicable in {analysis_mode} mode." + elif rule.requires_target and not target_column: + status = "not_applicable" + reason = "Requires an explicit target column." + elif rule.artifacts_required and not artifacts: + status = "not_applicable" + reason = "Requires artifact generation to be enabled." + else: + signal_reason = _missing_signal_reason(rule, signals) + if signal_reason is not None: + status = "not_applicable" + reason = signal_reason + elif rule.conditional_reason is not None: + status = "conditional" + reason = rule.conditional_reason + else: + status = "run" + reason = "Applicable under the resolved mode, signals, and configuration." + + decisions[module] = { + "status": status, + "reason": reason, + "resource_class": rule.resource_class, + "depends_on": list(rule.depends_on), + "blocked_by": [], + } + + _apply_dependency_constraints(decisions) + + counts: dict[str, int] = {} + for decision in decisions.values(): + status = str(decision["status"]) + counts[status] = counts.get(status, 0) + 1 + + stages = _execution_stages(decisions) + runnable_modules = [ + module + for stage in stages + for module in stage["modules"] + ] + + return { + "explicit_disabled": sorted(explicit_disabled), + "effective_disabled": sorted(effective_disabled), + "decisions": decisions, + "summary": dict(sorted(counts.items())), + "execution_stages": stages, + "runnable_modules": runnable_modules, + } + + +def build_execution_plan( + *, + signals: Mapping[str, Any], + analysis_mode: str, + target_column: str | None = None, + disabled_modules: Iterable[str] = (), + artifacts: bool = False, +) -> dict[str, Any]: + """Build the stable planner contract consumed by ``framevitals.plan``.""" + selection = select_analyses( + signals=signals, + analysis_mode=analysis_mode, + target_column=target_column, + ) + modules = plan_execution_modules( + signals=signals, + analysis_mode=analysis_mode, + target_column=target_column, + disabled_modules=disabled_modules, + artifacts=artifacts, + ) + explicit_disabled = set(modules["explicit_disabled"]) + + # Preserve the 0.2/early-0.3 compatibility keys while adding the richer + # versioned decision model alongside them. + modules["disabled"] = sorted(explicit_disabled) + modules["enabled"] = sorted(VALID_MODULES - explicit_disabled) + + selection["planner_schema_version"] = PLANNER_SCHEMA_VERSION + selection["execution_modules"] = modules + return selection diff --git a/src/framevitals/planning.py b/src/framevitals/planning.py index 43d0e61..cf6c4f2 100644 --- a/src/framevitals/planning.py +++ b/src/framevitals/planning.py @@ -34,7 +34,33 @@ def recommended(self) -> list[dict[str, Any]]: value = self.selection.get("recommended_analyses", []) return value if isinstance(value, list) else [] + @property + def resource_policy(self) -> dict[str, Any]: + value = self.get("resource_policy", {}) + return value if isinstance(value, dict) else {} + + @property + def execution_budget(self) -> dict[str, Any]: + value = self.get("execution_budget", {}) + return value if isinstance(value, dict) else {} + + @property + def execution_modules(self) -> dict[str, Any]: + value = self.selection.get("execution_modules", {}) + return value if isinstance(value, dict) else {} + + @property + def module_decisions(self) -> dict[str, dict[str, Any]]: + value = self.execution_modules.get("decisions", {}) + return value if isinstance(value, dict) else {} + + @property + def planner_schema_version(self) -> str | None: + value = self.selection.get("planner_schema_version") + return str(value) if value is not None else None + def summary(self) -> dict[str, Any]: + module_summary = self.execution_modules.get("summary", {}) return { "dataset_name": self.get("dataset_name"), "analysis_mode": self.get("analysis_mode"), @@ -43,6 +69,10 @@ def summary(self) -> dict[str, Any]: "selected_count": len(self.selected), "skipped_count": len(self.skipped), "recommended_count": len(self.recommended), + "planner_schema_version": self.planner_schema_version, + "module_summary": ( + dict(module_summary) if isinstance(module_summary, dict) else {} + ), } def explain_text(self) -> str: @@ -54,11 +84,55 @@ def explain_text(self) -> str: f"Dataset {self.get('dataset_name', '')}", f"Mode {self.get('analysis_mode', 'unknown')}", f"Target {self.get('target') or ''}", - f"Shape {shape.get('rows', '?')} rows x {shape.get('columns', '?')} columns", - "", - f"Selected {len(self.selected)}", + ( + "Shape " + f"{shape.get('rows', '?')} rows x " + f"{shape.get('columns', '?')} columns" + ), ] + if self.planner_schema_version is not None: + lines.append(f"Planner schema v{self.planner_schema_version}") + + configured = [ + f"{name}={value}" + for name, value in self.resource_policy.items() + if value is not None + ] + if configured: + lines.append("Resource caps " + ", ".join(configured)) + + budget = self.execution_budget + if budget: + sample_keys = ( + "quality_sample_rows", + "deep_statistics_sample_rows", + "anomaly_sample_rows", + "time_series_sample_rows", + ) + max_sample = max((budget.get(key, 0) or 0) for key in sample_keys) + lines.append( + "Budget " + f"sample<= {max_sample}, " + f"pairs<= {budget.get('relationship_pair_budget', '?')}, " + f"heavy_workers<= " + f"{budget.get('max_memory_heavy_parallelism', '?')}" + ) + + module_summary = self.execution_modules.get("summary", {}) + if isinstance(module_summary, dict) and module_summary: + rendered = ", ".join( + f"{status}={count}" + for status, count in sorted(module_summary.items()) + ) + lines.extend(["", "Execution modules", f" {rendered}"]) + for name, decision in self.module_decisions.items(): + status = str(decision.get("status", "unknown")).upper() + reason = " ".join(str(decision.get("reason", "")).split()) + if len(reason) > 58: + reason = reason[:57].rstrip() + "…" + lines.append(f" [{status:<18}] {name:<24} {reason}") + lines.extend(["", f"Selected {len(self.selected)}"]) for item in self.selected: lines.append( f" [RUN] {item.get('id', ''):<28} {item.get('name', '')}" diff --git a/src/framevitals/planning_api.py b/src/framevitals/planning_api.py index d3063ed..218ace0 100644 --- a/src/framevitals/planning_api.py +++ b/src/framevitals/planning_api.py @@ -14,14 +14,17 @@ import pandas as pd -from framevitals.analysis_selector import select_analyses from framevitals.column_roles import infer_column_roles -from framevitals.config import ConfigInput, VALID_MODULES, resolve_config +from framevitals.config import AnalysisConfig, ConfigInput, resolve_config from framevitals.dataset_signals import detect_dataset_signals from framevitals.execution import ( + ExecutionPolicy, derive_execution_budget, derive_streaming_profile_column_limit, + use_execution_policy, ) +from framevitals.execution_context import AnalysisContext +from framevitals.planner import build_execution_plan from framevitals.planning import AnalysisPlan from framevitals.profiler import build_profile from framevitals.sources import StreamingDatasetSource, resolve_source @@ -134,35 +137,24 @@ def _project_sample_profile_to_source( return projected -def plan( +def _build_plan( data: DataInput, *, - target: str | None = None, - mode: str | None = None, - workers: int | None = None, - preset: str | None = None, - config: ConfigInput = None, - disabled_modules: list[str] | tuple[str, ...] | None = None, + resolved: AnalysisConfig, + execution_policy: ExecutionPolicy, ) -> AnalysisPlan: - """Preview planned analyses, scale policy, and execution constraints.""" - resolved = resolve_config( - config, - preset=preset, - mode=mode, - target=target, - workers=workers, - artifacts=False, - disabled_modules=disabled_modules, - ) - source = resolve_source(data) source_metadata = source.inspect() - if source_metadata.supports_streaming and isinstance(source, StreamingDatasetSource): + if source_metadata.supports_streaming and isinstance( + source, StreamingDatasetSource + ): source_rows = int(source_metadata.rows or 0) source_columns = int(source_metadata.columns or 0) if source_rows < 1 or source_columns < 1: - raise ValueError(f"Dataset is empty or has no columns: {source_metadata.name}") + raise ValueError( + f"Dataset is empty or has no columns: {source_metadata.name}" + ) column_limit = derive_streaming_profile_column_limit( source_rows, @@ -175,8 +167,15 @@ def plan( limit=column_limit, target=resolved.target, ) + planning_sample_rows = PLANNING_SAMPLE_ROWS + if execution_policy.max_sample_rows is not None: + planning_sample_rows = min( + planning_sample_rows, + int(execution_policy.max_sample_rows), + ) dataframe = _streaming_head_sample( source, + max_rows=planning_sample_rows, columns=projected_columns, ) profiled_columns = int(len(dataframe.columns)) @@ -223,35 +222,72 @@ def plan( "column_limit": source_columns, } + context = AnalysisContext( + dataset_name=source_metadata.name, + source=source_metadata.to_dict(), + config=resolved, + execution_policy=execution_policy, + rows=source_rows, + columns=source_columns, + ) + context.store_sample( + "planning", + dataframe, + metadata={ + "scope": "planning", + "sampled": bool(planning_data["sampled"]), + "strategy": planning_data["strategy"], + "source_rows": source_rows, + "source_columns": source_columns, + "rows": int(len(dataframe)), + "columns": int(len(dataframe.columns)), + }, + ) + context.set_fact("profile", dataset_profile) + source_columns_list = list(dataset_profile.get("columns", dataframe.columns)) if resolved.target is not None and resolved.target not in source_columns_list: raise ValueError(f"Target column not found: {resolved.target}") - column_roles = infer_column_roles(dataframe) - dataset_signals = detect_dataset_signals( - dataframe, - dataset_profile, - column_roles=column_roles, - source_shape=(source_rows, source_columns), + column_roles = context.get_or_compute( + "column_roles", + lambda: infer_column_roles(dataframe), + ) + context.set_fact("column_roles", column_roles) + + dataset_signals = context.get_or_compute( + "dataset_signals", + lambda: detect_dataset_signals( + dataframe, + dataset_profile, + column_roles=column_roles, + source_shape=(source_rows, source_columns), + ), ) + context.set_fact("signals", dataset_signals) - budget = derive_execution_budget( - source_rows, - source_columns, - mode=resolved.mode, + budget = context.get_or_compute( + "execution_budget", + lambda: derive_execution_budget( + source_rows, + source_columns, + mode=resolved.mode, + ), ) + context.set_fact("execution_budget", budget) - selection = select_analyses( - signals=dataset_signals, - analysis_mode=resolved.mode, - target_column=resolved.target, + selection = context.get_or_compute( + "execution_plan", + lambda: build_execution_plan( + signals=dataset_signals, + analysis_mode=resolved.mode, + target_column=resolved.target, + disabled_modules=resolved.disabled_modules, + artifacts=resolved.artifacts, + ), ) - disabled = set(resolved.disabled_modules) - selection["execution_modules"] = { - "disabled": sorted(disabled), - "enabled": sorted(VALID_MODULES - disabled), - } selection["execution_budget"] = budget.to_dict() + context.set_fact("selection", selection) public_signals = { key: value @@ -267,7 +303,46 @@ def plan( "target": resolved.target, "shape": dict(dataset_profile.get("shape", {})), "config": resolved.to_dict(), + "resource_policy": execution_policy.to_dict(), "execution_budget": budget.to_dict(), + "execution_context": context.metadata(), "signals": public_signals, "selection": selection, }) + + +def plan( + data: DataInput, + *, + target: str | None = None, + mode: str | None = None, + workers: int | None = None, + preset: str | None = None, + config: ConfigInput = None, + disabled_modules: list[str] | tuple[str, ...] | None = None, + max_sample_rows: int | None = None, + max_relationship_pairs: int | None = None, + max_memory_heavy_parallelism: int | None = None, + max_streaming_profile_columns: int | None = None, +) -> AnalysisPlan: + """Preview planned analyses, scale policy, and execution constraints.""" + resolved = resolve_config( + config, + preset=preset, + mode=mode, + target=target, + workers=workers, + artifacts=False, + disabled_modules=disabled_modules, + max_sample_rows=max_sample_rows, + max_relationship_pairs=max_relationship_pairs, + max_memory_heavy_parallelism=max_memory_heavy_parallelism, + max_streaming_profile_columns=max_streaming_profile_columns, + ) + execution_policy = ExecutionPolicy(**resolved.execution_policy()) + with use_execution_policy(execution_policy): + return _build_plan( + data, + resolved=resolved, + execution_policy=execution_policy, + ) diff --git a/src/framevitals/quality_diagnostics.py b/src/framevitals/quality_diagnostics.py index 29ded4a..bc9139b 100644 --- a/src/framevitals/quality_diagnostics.py +++ b/src/framevitals/quality_diagnostics.py @@ -435,6 +435,15 @@ def _missingness_relationships( return relationships[:30] +def _require_int_control(name: str, value: Any, *, minimum: int) -> int: + if isinstance(value, bool) or not isinstance(value, (int, np.integer)): + raise TypeError(f"{name} must be an integer.") + resolved = int(value) + if resolved < minimum: + raise ValueError(f"{name} must be at least {minimum}.") + return resolved + + def run_quality_diagnostics( df: pd.DataFrame, *, @@ -445,12 +454,17 @@ def run_quality_diagnostics( max_missingness_columns: int = DEFAULT_MAX_MISSINGNESS_COLUMNS, ) -> dict[str, Any]: """Return a bounded, deterministic set of practical data-quality checks.""" - if max_sample_rows < 10: - raise ValueError("max_sample_rows must be at least 10.") - if max_columns < 1: - raise ValueError("max_columns must be at least 1.") - if max_missingness_columns < 2: - raise ValueError("max_missingness_columns must be at least 2.") + max_sample_rows = _require_int_control( + "max_sample_rows", + max_sample_rows, + minimum=1, + ) + max_columns = _require_int_control("max_columns", max_columns, minimum=1) + max_missingness_columns = _require_int_control( + "max_missingness_columns", + max_missingness_columns, + minimum=2, + ) if profile is None: from framevitals.profiler import build_profile @@ -529,7 +543,11 @@ def run_quality_diagnostics( "max_sample_rows": int(max_sample_rows), "duplicate_rows": duplicate_rows, "summary": { - "issue_groups": sum(bool(value) for key, value in checks.items() if key != "primary_key_candidates"), + "issue_groups": sum( + bool(value) + for key, value in checks.items() + if key != "primary_key_candidates" + ), "issue_count": issue_count + (1 if duplicate_rows else 0), "primary_key_candidate_count": len(primary_keys), }, diff --git a/src/framevitals/rag_index.py b/src/framevitals/rag_index.py index c515ddd..53e13f6 100644 --- a/src/framevitals/rag_index.py +++ b/src/framevitals/rag_index.py @@ -1,19 +1,15 @@ """ -RAG Fact Index (WS-10) -====================== +RAG Fact Index +============== Flatten an analysis result into atomic facts, then retrieve the top-k facts relevant to a free-form question. Two retrieval backends: - 1. Ollama embeddings (`nomic-embed-text` by default) — preferred when reachable. - 2. TF-IDF cosine similarity (sklearn) — always-available fallback. + 1. Ollama embeddings (``nomic-embed-text`` by default) when reachable. + 2. TF-IDF cosine similarity (sklearn) as the always-available fallback. The retrieval API stays identical regardless of backend, so the agent layer never has to care which one is in play. - -Public API: - facts = build_fact_index(analysis_result) - top = retrieve(question, facts, k=8) """ from __future__ import annotations @@ -25,18 +21,56 @@ import numpy as np -_EMBED_MODEL = os.environ.get("OLLAMA_EMBED_MODEL", "nomic-embed-text") +_DEFAULT_EMBED_MODEL = "nomic-embed-text" +_DEFAULT_EMBED_CONCURRENCY = 8 +_MAX_EMBED_CONCURRENCY = 32 + + +def _environment_value( + name: str, + *, + legacy_name: str | None = None, + default: str = "", +) -> str: + """Read a FrameVitals setting with an optional 0.x compatibility alias.""" + value = os.environ.get(name) + if value is not None: + return value + if legacy_name is not None: + legacy_value = os.environ.get(legacy_name) + if legacy_value is not None: + return legacy_value + return default + + +def _embedding_model() -> str: + value = _environment_value( + "FRAMEVITALS_OLLAMA_EMBED_MODEL", + legacy_name="OLLAMA_EMBED_MODEL", + default=_DEFAULT_EMBED_MODEL, + ).strip() + return value or _DEFAULT_EMBED_MODEL + + +def _embedding_concurrency() -> int: + raw = _environment_value( + "FRAMEVITALS_RAG_EMBED_CONCURRENCY", + legacy_name="DATALENS_RAG_EMBED_CONCURRENCY", + default=str(_DEFAULT_EMBED_CONCURRENCY), + ).strip() + try: + value = int(raw) + except ValueError: + return _DEFAULT_EMBED_CONCURRENCY + return max(1, min(value, _MAX_EMBED_CONCURRENCY)) -# Number of concurrent Ollama embedding requests. Embedding ~1k facts one at -# a time over localhost is ~25s; parallelising at 8 workers brings that under -# 4s on a typical machine. Tunable via env so a constrained box (e.g. one -# with a small GPU) can dial it back. -_EMBED_CONCURRENCY = int(os.environ.get("DATALENS_RAG_EMBED_CONCURRENCY", "8")) +def _rag_backend_override() -> str: + return _environment_value( + "FRAMEVITALS_RAG_BACKEND", + legacy_name="DATALENS_RAG_BACKEND", + ).strip().lower() -# --------------------------------------------------------------------------- -# Fact dataclass -# --------------------------------------------------------------------------- @dataclass class Fact: @@ -49,105 +83,85 @@ def to_dict(self) -> dict: return {"path": self.path, "text": self.text, "value": self.value} -# --------------------------------------------------------------------------- -# Flattening -# --------------------------------------------------------------------------- - # Paths excluded from the fact index — too noisy or too verbose to embed. _EXCLUDE_PATH_FRAGMENTS = ( "profile.preview", "profile.correlations.", - "charts", # raw chart paths aren't useful as text facts - "ai_report.text", # already a free-form summary, redundant + "charts", + "ai_report.text", "explainability.summary_chart_path", - "anomalies_v2.top_rows.", # individual anomaly rows are noisy + "anomalies_v2.top_rows.", "deep_statistics_v2.numeric_statistics.", "deep_statistics_v2.categorical_statistics.", ) def _is_excluded(path: str) -> bool: - return any(frag in path for frag in _EXCLUDE_PATH_FRAGMENTS) + return any(fragment in path for fragment in _EXCLUDE_PATH_FRAGMENTS) def _summarize_value(value: Any, max_len: int = 220) -> str: - """Short string form of a leaf value, suitable for embedding.""" + """Return a short leaf representation suitable for retrieval.""" if value is None: return "null" if isinstance(value, bool): return "true" if value else "false" if isinstance(value, (int, float)): - # Round floats for stability if isinstance(value, float): return f"{value:.4f}".rstrip("0").rstrip(".") return str(value) if isinstance(value, str): - v = value.strip() - return v if len(v) <= max_len else v[:max_len] + "…" + normalized = value.strip() + return ( + normalized + if len(normalized) <= max_len + else normalized[:max_len] + "…" + ) return str(value)[:max_len] def _humanize_path(path: str) -> str: - """Turn dotted path into a readable label for retrieval.""" return path.replace(".", " ").replace("_", " ") def _walk(obj: Any, path: str = "") -> Iterable[tuple[str, Any]]: - """Yield (path, value) leaves from a nested dict/list.""" + """Yield ``(path, value)`` leaves from a nested dict/list.""" if isinstance(obj, dict): - for key, val in obj.items(): + for key, value in obj.items(): sub_path = f"{path}.{key}" if path else str(key) if _is_excluded(sub_path): continue - yield from _walk(val, sub_path) + yield from _walk(value, sub_path) elif isinstance(obj, list): - # Summarize lists of dicts / scalars compactly rather than yielding every - # element. This keeps the index focused on aggregate facts. - if len(obj) == 0: + if not obj: yield path, [] return - - # All scalars -> single fact with a short list if all(not isinstance(item, (dict, list)) for item in obj): yield path, obj[:10] return - - # List of dicts -> yield a few summarized children - for i, item in enumerate(obj[:5]): - yield from _walk(item, f"{path}[{i}]") + for index, item in enumerate(obj[:5]): + yield from _walk(item, f"{path}[{index}]") else: yield path, obj def _build_fact_text(path: str, value: Any) -> str: - label = _humanize_path(path) - summary = _summarize_value(value) - return f"{label}: {summary}" + return f"{_humanize_path(path)}: {_summarize_value(value)}" def build_fact_index(analysis_result: dict) -> list[Fact]: - """Flatten the analysis result into a list of Fact objects.""" + """Flatten an analysis result into retrieval facts.""" facts: list[Fact] = [] for path, value in _walk(analysis_result): text = _build_fact_text(path, value) - if not text or len(text) < 3: + if len(text) < 3: continue facts.append(Fact(path=path, text=text, value=value)) return facts -# --------------------------------------------------------------------------- -# Embedding backends -# --------------------------------------------------------------------------- - def _embed_with_ollama(texts: list[str]) -> np.ndarray | None: - """Return (n, d) array of embeddings, or None if Ollama is unavailable. - - Uses a small thread pool so embedding a large corpus (~1k facts) doesn't - serialize into one ~25s wall-clock waterfall of HTTP round-trips. Each - individual /api/embeddings call is cheap (~10-30ms on localhost) but the - server happily handles them concurrently. - """ + """Return an ``(n, d)`` embedding array, or ``None`` if unavailable.""" try: import ollama except Exception: @@ -156,11 +170,11 @@ def _embed_with_ollama(texts: list[str]) -> np.ndarray | None: if not texts: return np.zeros((0, 0), dtype=float) - # Single-shot fast path — avoids spinning up a pool for query-side calls. - if len(texts) <= 1: + model = _embedding_model() + if len(texts) == 1: try: - resp = ollama.embeddings(model=_EMBED_MODEL, prompt=texts[0]) - return np.asarray([resp["embedding"]], dtype=float) + response = ollama.embeddings(model=model, prompt=texts[0]) + return np.asarray([response["embedding"]], dtype=float) except Exception: return None @@ -168,127 +182,121 @@ def _embed_with_ollama(texts: list[str]) -> np.ndarray | None: embeddings: list[list[float] | None] = [None] * len(texts) - def _one(idx_text: tuple[int, str]) -> tuple[int, list[float] | None]: - i, t = idx_text + def _one(indexed_text: tuple[int, str]) -> tuple[int, list[float] | None]: + index, text = indexed_text try: - resp = ollama.embeddings(model=_EMBED_MODEL, prompt=t) - return i, list(resp["embedding"]) + response = ollama.embeddings(model=model, prompt=text) + return index, list(response["embedding"]) except Exception: - return i, None + return index, None - workers = max(1, min(_EMBED_CONCURRENCY, len(texts))) + workers = min(_embedding_concurrency(), len(texts)) try: with ThreadPoolExecutor(max_workers=workers) as pool: - for i, vec in pool.map(_one, list(enumerate(texts))): - embeddings[i] = vec + for index, vector in pool.map(_one, enumerate(texts)): + embeddings[index] = vector except Exception: return None - if any(v is None for v in embeddings): - # If even one call failed we cannot trust the matrix shape; fall back - # so the caller picks TF-IDF instead of seeing a ragged ndarray. + if any(vector is None for vector in embeddings): return None return np.asarray(embeddings, dtype=float) -def _embed_with_tfidf(corpus: list[str], queries: list[str]) -> tuple[np.ndarray, np.ndarray]: - """TF-IDF fallback. Returns (corpus_vectors, query_vectors).""" +def _embed_with_tfidf( + corpus: list[str], + queries: list[str], +) -> tuple[np.ndarray, np.ndarray]: + """Return TF-IDF corpus and query vectors.""" from sklearn.feature_extraction.text import TfidfVectorizer - vec = TfidfVectorizer( + vectorizer = TfidfVectorizer( max_features=4096, ngram_range=(1, 2), lowercase=True, token_pattern=r"(?u)\b[\w\-]{2,}\b", ) - vec.fit(corpus + queries) - return vec.transform(corpus).toarray(), vec.transform(queries).toarray() + vectorizer.fit(corpus + queries) + return ( + vectorizer.transform(corpus).toarray(), + vectorizer.transform(queries).toarray(), + ) def _cosine_top_k(query_vec: np.ndarray, matrix: np.ndarray, k: int) -> list[int]: - """Return indices of the k highest cosine similarities.""" - if matrix.size == 0: + """Return indices of the ``k`` highest cosine similarities.""" + if matrix.size == 0 or k <= 0: return [] norms = np.linalg.norm(matrix, axis=1) - qnorm = float(np.linalg.norm(query_vec)) - if qnorm == 0: + query_norm = float(np.linalg.norm(query_vec)) + if query_norm == 0: return [] - safe = np.where(norms > 0, norms, 1.0) - sims = (matrix @ query_vec) / (safe * qnorm) - sims = np.where(norms > 0, sims, 0.0) - if k >= len(sims): - order = np.argsort(-sims) + + safe_norms = np.where(norms > 0, norms, 1.0) + similarities = (matrix @ query_vec) / (safe_norms * query_norm) + similarities = np.where(norms > 0, similarities, 0.0) + if k >= len(similarities): + order = np.argsort(-similarities) else: - order = np.argpartition(-sims, k)[:k] - order = order[np.argsort(-sims[order])] + order = np.argpartition(-similarities, k - 1)[:k] + order = order[np.argsort(-similarities[order])] return order.tolist() -# --------------------------------------------------------------------------- -# Retrieval -# --------------------------------------------------------------------------- - def retrieve(question: str, facts: list[Fact], k: int = 8) -> dict: - """ - Retrieve the k facts most relevant to `question`. + """Retrieve the ``k`` facts most relevant to ``question``. - Returns: - {"backend": "ollama" | "tfidf", "facts": [Fact.to_dict(), ...], "k": int} - - Set ``DATALENS_RAG_BACKEND=tfidf`` to skip Ollama embeddings entirely. The - TF-IDF path retrieves from a 1k-fact corpus in well under 100ms and is a - perfectly sensible default for batch / test / CI runs where every second - counts. Ollama embeddings produce slightly better neighbours but cost a - multi-second waterfall through the embedding model. + ``FRAMEVITALS_RAG_BACKEND=tfidf`` skips Ollama entirely. The old + ``DATALENS_RAG_BACKEND`` name remains a compatibility fallback for the 0.x + series but is no longer the primary configuration surface. """ + if k < 1: + raise ValueError("k must be at least 1.") if not question or not facts: return {"backend": "none", "facts": [], "k": 0} - corpus = [f.text for f in facts] - - forced = os.environ.get("DATALENS_RAG_BACKEND", "").strip().lower() - use_ollama_first = forced != "tfidf" + corpus = [fact.text for fact in facts] + use_ollama_first = _rag_backend_override() != "tfidf" - corpus_emb = None - query_emb = None + corpus_embeddings = None + query_embedding = None backend = "tfidf" if use_ollama_first: - corpus_emb = _embed_with_ollama(corpus) - if corpus_emb is not None: - q_arr = _embed_with_ollama([question]) - if q_arr is not None and q_arr.shape[0] == 1: - query_emb = q_arr[0] + corpus_embeddings = _embed_with_ollama(corpus) + if corpus_embeddings is not None: + query_array = _embed_with_ollama([question]) + if query_array is not None and query_array.shape[0] == 1: + query_embedding = query_array[0] backend = "ollama" else: - corpus_emb = None # fall through to TF-IDF + corpus_embeddings = None - if corpus_emb is None or query_emb is None: + if corpus_embeddings is None or query_embedding is None: backend = "tfidf" - corpus_emb, query_arr = _embed_with_tfidf(corpus, [question]) - query_emb = query_arr[0] - - indices = _cosine_top_k(query_emb, corpus_emb, k) - selected = [facts[i] for i in indices] + corpus_embeddings, query_array = _embed_with_tfidf(corpus, [question]) + query_embedding = query_array[0] + indices = _cosine_top_k(query_embedding, corpus_embeddings, k) + selected = [facts[index] for index in indices] return { "backend": backend, "k": len(selected), - "facts": [f.to_dict() for f in selected], + "facts": [fact.to_dict() for fact in selected], } def render_facts_block(retrieved: dict, max_chars: int = 4000) -> str: - """Format retrieved facts as a compact text block for prompt injection.""" + """Format retrieved facts as a compact prompt block.""" items = retrieved.get("facts", []) if not items: return "(no relevant facts)" lines = [] used = 0 - for f in items: - line = f"- {f['text']}" + for fact in items: + line = f"- {fact['text']}" if used + len(line) + 1 > max_chars: break lines.append(line) diff --git a/src/framevitals/report_generator.py b/src/framevitals/report_generator.py index 01be309..920fefe 100644 --- a/src/framevitals/report_generator.py +++ b/src/framevitals/report_generator.py @@ -4,11 +4,13 @@ from framevitals.pdf_report_builder import generate_pdf_report as _generate_pdf_report + REPORT_DIR = Path("reports") -REPORT_DIR.mkdir(exist_ok=True) def generate_pdf_report(result): + """Generate a PDF report, creating the output directory only on demand.""" + REPORT_DIR.mkdir(parents=True, exist_ok=True) return _generate_pdf_report( result, output_dir=REPORT_DIR, diff --git a/src/framevitals/safe_pandas.py b/src/framevitals/safe_pandas.py index 65b0202..39fe655 100644 --- a/src/framevitals/safe_pandas.py +++ b/src/framevitals/safe_pandas.py @@ -1,24 +1,9 @@ -""" -Safe Pandas Evaluator -===================== -AST-allowlist sandbox for evaluating pandas expressions submitted by an LLM. - -Only a small whitelist of node types, names, and attribute accesses are -permitted. Anything else raises UnsafeExpression. - -Usage: - from framevitals.safe_pandas import safe_eval, UnsafeExpression - result = safe_eval("df['age'].mean()", df) - result = safe_eval("df.groupby('region')['revenue'].sum().head(5)", df) - -Rules: -- Only the names {df, np, pd, len, abs, min, max, round, sum, sorted} are allowed. -- Only the attribute access list below is permitted. -- No assignments, no imports, no function definitions, no comprehensions with - side effects, no f-strings, no exec/eval/compile/open. -- Maximum expression length is enforced. -- Maximum AST depth is enforced. -- Output is converted to a JSON-safe Python primitive (or list/dict thereof). +"""Safe, read-only pandas expression evaluator for the optional agent layer. + +Expressions are parsed with an AST allowlist and evaluated against an isolated +DataFrame copy. The evaluator intentionally exposes a small analytical surface; +private attributes, imports, arbitrary code execution, pandas ``query`` strings, +and mutating access to the caller's DataFrame are not permitted. """ from __future__ import annotations @@ -31,37 +16,25 @@ import pandas as pd -# --------------------------------------------------------------------------- -# Allowlists -# --------------------------------------------------------------------------- - _ALLOWED_NODES: set[type] = { ast.Expression, - ast.Module, - ast.Expr, - # Literals ast.Constant, ast.List, ast.Tuple, ast.Dict, ast.Set, - # Names and attributes ast.Name, ast.Load, ast.Attribute, ast.Subscript, ast.Slice, - ast.Index, # py<3.9 compatibility, harmless on newer - # Calls ast.Call, ast.keyword, - # Operators ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.Compare, ast.IfExp, - # Arithmetic / boolean operators ast.Add, ast.Sub, ast.Mult, @@ -69,7 +42,6 @@ ast.FloorDiv, ast.Mod, ast.Pow, - ast.MatMult, ast.UAdd, ast.USub, ast.Not, @@ -88,45 +60,106 @@ ast.IsNot, } -_ALLOWED_NAMES: set[str] = {"df", "np", "pd", "len", "abs", "min", "max", "round", "sum", "sorted"} +_ALLOWED_NAMES: set[str] = { + "df", + "np", + "pd", + "len", + "abs", + "min", + "max", + "round", + "sum", + "sorted", +} _ALLOWED_ATTRS: set[str] = { - # DataFrame / Series essentials - "loc", "iloc", "at", "iat", - "head", "tail", "shape", "size", "columns", "index", "dtypes", "values", - # Boolean / null - "isna", "notna", "isnull", "notnull", - # Aggregation - "sum", "count", "mean", "median", "std", "var", "min", "max", - "quantile", "describe", "agg", "aggregate", - # Groupby + sort - "groupby", "sort_values", "sort_index", "value_counts", - # Selection / shape - "select_dtypes", "drop", "drop_duplicates", "rename", "reset_index", - "set_index", "unique", "nunique", - # Combination - "merge", "join", "concat", - # Stats / transform (read-only) - "corr", "cov", "abs", "round", "rank", "diff", "pct_change", - "cumsum", "cummax", "cummin", "rolling", - # String accessor - "str", "dt", "cat", - # Common str / dt methods - "lower", "upper", "contains", "startswith", "endswith", "len", "strip", - "year", "month", "day", "dayofweek", "weekday", - # numpy aliases - "log", "log1p", "exp", "sqrt", "where", - # Conversion / formatting - "astype", "to_dict", "to_list", "tolist", - # Boolean-array logic - "any", "all", - # Filter helper - "query", "between", "isin", - # Apply (whitelisted callables only, see _ALLOWED_CALLABLES below) - "apply", "applymap", "map", + "loc", + "iloc", + "at", + "iat", + "head", + "tail", + "shape", + "size", + "columns", + "index", + "dtypes", + "values", + "isna", + "notna", + "isnull", + "notnull", + "sum", + "count", + "mean", + "median", + "std", + "var", + "min", + "max", + "quantile", + "describe", + "agg", + "aggregate", + "groupby", + "sort_values", + "sort_index", + "value_counts", + "select_dtypes", + "drop", + "drop_duplicates", + "rename", + "reset_index", + "set_index", + "unique", + "nunique", + "merge", + "join", + "concat", + "corr", + "cov", + "abs", + "round", + "rank", + "diff", + "pct_change", + "cumsum", + "cummax", + "cummin", + "rolling", + "str", + "dt", + "cat", + "lower", + "upper", + "contains", + "startswith", + "endswith", + "len", + "strip", + "year", + "month", + "day", + "dayofweek", + "weekday", + "log", + "log1p", + "exp", + "sqrt", + "where", + "astype", + "to_dict", + "to_list", + "tolist", + "any", + "all", + "between", + "isin", + "apply", + "map", } -# Hard limits _MAX_LEN = 600 _MAX_DEPTH = 30 _MAX_PREVIEW_ROWS = 50 @@ -136,10 +169,6 @@ class UnsafeExpression(ValueError): """Raised when an expression contains a disallowed construct.""" -# --------------------------------------------------------------------------- -# Validation -# --------------------------------------------------------------------------- - def _walk_with_depth(node, depth=0): yield node, depth for child in ast.iter_child_nodes(node): @@ -150,131 +179,165 @@ def _validate(tree: ast.AST) -> None: for node, depth in _walk_with_depth(tree): if depth > _MAX_DEPTH: raise UnsafeExpression(f"Expression nesting too deep (>{_MAX_DEPTH})") - if type(node) not in _ALLOWED_NODES: raise UnsafeExpression(f"Disallowed AST node: {type(node).__name__}") if isinstance(node, ast.Name): if node.id not in _ALLOWED_NAMES and not node.id.startswith("__col__"): - # Names other than allowed roots can only appear as keyword args - # (e.g., axis=0). Keyword arg names are stored in ast.keyword.arg - # which is a string, not a Name node, so we don't reach here for them. raise UnsafeExpression(f"Disallowed name: {node.id}") if isinstance(node, ast.Attribute): - attr = node.attr - if attr.startswith("_"): - raise UnsafeExpression(f"Dunder/private access disallowed: {attr}") - if attr not in _ALLOWED_ATTRS: - raise UnsafeExpression(f"Disallowed attribute: {attr}") + attribute = node.attr + if attribute.startswith("_"): + raise UnsafeExpression( + f"Dunder/private access disallowed: {attribute}" + ) + if attribute not in _ALLOWED_ATTRS: + raise UnsafeExpression(f"Disallowed attribute: {attribute}") if isinstance(node, ast.Call): - # Reject calls like __import__, type, getattr, etc. if isinstance(node.func, ast.Name) and node.func.id not in _ALLOWED_NAMES: raise UnsafeExpression(f"Disallowed function call: {node.func.id}") -# --------------------------------------------------------------------------- -# JSON-safe coercion -# --------------------------------------------------------------------------- - def _to_jsonable(value: Any) -> Any: - """Convert pandas / numpy / scalar to a JSON-serializable structure.""" + """Convert pandas/numpy values into a bounded JSON-serializable structure.""" if value is None: return None - - if isinstance(value, (np.integer,)): + if isinstance(value, np.integer): return int(value) - if isinstance(value, (np.floating, float)): - v = float(value) - if math.isnan(v) or math.isinf(v): + normalized = float(value) + if math.isnan(normalized) or math.isinf(normalized): return None - return round(v, 6) - + return round(normalized, 6) if isinstance(value, (str, int, bool)): return value - if isinstance(value, np.ndarray): - return [_to_jsonable(v) for v in value.tolist()] + return [_to_jsonable(item) for item in value.tolist()] if isinstance(value, pd.Series): - out = [] - for idx, val in value.head(_MAX_PREVIEW_ROWS).items(): - out.append({"index": _to_jsonable(idx), "value": _to_jsonable(val)}) + rows = [ + {"index": _to_jsonable(index), "value": _to_jsonable(item)} + for index, item in value.head(_MAX_PREVIEW_ROWS).items() + ] return { "type": "series", "name": str(value.name) if value.name is not None else None, "length": int(len(value)), - "rows": out, + "rows": rows, "truncated": int(len(value)) > _MAX_PREVIEW_ROWS, } if isinstance(value, pd.DataFrame): - rows = value.head(_MAX_PREVIEW_ROWS).where(value.notna(), None).to_dict(orient="records") - rows = [{k: _to_jsonable(v) for k, v in r.items()} for r in rows] + records = ( + value.head(_MAX_PREVIEW_ROWS) + .where(value.head(_MAX_PREVIEW_ROWS).notna(), None) + .to_dict(orient="records") + ) + rows = [ + {key: _to_jsonable(item) for key, item in record.items()} + for record in records + ] return { "type": "dataframe", "shape": [int(value.shape[0]), int(value.shape[1])], - "columns": [str(c) for c in value.columns], + "columns": [str(column) for column in value.columns], "rows": rows, "truncated": int(value.shape[0]) > _MAX_PREVIEW_ROWS, } if isinstance(value, dict): - return {str(k): _to_jsonable(v) for k, v in value.items()} - + return {str(key): _to_jsonable(item) for key, item in value.items()} if isinstance(value, (list, tuple, set)): - return [_to_jsonable(v) for v in value] - + return [_to_jsonable(item) for item in value] if isinstance(value, pd.Index): - return [_to_jsonable(v) for v in value.tolist()] - + return [_to_jsonable(item) for item in value.tolist()] return str(value) -# --------------------------------------------------------------------------- -# Public entry point -# --------------------------------------------------------------------------- - def safe_eval(expression: str, df: pd.DataFrame) -> dict: - """ - Validate and evaluate a pandas expression against `df`. - - Returns a dict with: - {"ok": bool, "result": ..., "error": str | None, "expression": str} - """ + """Validate and evaluate a read-only pandas expression against ``df``.""" if not isinstance(expression, str): - return {"ok": False, "result": None, "error": "Expression must be a string.", "expression": str(expression)} + return { + "ok": False, + "result": None, + "error": "Expression must be a string.", + "expression": str(expression), + } + if not isinstance(df, pd.DataFrame): + return { + "ok": False, + "result": None, + "error": "df must be a pandas DataFrame.", + "expression": expression, + } expression = expression.strip() if not expression: - return {"ok": False, "result": None, "error": "Empty expression.", "expression": expression} - + return { + "ok": False, + "result": None, + "error": "Empty expression.", + "expression": expression, + } if len(expression) > _MAX_LEN: - return {"ok": False, "result": None, "error": f"Expression too long (>{_MAX_LEN} chars).", "expression": expression} + return { + "ok": False, + "result": None, + "error": f"Expression too long (>{_MAX_LEN} chars).", + "expression": expression, + } - # Reject obvious red flags pre-parse - forbidden = ("__", "import ", "from ", "lambda", "exec(", "eval(", "open(", "globals", "locals", "compile(") + forbidden = ( + "__", + "import ", + "from ", + "lambda", + "exec(", + "eval(", + "open(", + "globals", + "locals", + "compile(", + ".query(", + ".applymap(", + ) lower = expression.lower() for token in forbidden: if token in lower: - return {"ok": False, "result": None, "error": f"Disallowed token: {token!r}", "expression": expression} + return { + "ok": False, + "result": None, + "error": f"Disallowed token: {token!r}", + "expression": expression, + } try: tree = ast.parse(expression, mode="eval") except SyntaxError as exc: - return {"ok": False, "result": None, "error": f"Syntax error: {exc.msg}", "expression": expression} + return { + "ok": False, + "result": None, + "error": f"Syntax error: {exc.msg}", + "expression": expression, + } try: _validate(tree) except UnsafeExpression as exc: - return {"ok": False, "result": None, "error": str(exc), "expression": expression} + return { + "ok": False, + "result": None, + "error": str(exc), + "expression": expression, + } - # Restricted execution environment (NO __builtins__, NO globals) safe_globals = {"__builtins__": {}} safe_locals = { - "df": df, + # Evaluate against an isolated copy so otherwise-useful pandas methods + # cannot mutate the caller's data through ``inplace=True``. + "df": df.copy(deep=True), "np": np, "pd": pd, "len": len, @@ -288,8 +351,18 @@ def safe_eval(expression: str, df: pd.DataFrame) -> dict: try: compiled = compile(tree, "", "eval") - value = eval(compiled, safe_globals, safe_locals) # noqa: S307 — sandboxed + value = eval(compiled, safe_globals, safe_locals) # noqa: S307 - AST sandboxed except Exception as exc: - return {"ok": False, "result": None, "error": f"{type(exc).__name__}: {exc}", "expression": expression} + return { + "ok": False, + "result": None, + "error": f"{type(exc).__name__}: {exc}", + "expression": expression, + } - return {"ok": True, "result": _to_jsonable(value), "error": None, "expression": expression} + return { + "ok": True, + "result": _to_jsonable(value), + "error": None, + "expression": expression, + } diff --git a/src/framevitals/security.py b/src/framevitals/security.py index 8da94c9..fc06352 100644 --- a/src/framevitals/security.py +++ b/src/framevitals/security.py @@ -21,12 +21,15 @@ *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10)), } +_FORMULA_PREFIXES = ("=", "+", "-", "@") def validate_file(filename: str) -> None: - """Validate that a dataset uses a supported file extension.""" - suffix = Path(filename).suffix.lower() + """Validate that a dataset filename has a supported extension.""" + if not isinstance(filename, str) or not filename.strip(): + raise ValueError("Dataset filename must be a non-empty string.") + suffix = Path(filename).suffix.lower() if suffix not in ALLOWED_EXTENSIONS: raise ValueError( f"Unsupported file type '{suffix}'. " @@ -34,26 +37,39 @@ def validate_file(filename: str) -> None: ) -def make_safe_filename(filename: str) -> str: - """Return a conservative filesystem-safe filename using only stdlib code.""" - basename = Path(str(filename)).name - normalized = unicodedata.normalize("NFKD", basename) +def _safe_ascii_stem(stem: str) -> str: + normalized = unicodedata.normalize("NFKD", stem) ascii_name = normalized.encode("ascii", "ignore").decode("ascii") - safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", ascii_name).strip("._") + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", ascii_name).strip(".") + return safe or "dataset" - if not safe: + +def make_safe_filename(filename: str) -> str: + """Return a conservative filesystem-safe filename. + + The extension is preserved separately from the sanitized stem so valid + non-ASCII names such as ``数据.csv`` cannot accidentally become extensionless. + """ + if not isinstance(filename, str) or not filename.strip(): return "dataset" - stem = Path(safe).stem.upper() - if stem in _WINDOWS_DEVICE_NAMES: - safe = f"_{safe}" + basename = Path(filename).name + suffix = Path(basename).suffix.lower() + stem = Path(basename).stem + safe_stem = _safe_ascii_stem(stem) + + if safe_stem.upper() in _WINDOWS_DEVICE_NAMES: + safe_stem = f"_{safe_stem}" - return safe + return f"{safe_stem}{suffix}" def sanitize_csv_value(value): - """Prevent spreadsheet formula injection in exported CSV files.""" - if isinstance(value, str) and value.startswith(("=", "+", "-", "@")): - return "'" + value + """Prevent spreadsheet formula injection in exported CSV string cells.""" + if not isinstance(value, str): + return value + stripped = value.lstrip() + if stripped.startswith(_FORMULA_PREFIXES) or value.startswith(("\t", "\r")): + return "'" + value return value diff --git a/src/framevitals/snapshots.py b/src/framevitals/snapshots.py index d22ee67..55f5f06 100644 --- a/src/framevitals/snapshots.py +++ b/src/framevitals/snapshots.py @@ -4,6 +4,7 @@ import hashlib import json +import math import re from datetime import datetime, timezone from pathlib import Path @@ -19,11 +20,12 @@ def _as_mapping(value: Any) -> Mapping[str, Any]: def _number(value: Any) -> float | None: try: - if value is None: + if value is None or isinstance(value, bool): return None - return float(value) + converted = float(value) except (TypeError, ValueError): return None + return converted if math.isfinite(converted) else None def _state_payload(result: Mapping[str, Any]) -> dict[str, Any]: @@ -86,6 +88,28 @@ def _created_at(snapshot: Mapping[str, Any]) -> datetime: return parsed.astimezone(timezone.utc) +def _validate_snapshot_payload(snapshot: Mapping[str, Any]) -> None: + if snapshot.get("snapshot_schema_version") != SNAPSHOT_SCHEMA_VERSION: + raise ValueError( + "Unsupported FrameVitals snapshot schema version: " + f"{snapshot.get('snapshot_schema_version')!r}" + ) + state = snapshot.get("state") + if not isinstance(state, Mapping): + raise ValueError("Snapshot is missing a valid state object.") + _created_at(snapshot) + + fingerprint = snapshot.get("fingerprint") + if not isinstance(fingerprint, str) or not fingerprint: + raise ValueError("Snapshot is missing a valid fingerprint.") + expected = _fingerprint(state) + if fingerprint != expected: + raise ValueError( + "Snapshot fingerprint does not match its stored state; " + "the snapshot may be corrupted or modified." + ) + + def _safe_label(value: str | None) -> str | None: if value is None: return None @@ -122,6 +146,8 @@ def diff(self, other: Mapping[str, Any]) -> dict[str, Any]: def create_snapshot(result: Mapping[str, Any]) -> AnalysisSnapshot: """Create a deterministic compact state snapshot from an analysis result.""" + if not isinstance(result, Mapping): + raise TypeError("result must be a mapping.") state = _state_payload(result) return AnalysisSnapshot({ "snapshot_schema_version": SNAPSHOT_SCHEMA_VERSION, @@ -140,6 +166,8 @@ def load_snapshot(path: str | Path) -> AnalysisSnapshot: source = Path(path) if not source.exists(): raise FileNotFoundError(f"Snapshot not found: {source}") + if not source.is_file(): + raise ValueError(f"Expected a snapshot file, got: {source}") try: payload = json.loads(source.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: @@ -147,14 +175,7 @@ def load_snapshot(path: str | Path) -> AnalysisSnapshot: if not isinstance(payload, dict): raise ValueError("Snapshot JSON must contain an object.") - if payload.get("snapshot_schema_version") != SNAPSHOT_SCHEMA_VERSION: - raise ValueError( - "Unsupported FrameVitals snapshot schema version: " - f"{payload.get('snapshot_schema_version')!r}" - ) - if not isinstance(payload.get("state"), dict): - raise ValueError("Snapshot is missing a valid state object.") - _created_at(payload) + _validate_snapshot_payload(payload) return AnalysisSnapshot(payload) @@ -230,12 +251,7 @@ def compare_snapshots( class SnapshotHistory: - """Filesystem-backed history of compact FrameVitals snapshots. - - The history store never writes raw datasets. It persists only the compact - snapshot representation and provides lightweight timeline/latest/diff - helpers for local monitoring and CI workflows. - """ + """Filesystem-backed history of compact FrameVitals snapshots.""" def __init__(self, directory: str | Path = ".framevitals/history") -> None: self.directory = Path(directory) @@ -262,14 +278,11 @@ def add( label: str | None = None, ) -> Path: """Persist an analysis result or an existing snapshot and return its path.""" + if not isinstance(result_or_snapshot, Mapping): + raise TypeError("result_or_snapshot must be a mapping.") + if result_or_snapshot.get("snapshot_schema_version") is not None: - if result_or_snapshot.get("snapshot_schema_version") != SNAPSHOT_SCHEMA_VERSION: - raise ValueError( - "Unsupported FrameVitals snapshot schema version: " - f"{result_or_snapshot.get('snapshot_schema_version')!r}" - ) - if not isinstance(result_or_snapshot.get("state"), Mapping): - raise ValueError("Snapshot is missing a valid state object.") + _validate_snapshot_payload(result_or_snapshot) snapshot = AnalysisSnapshot(dict(result_or_snapshot)) created_at = _created_at(snapshot) else: diff --git a/src/framevitals/streaming_quality.py b/src/framevitals/streaming_quality.py index a1d641e..0dd98e7 100644 --- a/src/framevitals/streaming_quality.py +++ b/src/framevitals/streaming_quality.py @@ -14,6 +14,7 @@ import pandas as pd from framevitals.column_roles import infer_column_roles +from framevitals.execution import current_execution_policy from framevitals.provenance import normalize_execution from framevitals.quality_diagnostics import run_quality_diagnostics @@ -103,6 +104,13 @@ def run_streaming_quality_diagnostics( if source_columns < 1: raise ValueError("source_columns must be at least 1.") + # Enforce the per-run policy again at the adapter boundary. The outer + # streaming orchestrator may request a larger diagnostic floor, but a hard + # user cap must never be widened by an internal convenience default. + policy = current_execution_policy() + if policy.max_sample_rows is not None: + max_sample_rows = min(int(max_sample_rows), int(policy.max_sample_rows)) + roles = infer_column_roles(sample) payload = run_quality_diagnostics( sample, diff --git a/tests/test_ai_insights_hardening.py b/tests/test_ai_insights_hardening.py new file mode 100644 index 0000000..73af79a --- /dev/null +++ b/tests/test_ai_insights_hardening.py @@ -0,0 +1,49 @@ +from framevitals import ai_insights + + +def _context(): + profile = { + "shape": {"rows": 10, "columns": 1}, + "columns": ["x"], + "dtypes": {"x": "int64"}, + "missing_counts": {"x": 0}, + "duplicate_rows": 0, + "numeric_columns": ["x"], + "categorical_columns": [], + "date_columns": [], + } + health = {"overall_score": 90, "label": "Good", "details": {}} + readiness = {"score": 80, "label": "Ready", "recommendations": []} + return profile, health, readiness + + +def test_ai_report_fallback_preserves_endpoint_errors(monkeypatch): + profile, health, readiness = _context() + + def fail_openrouter(*args, **kwargs): + raise RuntimeError("openrouter down") + + def fail_ollama(*args, **kwargs): + raise RuntimeError("ollama down") + + monkeypatch.setattr(ai_insights, "_call_openrouter", fail_openrouter) + monkeypatch.setattr(ai_insights, "_call_ollama", fail_ollama) + + result = ai_insights.generate_ai_report( + profile, + health, + [], + readiness, + advanced={}, + ) + + assert result["source"].startswith("fallback:") + assert "openrouter down" in result["source"] + assert "ollama down" in result["source"] + + +def test_ai_settings_are_read_at_call_time(monkeypatch): + monkeypatch.setenv("OPENROUTER_MODEL", "first-model") + assert ai_insights._openrouter_model() == "first-model" + monkeypatch.setenv("OPENROUTER_MODEL", "second-model") + assert ai_insights._openrouter_model() == "second-model" diff --git a/tests/test_cli_monitoring.py b/tests/test_cli_monitoring.py index 6f04a65..ed2f6bf 100644 --- a/tests/test_cli_monitoring.py +++ b/tests/test_cli_monitoring.py @@ -159,10 +159,32 @@ def test_compare_snapshots_cli_can_fail_ci_on_change(tmp_path, monkeypatch, caps assert main() == 0 capsys.readouterr() - payload = json.loads(baseline.read_text(encoding="utf-8")) - payload["fingerprint"] = "0" * 64 - payload["state"]["dataset"]["dtypes"]["new_column"] = "int64" - current.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + dataset.write_text( + "value,other,group,new_column\n" + "1,2,a,10\n" + "2,4,b,20\n" + "3,6,a,30\n" + "4,8,b,40\n" + "5,10,a,50\n" + "6,12,b,60\n", + encoding="utf-8", + ) + monkeypatch.setattr( + "sys.argv", + [ + "framevitals", + "snapshot", + str(dataset), + "--mode", + "quick", + "--workers", + "1", + "--output", + str(current), + ], + ) + assert main() == 0 + capsys.readouterr() monkeypatch.setattr( "sys.argv", diff --git a/tests/test_config_object_precedence.py b/tests/test_config_object_precedence.py new file mode 100644 index 0000000..6e76d1a --- /dev/null +++ b/tests/test_config_object_precedence.py @@ -0,0 +1,13 @@ +from framevitals.config import AnalysisConfig, resolve_config + + +def test_analysis_config_object_can_clear_environment_resource_caps(monkeypatch): + monkeypatch.setenv("FRAMEVITALS_MAX_SAMPLE_ROWS", "250") + monkeypatch.setenv("FRAMEVITALS_MAX_RELATIONSHIP_PAIRS", "7") + + resolved = resolve_config(AnalysisConfig(mode="deep", workers=3)) + + assert resolved.mode == "deep" + assert resolved.workers == 3 + assert resolved.max_sample_rows is None + assert resolved.max_relationship_pairs is None diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 0000000..2b84767 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,29 @@ +import pytest + +from framevitals.config import AnalysisConfig, resolve_config + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"workers": True}, "workers"), + ({"workers": 2.5}, "workers"), + ({"artifacts": 1}, "artifacts"), + ({"target": 42}, "target"), + ({"max_sample_rows": True}, "max_sample_rows"), + ({"max_relationship_pairs": 2.5}, "max_relationship_pairs"), + ({"disabled_modules": "ai"}, "disabled_modules"), + ({"disabled_modules": ("ai", 3)}, "disabled_modules"), + ], +) +def test_analysis_config_rejects_runtime_type_coercion(kwargs, message): + with pytest.raises(ValueError, match=message): + AnalysisConfig(**kwargs) + + +def test_mapping_integer_controls_do_not_silently_truncate_floats(): + with pytest.raises(ValueError, match="workers"): + resolve_config({"resources": {"workers": 2.5}}) + + with pytest.raises(ValueError, match="max_sample_rows"): + resolve_config({"resources": {"max_sample_rows": 2.5}}) diff --git a/tests/test_execution_context.py b/tests/test_execution_context.py new file mode 100644 index 0000000..8b12ce1 --- /dev/null +++ b/tests/test_execution_context.py @@ -0,0 +1,126 @@ +from concurrent.futures import ThreadPoolExecutor +from time import sleep + +import pandas as pd +import pytest + +import framevitals +from framevitals.config import AnalysisConfig +from framevitals.execution import ExecutionPolicy +from framevitals.execution_context import AnalysisContext, CONTEXT_SCHEMA_VERSION + + +def _context() -> AnalysisContext: + return AnalysisContext( + dataset_name="example", + source={"kind": "dataframe", "rows": 3, "columns": 1}, + config=AnalysisConfig(mode="quick"), + execution_policy=ExecutionPolicy(max_sample_rows=2), + rows=3, + columns=1, + ) + + +def test_context_cache_computes_once_and_tracks_hits(): + context = _context() + calls = 0 + + def factory(): + nonlocal calls + calls += 1 + return {"value": 42} + + first = context.get_or_compute("profile", factory) + second = context.get_or_compute("profile", factory) + metadata = context.metadata() + + assert first is second + assert calls == 1 + assert metadata["cache"]["entries"] == ["profile"] + assert metadata["cache"]["misses"] == 1 + assert metadata["cache"]["hits"] == 1 + + +def test_context_cache_prevents_duplicate_concurrent_work(): + context = _context() + calls = 0 + + def factory(): + nonlocal calls + calls += 1 + sleep(0.01) + return object() + + with ThreadPoolExecutor(max_workers=4) as executor: + values = list(executor.map(lambda _: context.get_or_compute("shared", factory), range(8))) + + assert calls == 1 + assert all(value is values[0] for value in values) + assert context.metadata()["cache"]["hits"] == 7 + + +def test_context_facts_and_samples_are_isolated_per_run(): + left = _context() + right = _context() + sample = pd.DataFrame({"x": [1, 2, 3]}) + + left.set_fact("profile", {"shape": {"rows": 3, "columns": 1}}) + left.store_sample("working", sample, metadata={"purpose": "test"}) + + assert left.require_fact("profile")["shape"]["rows"] == 3 + assert right.fact("profile") is None + assert left.sample("working") is sample + assert right.sample("working") is None + + metadata = left.metadata() + assert metadata["facts"] == ["profile"] + assert metadata["samples"]["working"] == { + "purpose": "test", + "rows": 3, + "columns": 1, + } + assert "x" not in metadata["samples"]["working"] + + +def test_context_rejects_accidental_fact_or_sample_replacement(): + context = _context() + context.set_fact("profile", 1) + context.store_sample("working", [1, 2]) + + with pytest.raises(KeyError, match="fact already exists"): + context.set_fact("profile", 2) + with pytest.raises(KeyError, match="sample already exists"): + context.store_sample("working", [3]) + + assert context.set_fact("profile", 2, overwrite=True) == 2 + assert context.store_sample("working", [3], overwrite=True) == [3] + + +def test_plan_surfaces_context_metadata_without_raw_sample_values(): + frame = pd.DataFrame({ + "x": list(range(20)), + "y": [index * 2 for index in range(20)], + "group": ["a", "b"] * 10, + }) + + plan = framevitals.plan(frame, mode="standard") + metadata = plan["execution_context"] + + assert metadata["context_schema_version"] == CONTEXT_SCHEMA_VERSION + assert metadata["shape"] == {"rows": 20, "columns": 3} + assert metadata["samples"]["planning"]["rows"] == 20 + assert metadata["samples"]["planning"]["columns"] == 3 + assert set(metadata["facts"]) == { + "column_roles", + "execution_budget", + "profile", + "selection", + "signals", + } + assert set(metadata["cache"]["entries"]) == { + "column_roles", + "dataset_signals", + "execution_budget", + "execution_plan", + } + assert metadata["cache"]["misses"] == 4 diff --git a/tests/test_execution_policy.py b/tests/test_execution_policy.py new file mode 100644 index 0000000..d02c90b --- /dev/null +++ b/tests/test_execution_policy.py @@ -0,0 +1,187 @@ +import pandas as pd +import pytest + +import framevitals +from framevitals.config import available_presets, resolve_config +from framevitals.execution import ( + ExecutionPolicy, + derive_execution_budget, + derive_streaming_profile_column_limit, + use_execution_policy, +) + + +def _small_dataset() -> pd.DataFrame: + return pd.DataFrame({ + "x": list(range(20)), + "y": [value * 2 for value in range(20)], + "group": ["a", "b"] * 10, + }) + + +def test_resource_caps_resolve_from_config_and_exhaustive_alias(): + resolved = resolve_config( + preset="exhaustive", + config={ + "resources": { + "max_sample_rows": 1200, + "max_relationship_pairs": 7, + "max_memory_heavy_parallelism": 1, + "max_streaming_profile_columns": 12, + } + }, + ) + + assert "exhaustive" in available_presets() + assert resolved.mode == "research" + assert resolved.max_sample_rows == 1200 + assert resolved.max_relationship_pairs == 7 + assert resolved.max_memory_heavy_parallelism == 1 + assert resolved.max_streaming_profile_columns == 12 + assert resolved.to_dict()["max_sample_rows"] == 1200 + + with pytest.raises(ValueError, match="max_sample_rows"): + resolve_config({"resources": {"max_sample_rows": 0}}) + + +def test_execution_policy_caps_budgets_and_restores_context(): + baseline = derive_execution_budget(100_000, 200, mode="research") + policy = ExecutionPolicy( + max_sample_rows=1200, + max_relationship_pairs=7, + max_memory_heavy_parallelism=1, + max_streaming_profile_columns=12, + ) + + with use_execution_policy(policy): + capped = derive_execution_budget(100_000, 200, mode="research") + profile_columns = derive_streaming_profile_column_limit( + 10_000_000, + 10_000, + mode="research", + ) + + assert capped.quality_sample_rows <= 1200 + assert capped.deep_statistics_sample_rows <= 1200 + assert capped.bootstrap_sample_rows <= 1200 + assert capped.distribution_sample_rows <= 1200 + assert capped.pair_sample_rows <= 1200 + assert capped.anomaly_sample_rows <= 1200 + assert capped.time_series_sample_rows <= 1200 + assert capped.relationship_pair_budget == 7 + assert capped.max_memory_heavy_parallelism == 1 + assert profile_columns <= 12 + assert derive_execution_budget(100_000, 200, mode="research") == baseline + + +def test_policy_never_expands_mode_defaults(): + baseline = derive_execution_budget(100_000, 20, mode="quick") + policy = ExecutionPolicy( + max_sample_rows=999_999, + max_relationship_pairs=999_999, + max_memory_heavy_parallelism=999, + ) + + with use_execution_policy(policy): + capped = derive_execution_budget(100_000, 20, mode="quick") + + assert capped.quality_sample_rows == baseline.quality_sample_rows + assert capped.anomaly_sample_rows == baseline.anomaly_sample_rows + assert capped.relationship_pair_budget == baseline.relationship_pair_budget + assert ( + capped.max_memory_heavy_parallelism + == baseline.max_memory_heavy_parallelism + ) + + +def test_plan_reports_effective_resource_budget(): + result = framevitals.plan( + _small_dataset(), + mode="research", + config={ + "resources": { + "max_sample_rows": 8, + "max_relationship_pairs": 3, + "max_memory_heavy_parallelism": 1, + } + }, + ) + + assert result.resource_policy["max_sample_rows"] == 8 + assert result.execution_budget["quality_sample_rows"] == 8 + assert result.execution_budget["deep_statistics_sample_rows"] == 8 + assert result.execution_budget["relationship_pair_budget"] == 3 + assert result.execution_budget["max_memory_heavy_parallelism"] == 1 + assert "Resource caps" in result.explain_text() + + +def test_analyze_applies_policy_to_pipeline_budget(monkeypatch): + captured = {} + + def fake_run_full_analysis(**kwargs): + budget = derive_execution_budget(100_000, 64, mode=kwargs["analysis_mode"]) + captured["budget"] = budget + return { + "profile": {"shape": {"rows": 1, "columns": 1}}, + "execution": {}, + "signals": [], + } + + monkeypatch.setattr( + "framevitals.analysis_api.run_full_analysis", + fake_run_full_analysis, + ) + + result = framevitals.analyze( + pd.DataFrame({"x": [1]}), + mode="research", + config={ + "resources": { + "max_sample_rows": 17, + "max_relationship_pairs": 2, + "max_memory_heavy_parallelism": 1, + } + }, + ) + + budget = captured["budget"] + assert budget.quality_sample_rows == 17 + assert budget.anomaly_sample_rows == 17 + assert budget.relationship_pair_budget == 2 + assert budget.max_memory_heavy_parallelism == 1 + assert result["execution"]["resource_policy"]["max_sample_rows"] == 17 + + +def test_environment_precedence_is_deterministic(monkeypatch): + monkeypatch.setenv("FRAMEVITALS_PRESET", "deep") + monkeypatch.setenv("FRAMEVITALS_MODE", "research") + monkeypatch.setenv("FRAMEVITALS_WORKERS", "2") + monkeypatch.setenv("FRAMEVITALS_ARTIFACTS", "true") + monkeypatch.setenv("FRAMEVITALS_MAX_SAMPLE_ROWS", "900") + monkeypatch.setenv( + "FRAMEVITALS_DISABLED_MODULES", + "modeling, explainability", + ) + + from_environment = resolve_config(preset="quick") + assert from_environment.mode == "research" + assert from_environment.workers == 2 + assert from_environment.artifacts is True + assert from_environment.max_sample_rows == 900 + assert from_environment.disabled_modules == ("modeling", "explainability") + + configured = resolve_config( + config={ + "analysis": {"mode": "standard", "artifacts": False}, + "resources": {"workers": 3, "max_sample_rows": 400}, + }, + mode="deep", + ) + assert configured.mode == "deep" + assert configured.workers == 3 + assert configured.artifacts is False + assert configured.max_sample_rows == 400 + + monkeypatch.setenv("FRAMEVITALS_ARTIFACTS", "sometimes") + with pytest.raises(ValueError, match="FRAMEVITALS_ARTIFACTS"): + resolve_config() diff --git a/tests/test_execution_validation.py b/tests/test_execution_validation.py new file mode 100644 index 0000000..3ad041c --- /dev/null +++ b/tests/test_execution_validation.py @@ -0,0 +1,33 @@ +import pandas as pd +import pytest + +from framevitals.execution import ( + ExecutionPolicy, + derive_execution_budget, + derive_streaming_profile_column_limit, + deterministic_sample_frame, +) + + +@pytest.mark.parametrize( + "value", + [True, 2.5, "10"], +) +def test_execution_policy_rejects_non_integer_caps(value): + with pytest.raises(ValueError, match="max_sample_rows"): + ExecutionPolicy(max_sample_rows=value) + + +def test_execution_budget_rejects_boolean_and_fractional_shapes(): + with pytest.raises(ValueError, match="rows"): + derive_execution_budget(True, 10) + with pytest.raises(ValueError, match="columns"): + derive_streaming_profile_column_limit(10, 2.5) + + +def test_deterministic_sample_rejects_invalid_control_types(): + frame = pd.DataFrame({"x": range(5)}) + with pytest.raises(ValueError, match="max_rows"): + deterministic_sample_frame(frame, True) + with pytest.raises(TypeError, match="DataFrame"): + deterministic_sample_frame([1, 2, 3], 2) diff --git a/tests/test_planner.py b/tests/test_planner.py new file mode 100644 index 0000000..f4c0b3d --- /dev/null +++ b/tests/test_planner.py @@ -0,0 +1,88 @@ +import pandas as pd + +import framevitals +from framevitals.planner import ( + PLANNER_SCHEMA_VERSION, + effective_disabled_modules, + plan_execution_modules, +) + + +def _dataset() -> pd.DataFrame: + return pd.DataFrame({ + "age": list(range(20, 40)), + "income": [30_000 + index * 1_000 for index in range(20)], + "city": ["Pune", "Mumbai"] * 10, + "churn": [0, 1] * 10, + }) + + +def test_module_planner_reports_versioned_explainable_decisions(): + result = framevitals.plan(_dataset(), mode="standard") + + modules = result.selection["execution_modules"] + decisions = modules["decisions"] + + assert result.planner_schema_version == PLANNER_SCHEMA_VERSION + assert result.summary()["planner_schema_version"] == PLANNER_SCHEMA_VERSION + assert decisions["quality_diagnostics"]["status"] == "run" + assert decisions["anomaly_detection"]["status"] == "run" + assert decisions["deep_statistics"]["status"] == "disabled_by_mode" + assert decisions["text_profile"]["status"] == "disabled_by_mode" + assert decisions["target_intelligence"]["status"] == "not_applicable" + assert decisions["charts"]["status"] == "not_applicable" + assert decisions["ai"]["status"] == "conditional" + assert "Execution modules" in result.explain_text() + + +def test_explicit_disable_is_distinct_from_mode_policy(): + result = framevitals.plan( + _dataset(), + mode="standard", + disabled_modules=["anomaly_detection"], + ) + modules = result.execution_modules + + assert modules["decisions"]["anomaly_detection"]["status"] == "disabled_by_config" + assert "anomaly_detection" in modules["explicit_disabled"] + assert "anomaly_detection" in modules["effective_disabled"] + assert modules["decisions"]["deep_statistics"]["status"] == "disabled_by_mode" + assert "deep_statistics" not in modules["explicit_disabled"] + assert "deep_statistics" in modules["effective_disabled"] + + +def test_target_and_research_mode_unlock_dependent_modules(): + result = framevitals.plan(_dataset(), mode="research", target="churn") + decisions = result.module_decisions + + assert decisions["target_intelligence"]["status"] == "run" + assert decisions["modeling"]["status"] == "run" + assert decisions["modeling"]["depends_on"] == ["target_intelligence"] + assert decisions["explainability"]["status"] == "conditional" + assert decisions["explainability"]["depends_on"] == ["modeling"] + + +def test_mode_policy_helper_is_the_runtime_source_of_truth(): + disabled = effective_disabled_modules("quick", ("charts",)) + + assert "charts" in disabled + assert "deep_statistics" in disabled + assert "anomaly_detection" in disabled + assert "target_intelligence" not in disabled + + +def test_signal_applicability_reasons_are_structured(): + modules = plan_execution_modules( + signals={ + "has_numeric_columns": False, + "has_datetime_columns": False, + "has_time_series_structure": False, + "has_long_text_columns": False, + }, + analysis_mode="deep", + target_column=None, + ) + + assert modules["decisions"]["anomaly_detection"]["status"] == "not_applicable" + assert "has_numeric_columns" in modules["decisions"]["anomaly_detection"]["reason"] + assert modules["decisions"]["time_series"]["status"] == "not_applicable" diff --git a/tests/test_planner_dependencies.py b/tests/test_planner_dependencies.py new file mode 100644 index 0000000..c2aa469 --- /dev/null +++ b/tests/test_planner_dependencies.py @@ -0,0 +1,70 @@ +import pandas as pd + +import framevitals +from framevitals.planner import plan_execution_modules + + +def _signals() -> dict[str, bool]: + return { + "has_numeric_columns": True, + "has_datetime_columns": False, + "has_time_series_structure": False, + "has_long_text_columns": False, + } + + +def test_disabled_dependency_blocks_downstream_modules(): + modules = plan_execution_modules( + signals=_signals(), + analysis_mode="research", + target_column="target", + disabled_modules=["target_intelligence"], + ) + decisions = modules["decisions"] + + assert decisions["target_intelligence"]["status"] == "disabled_by_config" + assert decisions["modeling"]["status"] == "not_applicable" + assert decisions["modeling"]["blocked_by"] == ["target_intelligence"] + assert "target_intelligence (disabled_by_config)" in decisions["modeling"]["reason"] + assert decisions["explainability"]["status"] == "not_applicable" + assert decisions["explainability"]["blocked_by"] == ["modeling"] + + +def test_execution_stages_respect_dependency_order(): + modules = plan_execution_modules( + signals=_signals(), + analysis_mode="research", + target_column="target", + ) + stages = modules["execution_stages"] + positions = { + module: stage["stage"] + for stage in stages + for module in stage["modules"] + } + + assert positions["target_intelligence"] < positions["modeling"] + assert positions["modeling"] < positions["explainability"] + assert modules["runnable_modules"] == [ + module + for stage in stages + for module in stage["modules"] + ] + + +def test_plan_exposes_scheduler_ready_stages(): + frame = pd.DataFrame({ + "x": list(range(30)), + "y": [index * 2 for index in range(30)], + "target": [0, 1] * 15, + }) + + plan = framevitals.plan(frame, mode="research", target="target") + modules = plan.execution_modules + stages = modules["execution_stages"] + + assert stages + assert all("resource_classes" in stage for stage in stages) + assert "target_intelligence" in modules["runnable_modules"] + assert "modeling" in modules["runnable_modules"] + assert "explainability" in modules["runnable_modules"] diff --git a/tests/test_rag_runtime_config.py b/tests/test_rag_runtime_config.py new file mode 100644 index 0000000..42bf6f7 --- /dev/null +++ b/tests/test_rag_runtime_config.py @@ -0,0 +1,33 @@ +import pytest + +from framevitals.rag_index import ( + Fact, + _embedding_concurrency, + _rag_backend_override, + retrieve, +) + + +def test_rag_prefers_framevitals_environment_names(monkeypatch): + monkeypatch.setenv("DATALENS_RAG_BACKEND", "ollama") + monkeypatch.setenv("FRAMEVITALS_RAG_BACKEND", "tfidf") + assert _rag_backend_override() == "tfidf" + + +def test_rag_legacy_environment_names_remain_compatible(monkeypatch): + monkeypatch.delenv("FRAMEVITALS_RAG_BACKEND", raising=False) + monkeypatch.setenv("DATALENS_RAG_BACKEND", "tfidf") + assert _rag_backend_override() == "tfidf" + + +def test_invalid_embedding_concurrency_cannot_break_import_or_execution(monkeypatch): + monkeypatch.setenv("FRAMEVITALS_RAG_EMBED_CONCURRENCY", "not-an-int") + assert _embedding_concurrency() == 8 + + monkeypatch.setenv("FRAMEVITALS_RAG_EMBED_CONCURRENCY", "100000") + assert _embedding_concurrency() == 32 + + +def test_retrieve_rejects_non_positive_k(): + with pytest.raises(ValueError, match="k"): + retrieve("question", [Fact(path="x", text="x: 1")], k=0) diff --git a/tests/test_resource_hard_cap.py b/tests/test_resource_hard_cap.py new file mode 100644 index 0000000..6b687a0 --- /dev/null +++ b/tests/test_resource_hard_cap.py @@ -0,0 +1,51 @@ +import pandas as pd + +import framevitals +from framevitals.quality_diagnostics import run_quality_diagnostics + + +def test_materialized_quality_diagnostics_respect_subten_hard_cap(monkeypatch): + captured = {} + + def fake_quality(frame, **kwargs): + captured["frame_rows"] = len(frame) + captured["max_sample_rows"] = kwargs["max_sample_rows"] + return {"available": True, "execution": {"sample_rows": kwargs["max_sample_rows"]}} + + monkeypatch.setattr( + "framevitals.pipeline.run_quality_diagnostics", + fake_quality, + ) + + frame = pd.DataFrame({ + "x": list(range(20)), + "y": [index * 2 for index in range(20)], + }) + framevitals.analyze( + frame, + mode="standard", + max_sample_rows=4, + disabled_modules=[ + "anomaly_detection", + "time_series", + "cleaning", + "charts", + "ai", + ], + ) + + assert captured["frame_rows"] == 20 + assert captured["max_sample_rows"] == 4 + + +def test_quality_diagnostics_execute_with_subten_cap(): + frame = pd.DataFrame({ + "x": list(range(20)), + "y": [index * 2 for index in range(20)], + "category": ["a", "b"] * 10, + }) + + result = run_quality_diagnostics(frame, max_sample_rows=4) + + assert result["available"] is True + assert result["max_sample_rows"] == 4 diff --git a/tests/test_resource_overrides.py b/tests/test_resource_overrides.py new file mode 100644 index 0000000..153f305 --- /dev/null +++ b/tests/test_resource_overrides.py @@ -0,0 +1,80 @@ +import pandas as pd + +import framevitals +from framevitals.config import resolve_config + + +def _frame(rows: int = 40) -> pd.DataFrame: + return pd.DataFrame({ + "x": list(range(rows)), + "y": [index * 2 for index in range(rows)], + "group": ["a", "b"] * (rows // 2), + }) + + +def test_explicit_resource_caps_override_environment_and_config(monkeypatch): + monkeypatch.setenv("FRAMEVITALS_MAX_SAMPLE_ROWS", "90") + monkeypatch.setenv("FRAMEVITALS_MAX_RELATIONSHIP_PAIRS", "80") + + resolved = resolve_config( + { + "resources": { + "max_sample_rows": 70, + "max_relationship_pairs": 60, + "max_memory_heavy_parallelism": 4, + "max_streaming_profile_columns": 50, + } + }, + max_sample_rows=7, + max_relationship_pairs=6, + max_memory_heavy_parallelism=1, + max_streaming_profile_columns=5, + ) + + assert resolved.max_sample_rows == 7 + assert resolved.max_relationship_pairs == 6 + assert resolved.max_memory_heavy_parallelism == 1 + assert resolved.max_streaming_profile_columns == 5 + + +def test_public_plan_accepts_explicit_resource_caps(): + plan = framevitals.plan( + _frame(), + mode="standard", + max_sample_rows=5, + max_relationship_pairs=3, + max_memory_heavy_parallelism=1, + max_streaming_profile_columns=2, + ) + + assert plan.resource_policy == { + "max_sample_rows": 5, + "max_relationship_pairs": 3, + "max_memory_heavy_parallelism": 1, + "max_streaming_profile_columns": 2, + } + budget = plan.execution_budget + assert budget["quality_sample_rows"] <= 5 + assert budget["deep_statistics_sample_rows"] <= 5 + assert budget["anomaly_sample_rows"] <= 5 + assert budget["time_series_sample_rows"] <= 5 + assert budget["relationship_pair_budget"] <= 3 + assert budget["max_memory_heavy_parallelism"] == 1 + + +def test_public_analyze_accepts_explicit_resource_caps(): + result = framevitals.analyze( + _frame(), + mode="standard", + max_sample_rows=6, + max_relationship_pairs=2, + max_memory_heavy_parallelism=1, + ) + + assert result["execution"]["resource_policy"]["max_sample_rows"] == 6 + assert result["execution"]["resource_policy"]["max_relationship_pairs"] == 2 + budget = result["execution"]["budget"] + assert budget["quality_sample_rows"] <= 6 + assert budget["anomaly_sample_rows"] <= 6 + assert budget["relationship_pair_budget"] <= 2 + assert budget["max_memory_heavy_parallelism"] == 1 diff --git a/tests/test_safe_pandas_hardening.py b/tests/test_safe_pandas_hardening.py new file mode 100644 index 0000000..5ad0f34 --- /dev/null +++ b/tests/test_safe_pandas_hardening.py @@ -0,0 +1,26 @@ +import pandas as pd + +from framevitals.safe_pandas import safe_eval + + +def test_safe_eval_cannot_mutate_callers_dataframe(): + frame = pd.DataFrame({"keep": [1, 2], "drop_me": [3, 4]}) + + result = safe_eval("df.drop(columns=['drop_me'], inplace=True)", frame) + + assert result["ok"] is True + assert list(frame.columns) == ["keep", "drop_me"] + + +def test_safe_eval_rejects_pandas_query_string_surface(): + frame = pd.DataFrame({"x": [1, 2, 3]}) + result = safe_eval("df.query('x > 1')", frame) + assert result["ok"] is False + assert "Disallowed" in result["error"] + + +def test_safe_eval_rejects_deprecated_applymap_surface(): + frame = pd.DataFrame({"x": [1, 2, 3]}) + result = safe_eval("df.applymap(abs)", frame) + assert result["ok"] is False + assert "Disallowed" in result["error"] diff --git a/tests/test_security_hardening.py b/tests/test_security_hardening.py new file mode 100644 index 0000000..7612b1f --- /dev/null +++ b/tests/test_security_hardening.py @@ -0,0 +1,23 @@ +import pytest + +from framevitals.security import make_safe_filename, sanitize_csv_value, validate_file + + +def test_non_ascii_upload_name_preserves_supported_extension(): + validate_file("数据.csv") + assert make_safe_filename("数据.csv") == "dataset.csv" + + +def test_safe_filename_preserves_extension_after_path_stripping(): + assert make_safe_filename("../../CON.csv") == "_CON.csv" + + +def test_validate_file_rejects_empty_filename(): + with pytest.raises(ValueError, match="non-empty"): + validate_file("") + + +def test_csv_formula_sanitizer_handles_leading_whitespace_and_control_chars(): + assert sanitize_csv_value(" =SUM(A1:A2)").startswith("'") + assert sanitize_csv_value("\t=SUM(A1:A2)").startswith("'") + assert sanitize_csv_value("plain text") == "plain text" diff --git a/tests/test_snapshot_integrity.py b/tests/test_snapshot_integrity.py new file mode 100644 index 0000000..76fdf2b --- /dev/null +++ b/tests/test_snapshot_integrity.py @@ -0,0 +1,40 @@ +import json + +import pytest + +from framevitals.snapshots import SnapshotHistory, create_snapshot, load_snapshot + + +def _result(): + return { + "dataset_id": "fv_test", + "filename": "data.csv", + "analysis_mode": "quick", + "profile": { + "shape": {"rows": 1, "columns": 1}, + "dtypes": {"x": "int64"}, + "missing_percent": {"x": 0.0}, + }, + "health": {"overall_score": 100.0}, + "ml_readiness": {"score": 100.0}, + "findings": [], + "config": {}, + } + + +def test_load_snapshot_rejects_state_modified_after_fingerprinting(tmp_path): + snapshot = create_snapshot(_result()) + snapshot["state"]["health"]["overall_score"] = 1.0 + path = tmp_path / "tampered.json" + path.write_text(json.dumps(snapshot), encoding="utf-8") + + with pytest.raises(ValueError, match="fingerprint"): + load_snapshot(path) + + +def test_history_rejects_tampered_existing_snapshot(tmp_path): + snapshot = create_snapshot(_result()) + snapshot["state"]["analysis_mode"] = "research" + + with pytest.raises(ValueError, match="fingerprint"): + SnapshotHistory(tmp_path).add(snapshot) diff --git a/tests/test_streaming_resource_policy.py b/tests/test_streaming_resource_policy.py new file mode 100644 index 0000000..c62f457 --- /dev/null +++ b/tests/test_streaming_resource_policy.py @@ -0,0 +1,46 @@ +import pandas as pd + +from framevitals.execution import ExecutionPolicy, use_execution_policy +from framevitals.streaming_quality import run_streaming_quality_diagnostics + + +def test_streaming_quality_never_widens_hard_sample_cap(monkeypatch): + captured = {} + + def fake_quality(*args, **kwargs): + captured["max_sample_rows"] = kwargs["max_sample_rows"] + return { + "identifier_duplicates": [], + "quasi_constant_columns": [], + "duplicate_columns": [], + "coercion_candidates": [], + "category_normalisation": [], + "blank_strings": [], + "infinite_values": [], + "mixed_object_types": [], + "missingness_relationships": [], + "primary_key_candidates": [], + } + + monkeypatch.setattr( + "framevitals.streaming_quality.run_quality_diagnostics", + fake_quality, + ) + + sample = pd.DataFrame({"x": range(20)}) + profile = { + "columns": ["x"], + "missing_counts": {"x": 0}, + "duplicate_rows": 0, + } + + with use_execution_policy(ExecutionPolicy(max_sample_rows=4)): + run_streaming_quality_diagnostics( + sample, + profile=profile, + source_rows=100, + source_columns=1, + max_sample_rows=10, + ) + + assert captured["max_sample_rows"] == 4 diff --git a/tests/test_web_app_hardening.py b/tests/test_web_app_hardening.py new file mode 100644 index 0000000..5dc7aee --- /dev/null +++ b/tests/test_web_app_hardening.py @@ -0,0 +1,51 @@ +import pytest + +pytest.importorskip("flask") + +import app as web_app + + +def test_web_dataset_ids_reject_path_traversal(): + with pytest.raises(ValueError, match="dataset identifier"): + web_app._validate_dataset_id("../../etc/passwd") + assert web_app._validate_dataset_id("0123456789ab") == "0123456789ab" + + +def test_web_analysis_uses_canonical_mode_policy(monkeypatch): + captured = {} + + def fake_run_full_analysis(**kwargs): + captured.update(kwargs) + return {"dataset_id": kwargs["dataset_id"]} + + monkeypatch.setattr(web_app, "run_full_analysis", fake_run_full_analysis) + + web_app._run_web_analysis( + dataset_id="0123456789ab", + original_filename="data.csv", + analysis_mode="standard", + target_column=None, + dataframe=object(), + ) + + disabled = set(captured["disabled_modules"]) + assert "deep_statistics" in disabled + assert "text_profile" in disabled + assert "modeling" in disabled + assert "explainability" in disabled + + +def test_web_cache_is_bounded(monkeypatch): + monkeypatch.setattr(web_app, "_WEB_CACHE_LIMIT", 2) + with web_app.REPORT_LOCK: + web_app.ANALYSIS_CACHE.clear() + web_app.REPORT_JOBS.clear() + + web_app._cache_analysis("000000000001", {"value": 1}) + web_app._cache_analysis("000000000002", {"value": 2}) + web_app._cache_analysis("000000000003", {"value": 3}) + + with web_app.REPORT_LOCK: + assert list(web_app.ANALYSIS_CACHE) == ["000000000002", "000000000003"] + web_app.ANALYSIS_CACHE.clear() + web_app.REPORT_JOBS.clear()